From 4ae3dd849118526e467466198ba74edaadb9b609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E9=87=8E?= Date: Mon, 10 Aug 2026 16:55:28 +0800 Subject: [PATCH] feat: three-state update mode and fixed nudge growth interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update becomes a real 3-state policy (auto/check/manual) instead of a boolean autoUpdate plus two buttons: - type UpdateMode = "auto" | "check" | "manual"; config update.mode - update.ts refactored into checkLatestVersion() / installUpdate() / runScheduledUpdate() — checking no longer installs - manual mode never touches the network; only explicit user actions do - precedence: CLI > BILI_UPDATE_MODE > update.mode > legacy autoUpdate - registry metadata + tarball downloads reuse the upstream-proxy ProxyAgent egress so system-proxy users don't hit direct-fetch timeouts - Web UI three-state selector with current/latest/last-check/status; check failure no longer toasts a false "up to date" compress.nudgeGrowthTokens pins acp-kernel's adaptive nudge interval by setting nudge.growthFloor = nudge.growthCap = value; config PUT/reload hot-reloads kernelConfig, modelContextLimit, and compress for new requests (in-flight requests keep their snapshot). Tests: update-mode (auto/check/manual semantics, precedence, legacy migration, concurrent lock, proxy egress, Web UI endpoints) and nudge-growth-tokens (adaptive default, floor=cap, breakdown denominator, hot reload, emergencyThresholdPct survival). --- .gitignore | 1 + src/cli.ts | 41 +++- src/config.ts | 90 +++++++- src/server.ts | 73 +++++- src/update.ts | 315 +++++++++++++++++++------- src/web/api.ts | 61 ++++- src/web/client.ts | 10 +- src/web/index.ts | 3 + src/web/page.ts | 5 +- tests/codex-official.test.ts | 1 + tests/nudge-growth-tokens.test.ts | 248 +++++++++++++++++++++ tests/update-mode.test.ts | 320 +++++++++++++++++++++++++++ tests/upstream-proxy-routing.test.ts | 1 + tests/web-routing.test.ts | 2 + tests/zero-config-routing.test.ts | 1 + 15 files changed, 1073 insertions(+), 99 deletions(-) create mode 100644 tests/nudge-growth-tokens.test.ts create mode 100644 tests/update-mode.test.ts diff --git a/.gitignore b/.gitignore index dd8fe26..a698525 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.log .env +cc-switch/ diff --git a/src/cli.ts b/src/cli.ts index ad3253f..4edb403 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,8 @@ import { loadOptions, ensureConfigTemplate } from "./config.js"; import { startServer } from "./server.js"; import { configFile as defaultConfigFile } from "./paths.js"; -import { checkForUpdate, startAutoUpdate } from "./update.js"; +import { startAutoUpdate, installUpdate } from "./update.js"; +import { resolveProxy } from "./upstream-proxy.js"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -107,7 +108,8 @@ function parseArgs(argv: string[]): Parsed { overrides.ACP_DEBUG = "1"; break; case "--no-auto-update": - overrides.ACP_AUTO_UPDATE = "0"; + // Override the current run to manual only — never persist. + overrides.BILI_UPDATE_MODE = "manual"; break; case "--passthrough": overrides.ACP_PASSTHROUGH = "1"; @@ -189,8 +191,23 @@ export async function main(): Promise { return; } if (command === "update") { - // Manual one-shot update — bypasses the throttle. - await checkForUpdate({ packageName: PACKAGE_NAME, currentVersion: VERSION, autoUpdate: true }, true); + // Manual one-shot update — check + install now, bypasses the throttle. + const opts = loadOptions(); + const proxyUrl = resolveProxy(opts.routes, opts.proxy, "https://registry.npmjs.org", opts.proxyFallback); + const result = await installUpdate({ + packageName: PACKAGE_NAME, + currentVersion: VERSION, + mode: opts.updateMode, + ...(proxyUrl ? { proxyUrl } : {}), + }); + if (result.ok) { + process.stdout.write(result.installedTo === VERSION + ? `billion-context is up to date (v${VERSION})\n` + : `✔ billion-context updated ${VERSION} → ${result.installedTo}. Restart bili to finish.\n`); + } else { + process.stderr.write(`✖ update failed: ${result.error}\n`); + process.exitCode = 1; + } return; } @@ -226,9 +243,15 @@ export async function main(): Promise { claimCodexTakeover(process.pid); await startServer(opts); - // Start background auto-update after the server is listening so a slow - // registry check never delays startup or races the listen socket. - if (opts.autoUpdate) { - startAutoUpdate({ packageName: PACKAGE_NAME, currentVersion: VERSION, autoUpdate: true }); - } + // Start the background update scheduler after the server is listening so a + // slow registry check never delays startup or races the listen socket. + // startAutoUpdate branches internally: auto/check schedule periodic checks + // (auto also installs), manual schedules nothing. + const proxyUrl = resolveProxy(opts.routes, opts.proxy, "https://registry.npmjs.org", opts.proxyFallback); + startAutoUpdate({ + packageName: PACKAGE_NAME, + currentVersion: VERSION, + mode: opts.updateMode, + ...(proxyUrl ? { proxyUrl } : {}), + }); } diff --git a/src/config.ts b/src/config.ts index bd70e0f..e5730c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,12 @@ export type ProviderRoute = { export type ProviderRoutes = Record; // key = upstream URL prefix (the /bili/ string) export type PromptCacheRouting = "auto" | "enabled" | "disabled"; export type UpstreamProxyMode = "auto" | "manual" | "direct"; +/** Software self-update mode. + * - `auto`: startup + periodic check, newer version auto-installed. + * - `check`: startup + periodic check, only reports (never installs). + * - `manual`: no background checks at all; only the explicit Web UI / + * `bili update` action ever contacts the registry. */ +export type UpdateMode = "auto" | "check" | "manual"; /** Built-in context window for common model families, keyed by a lowercase * prefix. This is a FALLBACK used when the per-route model declaration in @@ -125,6 +131,15 @@ export type ProxyOptions = { proxyFallback?: ProxyFallbackOptions; modelContextLimit: number; kernelConfig: Config; + /** Explicit fixed "proactive nudge interval" (tokens). When set, acp-kernel's + * adaptive `nudgeGrowthTokens` is clamped to exactly this value (by setting + * nudge.growthFloor = nudge.growthCap = value). When unset, acp-kernel's + * adaptive default (≈5% of modelContextLimit, clamped to [20k, 50k]) is used. + * Used by Web UI / config to expose a stable "compress every X tokens" knob. */ + nudgeGrowthTokens?: number; + /** Origin of nudgeGrowthTokens for startup diag log: "env" | "config" | undefined + * (undefined = adaptive default, no user override). */ + nudgeGrowthTokensSource?: "env" | "config"; compress: { injectTool: boolean; injectNudge: boolean; @@ -135,7 +150,12 @@ export type ProxyOptions = { debug: boolean; dumpSse?: string; passthrough: boolean; + /** @deprecated replaced by `updateMode` (3-state). Kept for legacy migration + * only — `true` → "auto", `false` → "manual". New code should read updateMode. */ autoUpdate: boolean; + /** Software self-update mode (3-state). Resolved from env `BILI_UPDATE_MODE` > + * config `update.mode` > legacy `autoUpdate` (true→auto, false→manual) > "auto". */ + updateMode: UpdateMode; logFile?: string; /** MITM transparent-proxy mode. When enabled, an HTTP CONNECT handler is * attached so clients that only know how to set HTTP_PROXY (ZCode with a @@ -208,6 +228,31 @@ export function loadOptions(env: NodeJS.ProcessEnv = process.env): ProxyOptions } } const modelContextLimit = parseInt(env.ACP_MODEL_CONTEXT_LIMIT ?? `${fileConfig.modelContextLimit ?? 200000}`, 10); + // nudgeGrowthTokens: explicit fixed "proactive nudge interval". + // Priority: env ACP_NUDGE_GROWTH_TOKENS > config compress.nudgeGrowthTokens > + // acp-kernel adaptive default. Invalid values (0, negative, NaN, non-integer) + // are a hard config error — never a silent fallback. + const nudgeEnvRaw = env.ACP_NUDGE_GROWTH_TOKENS; + const nudgeConfigRaw = fileConfig.compress?.nudgeGrowthTokens; + const nudgeGrowthTokens = parseNudgeGrowthTokens( + nudgeEnvRaw !== undefined ? nudgeEnvRaw : nudgeConfigRaw, + nudgeEnvRaw !== undefined ? "env" : nudgeConfigRaw !== undefined ? "config" : undefined, + ); + // acp-kernel's defaultConfig overrides take a full Config, so we build the + // base default first, then override ONLY growthFloor/growthCap on the nudge + // block. emergencyThresholdPct and every other nudge default survive. + // Setting floor=cap=value makes the runtime-derived nudgeGrowthTokens constant. + const baseKernelConfig = defaultConfig(modelContextLimit); + const kernelConfig = nudgeGrowthTokens !== undefined + ? { + ...baseKernelConfig, + nudge: { + ...baseKernelConfig.nudge, + growthFloor: nudgeGrowthTokens, + growthCap: nudgeGrowthTokens, + }, + } + : baseKernelConfig; const biliProxy = nonEmpty(env.BILI_UPSTREAM_PROXY); const webProxy = nonEmpty(fileConfig.upstreamProxy); const configProxy = nonEmpty(fileConfig.proxy); @@ -244,6 +289,15 @@ export function loadOptions(env: NodeJS.ProcessEnv = process.env): ProxyOptions throw new Error(`[acp-config] invalid upstream proxy for ${url}: ${String(error)}`); } } + // Update mode: 3-state. Priority: env BILI_UPDATE_MODE > config update.mode > + // legacy autoUpdate (true→auto, false→manual) > "auto". The legacy + // ACP_AUTO_UPDATE env var still maps (0→manual) for backward compat. + const legacyAutoUpdate = (env.ACP_AUTO_UPDATE ?? (fileConfig.autoUpdate === false ? "0" : "1")) !== "0"; + const updateMode = parseUpdateMode( + env.BILI_UPDATE_MODE + ?? fileConfig.update?.mode + ?? (legacyAutoUpdate ? "auto" : "manual"), + ); return { port: Number.isFinite(port) ? port : 8787, host, @@ -254,7 +308,9 @@ export function loadOptions(env: NodeJS.ProcessEnv = process.env): ProxyOptions proxySource, proxyFallback, modelContextLimit, - kernelConfig: defaultConfig(modelContextLimit), + kernelConfig, + nudgeGrowthTokens, + nudgeGrowthTokensSource: nudgeEnvRaw !== undefined ? "env" : nudgeConfigRaw !== undefined ? "config" : undefined, compress: { injectTool: (env.ACP_COMPRESS_TOOL ?? (fileConfig.compress?.injectTool === false ? "0" : "1")) !== "0", injectNudge: (env.ACP_COMPRESS_NUDGE ?? (fileConfig.compress?.injectNudge === false ? "0" : "1")) !== "0", @@ -267,7 +323,8 @@ export function loadOptions(env: NodeJS.ProcessEnv = process.env): ProxyOptions debug: (env.ACP_DEBUG ?? (fileConfig.debug ? "1" : "0")) === "1", dumpSse: env.ACP_DUMP_SSE || fileConfig.dumpSse || undefined, passthrough: (env.ACP_PASSTHROUGH ?? (fileConfig.passthrough ? "1" : "0")) === "1", - autoUpdate: (env.ACP_AUTO_UPDATE ?? (fileConfig.autoUpdate === false ? "0" : "1")) !== "0", + autoUpdate: legacyAutoUpdate, + updateMode, logFile: env.ACP_LOG_FILE !== undefined ? (env.ACP_LOG_FILE || undefined) : fileConfig.logFile, mitm: { enabled: (env.BILI_MITM ?? (fileConfig.mitm?.enabled === false ? "0" : "1")) !== "0", @@ -295,11 +352,13 @@ type FileConfig = { debug?: boolean; dumpSse?: string; passthrough?: boolean; + /** @deprecated replaced by `update.mode` (3-state). true→"auto", false→"manual". */ autoUpdate?: boolean; + update?: { mode?: UpdateMode }; upstreamProxy?: string; upstreamProxyMode?: string; logFile?: string; - compress?: { injectTool?: boolean; injectNudge?: boolean }; + compress?: { injectTool?: boolean; injectNudge?: boolean; nudgeGrowthTokens?: number }; promptCache?: { routing?: string }; mitm?: { enabled?: boolean; domains?: string[] }; }; @@ -309,6 +368,31 @@ function nonEmpty(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +/** Strict parser for nudgeGrowthTokens. Accepts only a finite positive integer + * (0, negative, NaN, Infinity, fractional, or arbitrary strings are hard + * config errors — never a silent fallback). Returns undefined when unset. */ +export function parseNudgeGrowthTokens( + value: unknown, + source: "env" | "config" | undefined, +): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const raw = typeof value === "string" ? value.trim() : value; + if (raw === "") return undefined; + const num = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(num) || num <= 0 || !Number.isInteger(num)) { + const where = source === "env" ? "env ACP_NUDGE_GROWTH_TOKENS" : source === "config" ? "compress.nudgeGrowthTokens" : "nudgeGrowthTokens"; + throw new Error(`[acp-config] invalid ${where}: ${JSON.stringify(value)} — must be a finite positive integer (> 0)`); + } + return num; +} + +/** Parse the 3-state update mode. Invalid values are a hard config error. */ +export function parseUpdateMode(value: string | undefined): UpdateMode { + if (value === "auto" || value === "check" || value === "manual") return value; + if (value === undefined) return "auto"; + throw new Error(`[acp-config] invalid update mode: ${JSON.stringify(value)} — must be "auto", "check", or "manual"`); +} + function loadConfigFile(): FileConfig { const parsed = safeReadJson(configFile()); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { diff --git a/src/server.ts b/src/server.ts index baf1263..ff15cb9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,7 @@ import http from "node:http"; import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { createCore, type CompressionCore, type Config, type CoreMessage, type NudgeDecision, estimateTokensFast, renderNudgeText, deactivateBlock } from "acp-kernel"; import type { ProxyOptions } from "./config.js"; @@ -9,6 +11,7 @@ import { resolveContextLimit } from "./config.js"; import { contextFromRegistry, loadRegistry } from "./registry.js"; import { fetchWithTimeout, MAX_REQUEST_BYTES } from "./fetch-util.js"; import { formatUpstreamError, getUpstreamConnectionStatus, recordUpstreamConnection, resolveProxy, resolveProxyDecision, proxyDispatcher } from "./upstream-proxy.js"; +import { type UpdateOptions } from "./update.js"; import { anthropicToCore, coreToAnthropic, @@ -38,7 +41,7 @@ import { getSession, listSessions, type Session, initSessions, markDirty, flushA import { COMPRESS_TOOL, ACP_TOOLS_ANTHROPIC, ACP_TOOLS_OPENAI, ACP_TOOLS_RESPONSES, COMPRESS_TOOL_NAME, buildCompressSystemPrompt, buildCompressTextSystemPrompt } from "./compress-tool.js"; import { rewriteSseStream, rewriteJsonResponse, type RewriteCtx } from "./stream.js"; import { applyRanges } from "./stream.js"; -import { renderUI, handleCodexHistoryGet, handleCodexHistoryRepair, handleConfigGet, handleConfigPut } from "./web/index.js"; +import { renderUI, handleCodexHistoryGet, handleCodexHistoryRepair, handleConfigGet, handleConfigPut, handleUpdateStatus, handleUpdateCheck, handleUpdateInstall } from "./web/index.js"; import { reapOrphanBlocks } from "./orphan-gc.js"; import { getStore } from "./persist.js"; import { compressLoopStream } from "./compress-loop.js"; @@ -74,6 +77,35 @@ const UPSTREAM_HOP_HEADERS = new Set([ "content-encoding", ]); +/** Package metadata read from package.json next to the bundle (same trick as + * cli.ts — works in dev via tsx and in the bundled dist). */ +function packageMeta(): { name: string; version: string } { + try { + const here = fileURLToPath(import.meta.url); + const pkg = path.join(path.dirname(here), "..", "package.json"); + const data = JSON.parse(fs.readFileSync(pkg, "utf8")) as { name?: string; version?: string }; + return { name: data.name ?? "billion-context", version: data.version ?? "dev" }; + } catch { + return { name: "billion-context", version: "dev" }; + } +} + +/** Build the update options for this process: the update mode comes from the + * live opts (env > config > legacy), and the registry/tarball egress reuses + * the project's upstream-proxy decision so system-proxy users don't time out. */ +function buildUpdateOptions(opts: ProxyOptions): UpdateOptions { + const { name, version } = packageMeta(); + // Route registry + tarball downloads through the same proxy resolution the + // upstream traffic uses (global proxy > per-URL > system/HTTP_PROXY). + const proxyUrl = resolveProxy(opts.routes, opts.proxy, "https://registry.npmjs.org", opts.proxyFallback); + return { + packageName: name, + currentVersion: version, + mode: opts.updateMode, + ...(proxyUrl ? { proxyUrl } : {}), + }; +} + export function resolveUpstream(_opts: ProxyOptions, reqUrl: string, req?: http.IncomingMessage): { upstream: string; rewrittenUrl: string } | undefined { // MITM mode: the request arrived over a CONNECT tunnel we terminated // locally (client set HTTP_PROXY and issued CONNECT host:443). The socket @@ -131,6 +163,10 @@ export async function startServer(opts: ProxyOptions): Promise { // re-send oversized raw history and hang). await initSessions(); log("info", `[persist] ${getStore().enabled ? "enabled" : "disabled"}`); + // Startup diag: resolved proactive nudge interval and its source. + const nudgeVal = opts.nudgeGrowthTokens; + const nudgeSrc = opts.nudgeGrowthTokensSource === "env" ? "env" : opts.nudgeGrowthTokensSource === "config" ? "config" : "adaptive"; + log("info", `[acp-config] nudgeGrowthTokens=${nudgeVal ?? "adaptive"} source=${nudgeSrc}`); if (filePath) { log("info", `[log] writing to ${filePath}`); } @@ -342,9 +378,27 @@ async function handle( resetProxyCache(); for (const k of Object.keys(opts.routes)) delete opts.routes[k]; Object.assign(opts.routes, loadRoutes()); + // Hot-reload compression-related runtime config so a Web UI save + // (e.g. nudgeGrowthTokens 20000 → 200000) takes effect on the next + // request without restarting bili or Codex. In-flight requests keep + // their captured config; new requests read the fresh one. + opts.modelContextLimit = fresh.modelContextLimit; + opts.kernelConfig = fresh.kernelConfig; + opts.nudgeGrowthTokens = fresh.nudgeGrowthTokens; + opts.nudgeGrowthTokensSource = fresh.nudgeGrowthTokensSource; + opts.compress = fresh.compress; + opts.updateMode = fresh.updateMode; + opts.autoUpdate = fresh.autoUpdate; }, opts.port); } if (req.method === "POST" && req.url === "/__bili/config/reload") return handleConfigReload(opts, res, log); + if (req.method === "GET" && req.url === "/__bili/update/status") return handleUpdateStatus(res); + if (req.method === "POST" && req.url === "/__bili/update/check") { + return handleUpdateCheck(res, buildUpdateOptions(opts)); + } + if (req.method === "POST" && req.url === "/__bili/update/install") { + return handleUpdateInstall(res, buildUpdateOptions(opts)); + } if (req.method === "GET" && req.url === "/__bili/codex") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(getCodexTakeoverStatus())); @@ -573,18 +627,19 @@ function diagTagSummary(messages: CoreMessage[], sessionId: string, strategy: st return `[${sessionId}] processTurn: ${messages.length} msgs, renderTags=${strategy}, ${textTagged} text tagged, ${toolTagged} tool tagged (should be 0 with text-only)`; } -function diagNudge(turn: { nudge?: { shouldInject: boolean; reason: string; contextUsage: number; tier: number | null; breakdown?: Record } | null }, sessionId: string, tokenCount: number, limit: number): string { +export function diagNudge(turn: { nudge?: { shouldInject: boolean; reason: string; contextUsage: number; tier: number | null; breakdown?: Record } | null }, sessionId: string, tokenCount: number, limit: number): string { const n = turn.nudge; if (!n) return `[${sessionId}] nudge: unavailable`; const b = n.breakdown ?? {}; const pct = limit > 0 ? `${Math.round((tokenCount / limit) * 100)}%` : "?"; const growth = b["growth"] ?? 0; - const floor = b["growthFloor"] ?? 0; const interval = b["nudgeGrowthTokens"] ?? 0; const pendingT1 = b["pendingT1"] ?? 0; const ref = b["growthReference"] ?? 0; const inject = n.shouldInject ? `INJECT T${n.tier ?? "?"}` : "idle"; - return `[${sessionId}] nudge ${inject}: usage=${pct} (${tokenCount}/${limit}), growth=${growth}/${floor} (ref=${ref}, interval=${interval}), pendingT1=${pendingT1}/${interval}, reason="${n.reason.slice(0, 120)}"`; + // Denominator is acp-kernel's resolved nudgeGrowthTokens (the actual nudge + // threshold), never growthFloor — the two diverge under adaptive growth. + return `[${sessionId}] nudge ${inject}: usage=${pct} (${tokenCount}/${limit}), growth=${growth}/${interval} (ref=${ref}, interval=${interval}), pendingT1=${pendingT1}/${interval}, reason="${n.reason.slice(0, 120)}"`; } function prepareAnthropic( @@ -1373,6 +1428,16 @@ function handleConfigReload(opts: ProxyOptions, res: http.ServerResponse, log: ( // removed/changed don't leak for the process lifetime. The next request // re-creates the needed agent lazily via proxyDispatcher(). resetProxyCache(); + // Hot-reload compression config too: kernelConfig (nudgeGrowthTokens etc.) + // is read per-request from `config` (captured at request start), so new + // requests see the fresh value immediately without a restart. + const freshFull = loadOptions(); + opts.modelContextLimit = freshFull.modelContextLimit; + opts.kernelConfig = freshFull.kernelConfig; + opts.nudgeGrowthTokens = freshFull.nudgeGrowthTokens; + opts.nudgeGrowthTokensSource = freshFull.nudgeGrowthTokensSource; + opts.compress = freshFull.compress; + opts.updateMode = freshFull.updateMode; const names = Object.keys(fresh); log("info", `[acp-web] routes hot-reloaded (${names.length} providers): ${names.join(", ") || "(none)"}`); res.writeHead(200, { "content-type": "application/json" }); diff --git a/src/update.ts b/src/update.ts index 99d88aa..ae745a5 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,13 +1,18 @@ /** - * Auto-update: periodically checks the npm registry for a newer version of - * billion-context and installs it by downloading the tarball and extracting - * it over the current installation. + * Self-update: checks the npm registry for a newer version of billion-context + * and optionally installs it by downloading the tarball and extracting it over + * the current installation. * - * Why tarball (not `npm install -g`): - * - Users may not have installed via npm (homebrew, manual, etc.). - * - `npm install -g` needs global write permissions and may fail silently. - * - Tarball extraction works for any install location, as long as the - * install directory is writable. + * 3-state update mode (see `UpdateMode` in config.ts): + * - auto: startup + periodic check, newer version auto-installed. + * - check: startup + periodic check, only reports (never installs). + * - manual: no background checks at all; only the explicit Web UI / + * `bili update` action ever contacts the registry. + * + * The registry metadata request and the tarball download reuse the project's + * upstream-proxy network egress (undici ProxyAgent via `proxyDispatcher`) when + * a proxy is configured, so users behind a system/HTTP proxy never hit a + * direct-fetch timeout. * * Concurrency safety: * - An exclusive lock file prevents multiple bili processes from updating @@ -21,26 +26,91 @@ * cycle automatically. */ import { readFile, writeFile, mkdir, access, constants, rm, cp, unlink } from "node:fs/promises"; -import { execFile } from "node:child_process"; import * as tar from "tar"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { cacheDir } from "./paths.js"; import { log as loggerLog } from "./logger.js"; +import { proxyDispatcher } from "./upstream-proxy.js"; +import type { UpdateMode } from "./config.js"; const REGISTRY_BASE = "https://registry.npmjs.org"; const CHECK_INTERVAL_MS = 3 * 60 * 1000; -const THROTTLE_FILE = path.join(cacheDir(), ".update-check"); -const LOCK_FILE = path.join(cacheDir(), ".update-lock"); -const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/; /** Lock staleness threshold: if a lock file is older than this, it's considered * abandoned (crashed process) and can be stolen. */ const LOCK_STALE_MS = 2 * 60 * 1000; +// Evaluated per call (not at module load) so tests / containers that relocate +// XDG_CACHE_HOME after import still isolate the throttle + lock files. +function throttleFile(): string { + return path.join(cacheDir(), ".update-check"); +} +function lockFile(): string { + return path.join(cacheDir(), ".update-lock"); +} + let timer: ReturnType | undefined; let inFlight = false; let firstCheckDone = false; +export type UpdateOptions = { + /** Package name, e.g. "billion-context". */ + packageName: string; + /** Fallback version (read at startup). The actual version is re-read from + * disk on each check so that an in-place tarball update is immediately + * reflected without restart. */ + currentVersion: string; + /** 3-state update mode. `manual` never runs background checks. */ + mode: UpdateMode; + /** Optional upstream proxy URL. Registry metadata + tarball downloads are + * routed through this proxy's ProxyAgent so system-proxy users don't time + * out on direct fetches. Omit for direct egress. */ + proxyUrl?: string; +}; + +/** Latest registry check result — the "report" half of the update mechanism. + * Stored in `lastStatus` and surfaced by the Web UI status endpoint. */ +export type LatestCheck = { + latestVersion: string; + tarballUrl: string | undefined; + currentVersion: string; + hasUpdate: boolean; +}; + +let lastStatus: { + mode?: UpdateMode; + currentVersion?: string; + latest?: LatestCheck; + checkedAt?: number; + checkError?: string; + installError?: string; + installing?: boolean; +} = {}; + +/** Current observable update status (Web UI / API). mode and currentVersion are + * filled in by the last startAutoUpdate/checkLatestVersion call. */ +export function getUpdateStatus(): { + mode: UpdateMode; + currentVersion: string; + latestVersion?: string; + hasUpdate?: boolean; + checkedAt?: number; + checkError?: string; + installError?: string; + installing?: boolean; +} { + return { + mode: lastStatus.mode ?? "manual", + currentVersion: lastStatus.currentVersion ?? "dev", + latestVersion: lastStatus.latest?.latestVersion, + hasUpdate: lastStatus.latest?.hasUpdate, + checkedAt: lastStatus.checkedAt, + checkError: lastStatus.checkError, + installError: lastStatus.installError, + installing: lastStatus.installing, + }; +} + function parseVersion(v: string): number[] { return v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0); } @@ -59,7 +129,7 @@ function isNewer(latest: string, current: string): boolean { async function readLastCheck(): Promise { try { - const data = await readFile(THROTTLE_FILE, "utf-8"); + const data = await readFile(throttleFile(), "utf-8"); return parseInt(data.trim(), 10) || 0; } catch { return 0; @@ -68,8 +138,8 @@ async function readLastCheck(): Promise { async function writeLastCheck(ts: number): Promise { try { - await mkdir(path.dirname(THROTTLE_FILE), { recursive: true }); - await writeFile(THROTTLE_FILE, String(ts), "utf-8"); + await mkdir(path.dirname(throttleFile()), { recursive: true }); + await writeFile(throttleFile(), String(ts), "utf-8"); } catch { // best-effort } @@ -118,7 +188,7 @@ async function tryAcquireLock(): Promise<{ release: () => Promise } | null async function readLock(): Promise<{ pid: number; ts: number } | null> { try { - const raw = await readFile(LOCK_FILE, "utf-8"); + const raw = await readFile(lockFile(), "utf-8"); const data = JSON.parse(raw); if (typeof data.pid === "number" && typeof data.ts === "number") { return data; @@ -154,7 +224,7 @@ async function tryAcquireLock(): Promise<{ release: () => Promise } | null // crash during update permanently blocks all future auto-updates. loggerLog("info", `[update] stealing stale lock (pid=${existing.pid}, age=${Math.round(age / 1000)}s, alive=${holderAlive})`); try { - await unlink(LOCK_FILE); + await unlink(lockFile()); } catch (e) { const code = (e as NodeJS.ErrnoException).code; // ENOENT is fine (someone else already cleaned it). Anything else @@ -169,7 +239,7 @@ async function tryAcquireLock(): Promise<{ release: () => Promise } | null // Write our lock. Use flag "wx" to fail if file already exists. try { - await writeFile(LOCK_FILE, JSON.stringify({ pid, ts: now }), { flag: "wx" }); + await writeFile(lockFile(), JSON.stringify({ pid, ts: now }), { flag: "wx" }); } catch { // Lost the race — another process created the lock file first. const winner = await readLock(); @@ -190,49 +260,35 @@ async function tryAcquireLock(): Promise<{ release: () => Promise } | null release: async () => { const current = await readLock(); if (current?.pid === pid) { - await rm(LOCK_FILE, { force: true }).catch(() => {}); + await rm(lockFile(), { force: true }).catch(() => {}); } }, }; } -export type UpdateOptions = { - /** Package name, e.g. "billion-context". */ - packageName: string; - /** Fallback version (read at startup). The actual version is re-read from - * disk on each check so that an in-place tarball update is immediately - * reflected without restart. */ - currentVersion: string; - /** Enable auto-install when a newer version is found. */ - autoUpdate: boolean; -}; +/** Build fetch init with the upstream proxy's ProxyAgent (if configured). */ +function proxiedFetchInit(opts: UpdateOptions, extra: RequestInit = {}): RequestInit { + const init: Omit & { dispatcher?: object } = { ...extra }; + const dispatcher = opts.proxyUrl ? proxyDispatcher(opts.proxyUrl) : undefined; + if (dispatcher) init.dispatcher = dispatcher; + return init as RequestInit; +} -/** Run a single check (throttled unless `force`). Safe to call frequently. */ -export async function checkForUpdate(opts: UpdateOptions, force = false): Promise { - if (!opts.autoUpdate && !force) return; - if (inFlight) return; - inFlight = true; +/** The "check" half: query the npm registry for the latest version. Never + * installs anything. Returns the check result, or null if the registry is + * unreachable / the response is malformed. */ +export async function checkLatestVersion(opts: UpdateOptions): Promise { + const url = `${REGISTRY_BASE}/${opts.packageName}/latest`; try { - const now = Date.now(); - const lastCheck = await readLastCheck(); - const sinceLastSec = lastCheck ? ((now - lastCheck) / 1000 | 0) : -1; - if (!force && firstCheckDone && now - lastCheck < CHECK_INTERVAL_MS) { - const retryIn = ((CHECK_INTERVAL_MS - (now - lastCheck)) / 1000 | 0); - loggerLog("info", `[update] throttled \u2014 last checked ${sinceLastSec}s ago, retry in ${retryIn}s`); - return; - } - await writeLastCheck(now); - firstCheckDone = true; - loggerLog("info", `[update] checking npm registry for ${opts.packageName}${sinceLastSec < 0 ? " (startup check)" : sinceLastSec === 0 ? "" : ` (last check ${sinceLastSec}s ago)`}\u2026`); - - const url = `${REGISTRY_BASE}/${opts.packageName}/latest`; - const res = await fetch(url, { + const res = await fetch(url, proxiedFetchInit(opts, { signal: AbortSignal.timeout(5000), headers: { Accept: "application/json" }, - }); + })); if (!res.ok) { - loggerLog("warn", `[update] registry returned ${res.status} ${res.statusText}, skipping`); - return; + const error = `registry returned ${res.status} ${res.statusText}`; + loggerLog("warn", `[update] ${error}, skipping`); + lastStatus = { ...lastStatus, checkError: error, checkedAt: Date.now() }; + return null; } const data = (await res.json()) as { version?: string; @@ -240,49 +296,91 @@ export async function checkForUpdate(opts: UpdateOptions, force = false): Promis }; const latest = data.version; if (!latest) { - loggerLog("warn", `[update] registry response had no version, skipping`); - return; + const error = "registry response had no version"; + loggerLog("warn", `[update] ${error}, skipping`); + lastStatus = { ...lastStatus, checkError: error, checkedAt: Date.now() }; + return null; } - // Read current version from disk (not from startup constant) so that // a successful in-place update is detected without a restart. const installDir = await findInstallDir(opts.packageName); const diskVersion = installDir ? await readDiskVersion(installDir) : undefined; const currentVersion = diskVersion ?? opts.currentVersion; - - if (!isNewer(latest, currentVersion)) { + const result: LatestCheck = { + latestVersion: latest, + tarballUrl: data.dist?.tarball, + currentVersion, + hasUpdate: isNewer(latest, currentVersion), + }; + lastStatus = { ...lastStatus, latest: result, currentVersion, checkedAt: Date.now(), checkError: undefined }; + if (!result.hasUpdate) { loggerLog("info", `[update] current=${currentVersion} latest=${latest} (up to date)`); - return; + } else { + loggerLog("info", `[update] new version found: ${currentVersion} \u2192 ${latest}`); } + return result; + } catch (e) { + lastStatus = { ...lastStatus, checkError: String(e), checkedAt: Date.now() }; + loggerLog("warn", `[update] check failed: ${String(e)}`); + return null; + } +} - const tarballUrl = data.dist?.tarball; - if (!tarballUrl) { - loggerLog("warn", `[update] registry response for ${latest} had no tarball URL`); - return; +/** The "install" half: install the given version (or the latest known). + * Downloads the tarball, extracts to staging, verifies, then copies over the + * install dir. Never performs the check itself when `version` is provided; + * in that case `tarballUrl` should also be passed (the caller already checked + * and holds the dist URL). */ +export async function installUpdate( + opts: UpdateOptions, + version?: string, + tarballUrl?: string, +): Promise<{ ok: boolean; error?: string; installedTo?: string }> { + if (inFlight) { + return { ok: false, error: "another update is already in progress" }; + } + inFlight = true; + lastStatus = { ...lastStatus, installing: true, installError: undefined }; + try { + let targetVersion = version; + let currentVersion = opts.currentVersion; + if (!targetVersion) { + const check = await checkLatestVersion(opts); + if (!check) return { ok: false, error: lastStatus.checkError ?? "check failed" }; + currentVersion = check.currentVersion; + if (!check.hasUpdate) { + loggerLog("info", `[update] already up to date (${currentVersion})`); + return { ok: true, installedTo: currentVersion }; + } + targetVersion = check.latestVersion; + tarballUrl = check.tarballUrl; } - - loggerLog("info", `[update] new version found: ${currentVersion} \u2192 ${latest}, downloading\u2026`); - - // Acquire lock to prevent concurrent updates across processes. + const installDir = await findInstallDir(opts.packageName); const lock = await tryAcquireLock(); if (!lock) { - loggerLog("info", `[update] another process is updating, will check next cycle`); - return; + const error = "another process is updating, will check next cycle"; + loggerLog("info", `[update] ${error}`); + return { ok: false, error }; } try { - const result = await installViaTarball(latest, tarballUrl, installDir); + const result = await installViaTarball(targetVersion, tarballUrl, installDir, opts); if (result.ok) { - loggerLog("info", `[update] installed ${currentVersion} \u2192 ${latest}. Restart to finish.`); - } else { - loggerLog("warn", `[update] install failed: ${result.error}. Will retry next cycle.`); + loggerLog("info", `[update] installed ${currentVersion} \u2192 ${targetVersion}. Restart to finish.`); + return { ok: true, installedTo: targetVersion }; } + lastStatus = { ...lastStatus, installError: result.error }; + loggerLog("warn", `[update] install failed: ${result.error}. Will retry next cycle.`); + return { ok: false, error: result.error }; } finally { await lock.release(); } } catch (e) { - loggerLog("warn", `[update] check failed: ${String(e)}`); + lastStatus = { ...lastStatus, installError: String(e) }; + loggerLog("warn", `[update] install failed: ${String(e)}`); + return { ok: false, error: String(e) }; } finally { inFlight = false; + lastStatus = { ...lastStatus, installing: false }; } } @@ -292,12 +390,16 @@ export async function checkForUpdate(opts: UpdateOptions, force = false): Promis */ async function installViaTarball( version: string, - tarballUrl: string, + tarballUrl: string | undefined, installDir: string | undefined, + opts: UpdateOptions, ): Promise<{ ok: boolean; error?: string }> { if (!installDir) { return { ok: false, error: "cannot determine install directory (package.json not found walking up from running binary)" }; } + if (!tarballUrl) { + return { ok: false, error: `registry response for ${version} had no tarball URL` }; + } // Pre-flight: can we write to the install dir? try { @@ -306,10 +408,10 @@ async function installViaTarball( return { ok: false, error: `install dir not writable: ${installDir}` }; } - // Download tarball + // Download tarball (through the configured upstream proxy when present). let tgzBuffer: Buffer; try { - const tgzRes = await fetch(tarballUrl, { signal: AbortSignal.timeout(60_000) }); + const tgzRes = await fetch(tarballUrl, proxiedFetchInit(opts, { signal: AbortSignal.timeout(60_000) })); if (!tgzRes.ok) { return { ok: false, error: `tarball download failed: HTTP ${tgzRes.status} ${tgzRes.statusText}` }; } @@ -376,15 +478,64 @@ async function installViaTarball( return { ok: true }; } +/** Run a single scheduled pass. Behavior depends on `opts.mode`: + * - auto: check + auto-install when newer (throttled unless force). + * - check: check only (never installs). + * - manual: no-op — background checks never run. + * `force` bypasses the throttle (explicit user action, e.g. Web UI button). */ +export async function runScheduledUpdate(opts: UpdateOptions, force = false): Promise { + if (opts.mode === "manual" && !force) return; + if (inFlight) return; + inFlight = true; + let check: LatestCheck | null = null; + try { + const now = Date.now(); + const lastCheck = await readLastCheck(); + const sinceLastSec = lastCheck ? ((now - lastCheck) / 1000 | 0) : -1; + if (!force && firstCheckDone && now - lastCheck < CHECK_INTERVAL_MS) { + const retryIn = ((CHECK_INTERVAL_MS - (now - lastCheck)) / 1000 | 0); + loggerLog("info", `[update] throttled \u2014 last checked ${sinceLastSec}s ago, retry in ${retryIn}s`); + return; + } + await writeLastCheck(now); + firstCheckDone = true; + loggerLog("info", `[update] checking npm registry for ${opts.packageName}${sinceLastSec < 0 ? " (startup check)" : sinceLastSec === 0 ? "" : ` (last check ${sinceLastSec}s ago)`}\u2026`); + + check = await checkLatestVersion(opts); + if (!check || !check.hasUpdate) return; + if (opts.mode !== "auto") { + // check mode: report only — never install. + loggerLog("info", `[update] mode=${opts.mode}: update available (${check.currentVersion} \u2192 ${check.latestVersion}), not installing`); + return; + } + } catch (e) { + loggerLog("warn", `[update] check failed: ${String(e)}`); + return; + } finally { + inFlight = false; + } + // Install outside the check's in-flight hold: installUpdate manages its own + // exclusive guard, so overlapping scheduled passes can't double-install. + if (!check) return; + await installUpdate(opts, check.latestVersion, check.tarballUrl); +} + +/** Start the background schedule. `manual` mode logs once and returns without + * scheduling anything (no startup check, no periodic check). */ export function startAutoUpdate(opts: UpdateOptions): void { + lastStatus = { ...lastStatus, mode: opts.mode, currentVersion: opts.currentVersion }; + if (opts.mode === "manual") { + loggerLog("info", "[update] manual mode — background update checks disabled"); + return; + } + loggerLog("info", `[update] ${opts.mode} mode enabled (checking every ${CHECK_INTERVAL_MS / 1000 | 0}s)`); // First check after a short delay (don't block startup / don't race the // listening socket). - loggerLog("info", `[update] auto-update enabled (checking every ${CHECK_INTERVAL_MS / 1000 | 0}s)`); setTimeout(() => { - void checkForUpdate(opts); + void runScheduledUpdate(opts); }, 10_000); timer = setInterval(() => { - void checkForUpdate(opts); + void runScheduledUpdate(opts); }, CHECK_INTERVAL_MS); timer.unref?.(); } @@ -396,3 +547,11 @@ export function stopAutoUpdate(): void { timer = undefined; } } + +/** Reset module state (for tests). */ +export function _resetUpdateForTest(): void { + stopAutoUpdate(); + inFlight = false; + firstCheckDone = false; + lastStatus = {}; +} diff --git a/src/web/api.ts b/src/web/api.ts index 8bf69fb..30f2fe2 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -16,11 +16,14 @@ import { import { log } from "../logger.js"; import { validateHttpProxy } from "../upstream-proxy.js"; import { previewLegacyCodexHistory, repairLegacyCodexHistory } from "../codex-history.js"; +import { getUpdateStatus, runScheduledUpdate, installUpdate, type UpdateOptions } from "../update.js"; type ConfigShape = Record & { providers?: Record; upstreamProxy?: string; upstreamProxyMode?: string; + update?: { mode?: string }; + compress?: { nudgeGrowthTokens?: number }; }; function readConfig(): ConfigShape { @@ -64,12 +67,15 @@ function atomicWriteConfig(config: ConfigShape): void { export async function handleConfigGet(res: ServerResponse): Promise { const upstream = readUpstreamSettings(); + const config = readConfig(); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ path: configFile(), providers: readProviders(), upstreamProxy: upstream.proxy ?? null, upstreamProxyMode: upstream.mode, + updateMode: config.update?.mode ?? null, + nudgeGrowthTokens: config.compress?.nudgeGrowthTokens ?? null, }, null, 2)); } @@ -85,7 +91,11 @@ export async function handleConfigPut( const hasProviders = Object.prototype.hasOwnProperty.call(body, "providers"); const hasProxy = Object.prototype.hasOwnProperty.call(body, "upstreamProxy"); const hasMode = Object.prototype.hasOwnProperty.call(body, "upstreamProxyMode"); - if (!hasProviders && !hasProxy && !hasMode) return sendError(res, 400, "expected providers or upstream proxy settings"); + const hasUpdateMode = Object.prototype.hasOwnProperty.call(body, "updateMode"); + const hasNudgeGrowthTokens = Object.prototype.hasOwnProperty.call(body, "nudgeGrowthTokens"); + if (!hasProviders && !hasProxy && !hasMode && !hasUpdateMode && !hasNudgeGrowthTokens) { + return sendError(res, 400, "expected providers, upstream proxy settings, updateMode, or nudgeGrowthTokens"); + } const routes: Record = {}; if (hasProviders) { @@ -123,6 +133,25 @@ export async function handleConfigPut( return sendError(res, 400, "manual mode requires an upstream proxy URL"); } + let updateMode: string | undefined; + if (hasUpdateMode) { + if (body.updateMode !== null && (typeof body.updateMode !== "string" || !["auto", "check", "manual"].includes(body.updateMode))) { + return sendError(res, 400, "updateMode must be auto, check, manual, or null"); + } + updateMode = typeof body.updateMode === "string" ? body.updateMode : undefined; + } + let nudgeGrowthTokens: number | undefined; + if (hasNudgeGrowthTokens) { + if (body.nudgeGrowthTokens !== null && body.nudgeGrowthTokens !== undefined) { + const value = body.nudgeGrowthTokens; + const num = typeof value === "number" ? value : Number(String(value).trim()); + if (!Number.isFinite(num) || num <= 0 || !Number.isInteger(num)) { + return sendError(res, 400, "nudgeGrowthTokens must be a finite positive integer (> 0) or null"); + } + nudgeGrowthTokens = num; + } + } + const config = readConfig(); if (hasProviders) config.providers = routes; if (hasProxy) { @@ -130,6 +159,14 @@ export async function handleConfigPut( else delete config.upstreamProxy; } if (hasMode && mode) config.upstreamProxyMode = mode; + if (hasUpdateMode) { + if (updateMode) config.update = { ...(config.update ?? {}), mode: updateMode }; + else if (config.update) delete config.update.mode; + } + if (hasNudgeGrowthTokens) { + if (nudgeGrowthTokens) config.compress = { ...(config.compress ?? {}), nudgeGrowthTokens }; + else if (config.compress) delete config.compress.nudgeGrowthTokens; + } try { atomicWriteConfig(config); onChanged?.(); @@ -161,6 +198,28 @@ export async function handleCodexHistoryRepair(res: ServerResponse): Promise { + await runScheduledUpdate(opts, true); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(getUpdateStatus())); +} + +/** POST /__bili/update/install — check + install the latest version now. */ +export async function handleUpdateInstall(res: ServerResponse, opts: UpdateOptions): Promise { + const result = await installUpdate(opts); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: result.ok, error: result.error ?? null, installedTo: result.installedTo ?? null, ...getUpdateStatus() })); +} + function sendError(res: ServerResponse, status: number, message: string): void { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify({ error: message })); diff --git a/src/web/client.ts b/src/web/client.ts index 9c39468..d369a21 100644 --- a/src/web/client.ts +++ b/src/web/client.ts @@ -6,7 +6,11 @@ function busy(button,on,label){if(!button)return;if(on){button.dataset.label=but async function json(url,options){const response=await fetch(url,options);const data=await response.json().catch(()=>({}));if(!response.ok)throw new Error(data.error||data.detail||("HTTP "+response.status));return data} function showPage(name){document.querySelectorAll(".page").forEach((node)=>node.classList.toggle("active",node.id==="page-"+name));document.querySelectorAll(".nav button").forEach((node)=>node.classList.toggle("active",node.dataset.page===name));if(name==="sessions")loadSessions();if(name==="routing")loadRouting();if(name==="upstream"){loadUpstream();loadOverrides()}} document.querySelectorAll(".nav button").forEach((button)=>button.addEventListener("click",()=>showPage(button.dataset.page))); -async function loadConfig(){const data=await json("/__bili/config");byId("providers-json").value=JSON.stringify(data.providers||{},null,2);byId("proxy-url").value=data.upstreamProxy||"";document.querySelectorAll('input[name="proxy-mode"]').forEach((input)=>input.checked=input.value===(data.upstreamProxyMode||"direct"));return data} +async function loadConfig(){const data=await json("/__bili/config");byId("providers-json").value=JSON.stringify(data.providers||{},null,2);byId("proxy-url").value=data.upstreamProxy||"";document.querySelectorAll('input[name="proxy-mode"]').forEach((input)=>input.checked=input.value===(data.upstreamProxyMode||"direct"));byId("nudge-growth-tokens").value=data.nudgeGrowthTokens==null?"":String(data.nudgeGrowthTokens);document.querySelectorAll('input[name="update-mode"]').forEach((input)=>input.checked=input.value===(data.updateMode||"auto"));return data} +async function saveNudge(){const button=byId("save-nudge");busy(button,true,"保存中…");const raw=byId("nudge-growth-tokens").value.trim();try{await json("/__bili/config",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify({nudgeGrowthTokens:raw===""?null:Number(raw)})});toast("主动压缩间隔已热更新")}catch(error){toast(String(error),true)}finally{busy(button,false)}} +async function loadUpdateStatus(){try{const data=await json("/__bili/update/status");const modes={auto:"自动更新",check:"仅检查更新",manual:"手动更新"};byId("update-current").textContent=data.currentVersion||"—";byId("update-latest").textContent=data.latestVersion||"—";byId("update-checked").textContent=data.checkedAt?new Date(data.checkedAt).toLocaleString():"—";const badge=byId("update-badge");const installBtn=byId("update-install");const modeLabel=modes[data.mode]||data.mode||"自动更新";if(data.installing){badge.className="badge warn";badge.textContent="更新中…";installBtn.disabled=true}else if(data.hasUpdate){badge.className="badge ok";badge.textContent="有新版本";installBtn.disabled=false}else if(data.checkError){badge.className="badge warn";badge.textContent="检查失败";installBtn.disabled=false}else{badge.className="badge";badge.textContent=modeLabel;installBtn.disabled=false}const state=byId("update-state");state.className="status "+(data.checkError?"err":data.hasUpdate?"ok":"");state.textContent=data.installError?("安装失败:"+data.installError):data.checkError?("检查失败:"+data.checkError):data.hasUpdate?("发现新版本 "+data.latestVersion):"已是最新版本"}catch(error){byId("update-state").textContent=String(error)}} +async function checkUpdate(){const button=byId("update-check");busy(button,true,"检查中…");try{const data=await json("/__bili/update/check",{method:"POST"});toast(data.checkError?"检查失败:"+data.checkError:data.hasUpdate?"发现新版本 "+data.latestVersion:"已是最新版本");await loadUpdateStatus()}catch(error){toast(String(error),true)}finally{busy(button,false)}} +async function installUpdate(){const button=byId("update-install");if(!confirm("立即下载并安装最新版本?安装完成后需要重启 bili 生效。"))return;busy(button,true,"更新中…");try{const data=await json("/__bili/update/install",{method:"POST"});if(data.ok)toast(data.installedTo?"已更新到 v"+data.installedTo+",请重启 bili":"已是最新版本");else toast("更新失败:"+(data.error||"未知错误"),true);await loadUpdateStatus()}catch(error){toast(String(error),true)}finally{busy(button,false)}} async function loadRouting(){try{const data=await json("/__bili/codex");routeState=data.state;const badge=byId("route-badge");const used=data.state==="enabled"&&data.lastRequestAt;badge.className="badge "+(data.state==="enabled"?(used?"ok":"warn"):data.state==="conflict"?"err":"");badge.textContent=data.state==="enabled"?(used?"使用中":"等待首次请求"):data.state==="conflict"?"配置冲突":"未启用";byId("route-provider").textContent=data.providerId||(data.provider&&data.provider.id)||"—";byId("route-real").textContent=data.originalBaseUrl||(data.provider&&data.provider.baseUrl)||"—";byId("route-local").textContent=data.baseUrl||"—";byId("route-detail").textContent=data.detail||(data.state==="enabled"&&!used?"若当前 Codex 尚未接入,可重新打开 Codex。":"");const button=byId("route-toggle");button.textContent=data.state==="disabled"?"启用 Codex 路由":"恢复原配置";button.className="btn "+(data.state==="disabled"?"primary":"danger");button.disabled=false}catch(error){byId("route-detail").textContent=String(error)}} async function toggleRoute(){const button=byId("route-toggle");const action=routeState==="disabled"?"enable":"disable";busy(button,true,action==="enable"?"启用中…":"恢复中…");try{const data=await json("/__bili/codex/"+action,{method:"POST"});busy(button,false);await loadRouting();toast(data.detail||(action==="enable"?"Codex 路由已启用":"原配置已恢复"))}catch(error){toast(String(error),true)}finally{busy(button,false)}} async function copyRoute(){const button=byId("copy-route");busy(button,true,"复制中…");try{await navigator.clipboard.writeText(byId("route-local").textContent);toast("路由地址已复制")}catch(error){toast(String(error),true)}finally{busy(button,false)}} @@ -21,6 +25,6 @@ async function loadOverrides(){try{const data=await loadConfig();const providers async function saveOverrides(){const button=byId("save-overrides");busy(button,true,"保存中…");try{const data=await loadConfig();const providers=data.providers||{};document.querySelectorAll(".override-proxy").forEach((input)=>{const url=input.dataset.url;if(!url)return;if(!providers[url])providers[url]={};const val=input.value.trim();if(val)providers[url].proxy=val;else delete providers[url].proxy});await json("/__bili/config",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify({providers})});await json("/__bili/config/reload",{method:"POST"});toast("按 URL 覆盖已热更新");await loadOverrides()}catch(error){toast(String(error),true)}finally{busy(button,false)}} async function loadSessions(){try{const data=await json("/__bili/stats");const rows=(data.sessions||[]).map((item)=>""+escapeHtml(item.title||"—")+""+escapeHtml(item.protocol||"—")+""+escapeHtml(item.label||"—")+""+escapeHtml(item.requests)+""+escapeHtml(item.contextTokens)+""+escapeHtml(new Date(item.lastSeen).toLocaleString())+"").join("");byId("sessions-body").innerHTML=rows||'暂无会话'}catch(error){toast(String(error),true);throw error}} async function refreshSessions(){const button=byId("refresh-sessions");busy(button,true,"刷新中…");try{await loadSessions();toast("会话列表已刷新")}catch{}finally{busy(button,false)}} -byId("route-toggle").addEventListener("click",toggleRoute);byId("copy-route").addEventListener("click",copyRoute);document.querySelectorAll(".copy-btn").forEach((btn)=>btn.addEventListener("click",async()=>{busy(btn,true,"复制中…");try{await navigator.clipboard.writeText(btn.dataset.copy||"");toast("已复制")}catch(e){toast(String(e),true)}finally{busy(btn,false)}}));byId("save-upstream").addEventListener("click",saveUpstream);byId("test-upstream").addEventListener("click",testUpstream);byId("preview-history").addEventListener("click",previewHistory);byId("repair-history").addEventListener("click",repairHistory);byId("save-providers").addEventListener("click",saveProviders);byId("save-overrides").addEventListener("click",saveOverrides);byId("refresh-sessions").addEventListener("click",refreshSessions); -Promise.all([loadConfig(),loadRouting(),loadUpstream(),loadHistory()]).catch((error)=>toast(String(error),true));setInterval(()=>{if(byId("page-sessions").classList.contains("active"))loadSessions().catch(()=>{})},5000); +byId("route-toggle").addEventListener("click",toggleRoute);byId("copy-route").addEventListener("click",copyRoute);document.querySelectorAll(".copy-btn").forEach((btn)=>btn.addEventListener("click",async()=>{busy(btn,true,"复制中…");try{await navigator.clipboard.writeText(btn.dataset.copy||"");toast("已复制")}catch(e){toast(String(e),true)}finally{busy(btn,false)}}));byId("save-upstream").addEventListener("click",saveUpstream);byId("test-upstream").addEventListener("click",testUpstream);byId("preview-history").addEventListener("click",previewHistory);byId("repair-history").addEventListener("click",repairHistory);byId("save-providers").addEventListener("click",saveProviders);byId("save-overrides").addEventListener("click",saveOverrides);byId("refresh-sessions").addEventListener("click",refreshSessions);byId("save-nudge").addEventListener("click",saveNudge);document.querySelectorAll("[data-nudge]").forEach((btn)=>btn.addEventListener("click",()=>{byId("nudge-growth-tokens").value=btn.dataset.nudge||""}));document.querySelectorAll('input[name="update-mode"]').forEach((input)=>input.addEventListener("change",async()=>{try{await json("/__bili/config",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify({updateMode:input.checked?input.value:null})});toast("更新模式已保存:"+(input.checked?input.value:"auto"));await loadUpdateStatus()}catch(error){toast(String(error),true)}}));byId("update-check").addEventListener("click",checkUpdate);byId("update-install").addEventListener("click",installUpdate); +Promise.all([loadConfig(),loadRouting(),loadUpstream(),loadHistory(),loadUpdateStatus()]).catch((error)=>toast(String(error),true));setInterval(()=>{if(byId("page-sessions").classList.contains("active"))loadSessions().catch(()=>{})},5000); `; diff --git a/src/web/index.ts b/src/web/index.ts index 62e37b1..2e9f746 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -8,6 +8,9 @@ export { handleCodexHistoryRepair, handleConfigGet, handleConfigPut, + handleUpdateStatus, + handleUpdateCheck, + handleUpdateInstall, readProviders, readUpstreamSettings, } from "./api.js"; diff --git a/src/web/page.ts b/src/web/page.ts index 810be7a..c07b132 100644 --- a/src/web/page.ts +++ b/src/web/page.ts @@ -21,5 +21,8 @@ export function renderPage(origin: string, version: string): string {

Codex 历史记录

加载中

上游网络

控制 bili 如何访问真实 Provider,不会改变客户端的本地路由地址。

全局代理

当前来源
直连
有效代理
direct
系统 PAC
状态
尚未测试

按 URL 覆盖

为特定 Provider 单独设置上游代理,覆盖全局设置。留空 = 继承全局。

加载中

会话

ACP 压缩状态与上游用量。

标题协议标识请求上下文最后活动
-

高级设置

直接编辑 providers JSON(添加新 Provider、配置模型上下文窗口、按 URL 代理等);保存后立即热更新。

`; +

高级设置

压缩行为、软件更新与 providers JSON(添加新 Provider、配置模型上下文窗口、按 URL 代理等);保存后立即热更新。

+

主动压缩间隔

上下文自上次压缩提示后增长达到该数量时,ACP 才再次主动提示压缩。留空使用自适应策略。

+

软件更新

加载中
当前版本
最新版本
最后检查
状态
尚未检查
+
`; } diff --git a/tests/codex-official.test.ts b/tests/codex-official.test.ts index 01f6dbc..a4359ca 100644 --- a/tests/codex-official.test.ts +++ b/tests/codex-official.test.ts @@ -58,6 +58,7 @@ test("Codex official transport preserves OAuth headers, decodes bodies, and reba debug: false, passthrough: false, autoUpdate: false, + updateMode: "auto", mitm: { enabled: false, domains: [] }, }; const proxy = await startServer(opts); diff --git a/tests/nudge-growth-tokens.test.ts b/tests/nudge-growth-tokens.test.ts new file mode 100644 index 0000000..382ce5b --- /dev/null +++ b/tests/nudge-growth-tokens.test.ts @@ -0,0 +1,248 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import http from "node:http"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { defaultConfig } from "acp-kernel"; +import { loadOptions, parseNudgeGrowthTokens, type ProxyOptions } from "../src/config.ts"; +import { diagNudge, startServer } from "../src/server.ts"; +import { SessionStore, _setStoreForTest } from "../src/persist.ts"; +import { _setForTest as setRegistryForTest } from "../src/registry.ts"; + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +async function freePort(): Promise { + const server = http.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as { port: number }).port; + await close(server); + return port; +} + +/** Load options with a temp config file whose contents are `json`. */ +function loadWithConfig(json: string, env: Record = {}): ProxyOptions { + const root = path.join(tmpdir(), `bili-nudge-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + const file = path.join(root, "billion-context.json"); + writeFileSync(file, json, "utf8"); + const prev = process.env.BILI_CONFIG_FILE; + process.env.BILI_CONFIG_FILE = file; + try { + return loadOptions(env); + } finally { + if (prev === undefined) delete process.env.BILI_CONFIG_FILE; + else process.env.BILI_CONFIG_FILE = prev; + rmSync(root, { recursive: true, force: true }); + } +} + +test("unset nudgeGrowthTokens keeps acp-kernel adaptive default unchanged", () => { + const opts = loadWithConfig("{}"); + const base = defaultConfig(opts.modelContextLimit); + assert.equal(opts.nudgeGrowthTokens, undefined); + assert.deepEqual(opts.kernelConfig.nudge, base.nudge, "nudge block must be byte-identical to acp-kernel default"); + assert.deepEqual(opts.kernelConfig, base, "whole kernel config must match default when unset"); +}); + +test("config compress.nudgeGrowthTokens=200000 sets growthFloor=growthCap=200000", () => { + const opts = loadWithConfig('{"compress":{"nudgeGrowthTokens":200000}}'); + assert.equal(opts.nudgeGrowthTokens, 200000); + assert.equal(opts.nudgeGrowthTokensSource, "config"); + assert.equal(opts.kernelConfig.nudge.growthFloor, 200000); + assert.equal(opts.kernelConfig.nudge.growthCap, 200000); +}); + +test("env ACP_NUDGE_GROWTH_TOKENS overrides config file", () => { + const opts = loadWithConfig('{"compress":{"nudgeGrowthTokens":50000}}', { ACP_NUDGE_GROWTH_TOKENS: "200000" }); + assert.equal(opts.nudgeGrowthTokens, 200000); + assert.equal(opts.nudgeGrowthTokensSource, "env"); + assert.equal(opts.kernelConfig.nudge.growthFloor, 200000); + assert.equal(opts.kernelConfig.nudge.growthCap, 200000); +}); + +test("invalid nudgeGrowthTokens values are hard config errors", () => { + for (const [label, value] of [ + ["0", "0"], + ["negative", "-1"], + ["NaN", "NaN"], + ["Infinity", "Infinity"], + ["string", "abc"], + ["fractional", "1.5"], + ] as const) { + assert.throws( + () => parseNudgeGrowthTokens(value, "env"), + /invalid env ACP_NUDGE_GROWTH_TOKENS/, + `${label} must be rejected`, + ); + } + assert.throws(() => loadWithConfig('{"compress":{"nudgeGrowthTokens":0}}'), /invalid compress\.nudgeGrowthTokens/); + assert.throws(() => loadWithConfig("{}", { ACP_NUDGE_GROWTH_TOKENS: "-1" }), /invalid env ACP_NUDGE_GROWTH_TOKENS/); +}); + +test("empty-string nudgeGrowthTokens is treated as unset (returns undefined)", () => { + assert.equal(parseNudgeGrowthTokens("", "env"), undefined); + assert.equal(parseNudgeGrowthTokens(" ", "config"), undefined); + assert.equal(parseNudgeGrowthTokens(undefined, undefined), undefined); +}); + +test("emergencyThresholdPct and every other nudge default survive the override", () => { + const base = defaultConfig(200000); + const opts = loadWithConfig('{"compress":{"nudgeGrowthTokens":200000}}', { ACP_MODEL_CONTEXT_LIMIT: "200000" }); + const baseNudge = base.nudge; + const cfgNudge = opts.kernelConfig.nudge; + assert.equal(cfgNudge.emergencyThresholdPct, baseNudge.emergencyThresholdPct, "emergency must never change"); + assert.equal(cfgNudge.maxContextLimitPct, baseNudge.maxContextLimitPct); + assert.equal(cfgNudge.minContextLimitPct, baseNudge.minContextLimitPct); + assert.equal(cfgNudge.frequency, baseNudge.frequency); + assert.equal(cfgNudge.iterationThreshold, baseNudge.iterationThreshold); + assert.equal(cfgNudge.force, baseNudge.force); + assert.equal(cfgNudge.growthRatio, baseNudge.growthRatio); + assert.equal(cfgNudge.minGrowthFloor, baseNudge.minGrowthFloor); + assert.equal(cfgNudge.minGrowthRatio, baseNudge.minGrowthRatio); + // Only growthFloor/growthCap may differ. + assert.equal(cfgNudge.growthFloor, 200000); + assert.equal(cfgNudge.growthCap, 200000); +}); + +test("model-specific context override keeps the fixed nudge interval", () => { + const opts = loadWithConfig('{"compress":{"nudgeGrowthTokens":200000}}'); + // server.ts builds reqConfig = { ...config, modelContextLimit: limit } for + // per-request model windows. The spread keeps the nudge sub-object, so the + // user's fixed interval must survive any modelContextLimit. + for (const limit of [258000, 400000, 1000000]) { + const reqConfig = { ...opts.kernelConfig, modelContextLimit: limit }; + assert.equal(reqConfig.nudge.growthFloor, 200000, `floor must survive modelContextLimit=${limit}`); + assert.equal(reqConfig.nudge.growthCap, 200000, `cap must survive modelContextLimit=${limit}`); + assert.equal(reqConfig.nudge.emergencyThresholdPct, 0.8, "emergency must survive"); + } +}); + +test("diagNudge uses breakdown nudgeGrowthTokens as the denominator", () => { + const turn = { + nudge: { + shouldInject: false, + reason: "idle", + contextUsage: 0.5, + tier: null, + breakdown: { + growth: 31667, + pendingT1: 7766, + nudgeGrowthTokens: 200000, + growthReference: 100, + growthFloor: 20000, + }, + }, + }; + const line = diagNudge(turn, "sess-1", 100000, 200000); + assert.match(line, /growth=31667\/200000/, "growth denominator must be nudgeGrowthTokens"); + assert.match(line, /pendingT1=7766\/200000/, "pendingT1 denominator must be nudgeGrowthTokens"); + assert.match(line, /interval=200000/); +}); + +test("PUT /__bili/config hot-reloads kernelConfig (20000 → 200000)", async () => { + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const root = path.join(tmpdir(), `bili-nudge-put-${process.pid}-${Date.now()}`); + mkdirSync(root, { recursive: true }); + const biliConfig = path.join(root, "billion-context.json"); + writeFileSync(biliConfig, '{"compress":{"nudgeGrowthTokens":20000}}\n', "utf8"); + const prevConfig = process.env.BILI_CONFIG_FILE; + process.env.BILI_CONFIG_FILE = biliConfig; + const port = await freePort(); + const opts: ProxyOptions = { + port, + host: "127.0.0.1", + upstream: "http://127.0.0.1:1", + routes: {}, + proxy: "", + proxyMode: "direct", + proxySource: "direct", + modelContextLimit: 200_000, + kernelConfig: defaultConfig(200_000), + nudgeGrowthTokens: 20000, + nudgeGrowthTokensSource: "config", + compress: { injectTool: true, injectNudge: true }, + promptCache: { routing: "auto" }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + updateMode: "auto", + mitm: { enabled: false, domains: [] }, + }; + const proxy = await startServer(opts); + if (!proxy.listening) await once(proxy, "listening"); + const base = `http://127.0.0.1:${port}`; + try { + const put = await fetch(`${base}/__bili/config`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nudgeGrowthTokens: 200000 }), + }); + assert.equal(put.status, 200); + // The PUT callback mutates the shared opts object in place, so the very + // next request sees the new interval — no restart required. + assert.equal(opts.nudgeGrowthTokens, 200000, "opts.nudgeGrowthTokens hot-reloaded"); + assert.equal(opts.kernelConfig.nudge.growthFloor, 200000, "kernelConfig hot-reloaded"); + assert.equal(opts.kernelConfig.nudge.growthCap, 200000); + assert.equal(opts.kernelConfig.nudge.emergencyThresholdPct, 0.8, "emergency survives hot reload"); + const readback = await (await fetch(`${base}/__bili/config`)).json() as { nudgeGrowthTokens: number }; + assert.equal(readback.nudgeGrowthTokens, 200000); + } finally { + await close(proxy); + if (prevConfig === undefined) delete process.env.BILI_CONFIG_FILE; else process.env.BILI_CONFIG_FILE = prevConfig; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("POST /__bili/config/reload hot-reloads kernelConfig from the config file", async () => { + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const root = path.join(tmpdir(), `bili-nudge-reload-${process.pid}-${Date.now()}`); + mkdirSync(root, { recursive: true }); + const biliConfig = path.join(root, "billion-context.json"); + writeFileSync(biliConfig, '{"compress":{"nudgeGrowthTokens":200000}}\n', "utf8"); + const prevConfig = process.env.BILI_CONFIG_FILE; + process.env.BILI_CONFIG_FILE = biliConfig; + const port = await freePort(); + const opts: ProxyOptions = { + port, + host: "127.0.0.1", + upstream: "http://127.0.0.1:1", + routes: {}, + proxy: "", + proxyMode: "direct", + proxySource: "direct", + modelContextLimit: 200_000, + kernelConfig: defaultConfig(200_000), // stale: interval not yet applied + compress: { injectTool: true, injectNudge: true }, + promptCache: { routing: "auto" }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + updateMode: "auto", + mitm: { enabled: false, domains: [] }, + }; + const proxy = await startServer(opts); + if (!proxy.listening) await once(proxy, "listening"); + const base = `http://127.0.0.1:${port}`; + try { + const reload = await fetch(`${base}/__bili/config/reload`, { method: "POST" }); + assert.equal(reload.status, 200); + assert.equal(opts.kernelConfig.nudge.growthFloor, 200000, "reload applied nudgeGrowthTokens"); + assert.equal(opts.kernelConfig.nudge.growthCap, 200000); + assert.equal(opts.kernelConfig.nudge.emergencyThresholdPct, 0.8); + } finally { + await close(proxy); + if (prevConfig === undefined) delete process.env.BILI_CONFIG_FILE; else process.env.BILI_CONFIG_FILE = prevConfig; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/update-mode.test.ts b/tests/update-mode.test.ts new file mode 100644 index 0000000..e0cdb50 --- /dev/null +++ b/tests/update-mode.test.ts @@ -0,0 +1,320 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import http from "node:http"; +import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { loadOptions, parseUpdateMode, type UpdateMode, type ProxyOptions } from "../src/config.ts"; +import { + checkLatestVersion, + installUpdate, + runScheduledUpdate, + startAutoUpdate, + stopAutoUpdate, + getUpdateStatus, + _resetUpdateForTest, + type UpdateOptions, +} from "../src/update.ts"; +import { startServer } from "../src/server.ts"; +import { SessionStore, _setStoreForTest } from "../src/persist.ts"; +import { _setForTest as setRegistryForTest } from "../src/registry.ts"; + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +async function freePort(): Promise { + const server = http.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as { port: number }).port; + await close(server); + return port; +} + +/** Point XDG_CACHE_HOME at a fresh temp dir so update lock/throttle files never + * touch the developer's real cache. Restores on cleanup. */ +function useTempCache(): { root: string; restore: () => void } { + const root = path.join(tmpdir(), `bili-update-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + const prev = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = root; + return { + root, + restore: () => { + if (prev === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prev; + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +function updateOpts(overrides: Partial = {}): UpdateOptions { + return { + packageName: "billion-context", + currentVersion: "0.0.1", + mode: "auto", + ...overrides, + }; +} + +type FetchCall = { url: string; init?: RequestInit }; +type StubResult = { ok: boolean; status?: number; statusText?: string; body?: unknown }; +function stubFetch( + results: Array, + intercept: (url: string) => boolean = () => true, +): { calls: FetchCall[]; restore: () => void } { + const original = globalThis.fetch; + const calls: FetchCall[] = []; + let index = 0; + globalThis.fetch = (async (input: string | URL, init?: RequestInit): Promise => { + const url = String(input); + // Let non-registry requests (e.g. the test's own fetch to the local Web + // UI) go through untouched — only stub the update network egress. + if (!intercept(url)) return original(input, init); + calls.push({ url, init }); + const r = results[Math.min(index, results.length - 1)]; + index++; + return new Response(r.ok ? JSON.stringify(r.body ?? {}) : String(r.body ?? "error"), { + status: r.status ?? (r.ok ? 200 : 500), + statusText: r.statusText ?? (r.ok ? "OK" : ""), + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return { + calls, + restore: () => { globalThis.fetch = original; }, + }; +} + +test("parseUpdateMode accepts only auto/check/manual", () => { + assert.equal(parseUpdateMode("auto"), "auto"); + assert.equal(parseUpdateMode("check"), "check"); + assert.equal(parseUpdateMode("manual"), "manual"); + assert.equal(parseUpdateMode(undefined), "auto"); + assert.throws(() => parseUpdateMode("weekly"), /invalid update mode/); +}); + +test("legacy autoUpdate migrates: true→auto, false→manual", () => { + // Isolate from the developer's real config file (which may set update.mode + // via the Web UI) — config update.mode would win over the legacy env var. + const root = path.join(tmpdir(), `bili-legacy-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + const file = path.join(root, "billion-context.json"); + writeFileSync(file, "{}\n", "utf8"); + const prev = process.env.BILI_CONFIG_FILE; + process.env.BILI_CONFIG_FILE = file; + try { + const withTrue = loadOptions({ ACP_AUTO_UPDATE: "1" }); + assert.equal(withTrue.updateMode, "auto"); + const withFalse = loadOptions({ ACP_AUTO_UPDATE: "0" }); + assert.equal(withFalse.updateMode, "manual"); + } finally { + if (prev === undefined) delete process.env.BILI_CONFIG_FILE; else process.env.BILI_CONFIG_FILE = prev; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("update mode precedence: BILI_UPDATE_MODE env > config update.mode > legacy autoUpdate", () => { + const root = path.join(tmpdir(), `bili-update-mode-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + const file = path.join(root, "billion-context.json"); + writeFileSync(file, '{"autoUpdate":false,"update":{"mode":"check"}}\n', "utf8"); + const prev = process.env.BILI_CONFIG_FILE; + process.env.BILI_CONFIG_FILE = file; + try { + // env wins over config update.mode + assert.equal(loadOptions({ BILI_UPDATE_MODE: "manual" }).updateMode, "manual"); + assert.equal(loadOptions({ BILI_UPDATE_MODE: "auto" }).updateMode, "auto"); + // config update.mode wins over legacy autoUpdate + assert.equal(loadOptions({}).updateMode, "check"); + } finally { + if (prev === undefined) delete process.env.BILI_CONFIG_FILE; else process.env.BILI_CONFIG_FILE = prev; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("manual mode: runScheduledUpdate makes no network request", async () => { + const cache = useTempCache(); + const stub = stubFetch([{ ok: true, body: { version: "9.9.9" } }]); + _resetUpdateForTest(); + try { + await runScheduledUpdate(updateOpts({ mode: "manual" })); + assert.equal(stub.calls.length, 0, "manual mode must never contact the registry"); + // startup of manual mode also schedules nothing + startAutoUpdate(updateOpts({ mode: "manual" })); + assert.equal(stub.calls.length, 0); + assert.equal(getUpdateStatus().mode, "manual"); + } finally { + stopAutoUpdate(); + stub.restore(); + cache.restore(); + } +}); + +test("check mode: reports but never installs", async () => { + const cache = useTempCache(); + const stub = stubFetch([{ ok: true, body: { version: "9.9.9", dist: { tarball: "https://registry.npmjs.org/x.tgz" } } }]); + _resetUpdateForTest(); + try { + await runScheduledUpdate(updateOpts({ mode: "check" }), true); + // Only the registry metadata request happened — no tarball download. + assert.equal(stub.calls.length, 1); + assert.match(stub.calls[0]!.url, /\/latest$/); + const status = getUpdateStatus(); + assert.equal(status.latestVersion, "9.9.9"); + assert.equal(status.hasUpdate, true); + assert.equal(status.installError, undefined, "check mode must never attempt install"); + } finally { + stub.restore(); + cache.restore(); + } +}); + +test("auto mode: checks and attempts install (tarball download via proxy egress)", async () => { + const cache = useTempCache(); + // First call: registry metadata (new version). Second: tarball download. + const stub = stubFetch([ + { ok: true, body: { version: "9.9.9", dist: { tarball: "https://registry.npmjs.org/x.tgz" } } }, + { ok: false, status: 500, statusText: "Internal Server Error", body: "boom" }, // tarball fetch fails → install reports error, doesn't crash + ]); + _resetUpdateForTest(); + try { + await runScheduledUpdate(updateOpts({ mode: "auto", proxyUrl: "http://127.0.0.1:9999" }), true); + assert.equal(stub.calls.length, 2, "auto mode must fetch metadata AND attempt tarball install"); + // The tarball fetch must carry the upstream proxy dispatcher. + assert.ok(stub.calls[1]!.init?.dispatcher, "tarball download must reuse the project ProxyAgent"); + const status = getUpdateStatus(); + assert.equal(status.installError, "tarball download failed: HTTP 500 Internal Server Error"); + } finally { + stub.restore(); + cache.restore(); + } +}); + +test("manual mode explicit checkLatestVersion still works", async () => { + const cache = useTempCache(); + const stub = stubFetch([{ ok: true, body: { version: "9.9.9" } }]); + _resetUpdateForTest(); + try { + const result = await checkLatestVersion(updateOpts({ mode: "manual" })); + assert.equal(result?.latestVersion, "9.9.9"); + assert.equal(result?.hasUpdate, true); + } finally { + stub.restore(); + cache.restore(); + } +}); + +test("concurrent update lock prevents a second installer", async () => { + const cache = useTempCache(); + _resetUpdateForTest(); + // Pre-create a lock file held by a LIVE process (ourselves) so the lock is + // not stale — the second installer must back off. + const lockDir = path.join(cache.root, "billion-context"); + mkdirSync(lockDir, { recursive: true }); + writeFileSync(path.join(lockDir, ".update-lock"), JSON.stringify({ pid: process.pid, ts: Date.now() }), "utf8"); + const stub = stubFetch([{ ok: true, body: { version: "9.9.9", dist: { tarball: "https://registry.npmjs.org/x.tgz" } } }]); + try { + const result = await installUpdate(updateOpts({ mode: "auto" })); + assert.equal(result.ok, false); + assert.match(result.error ?? "", /another process is updating/); + } finally { + stub.restore(); + cache.restore(); + } +}); + +test("Web UI: update status/check/install endpoints and three-state config", async () => { + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const cache = useTempCache(); + const root = path.join(tmpdir(), `bili-update-web-${process.pid}-${Date.now()}`); + mkdirSync(root, { recursive: true }); + const biliConfig = path.join(root, "billion-context.json"); + writeFileSync(biliConfig, '{"update":{"mode":"manual"}}\n', "utf8"); + const prevConfig = process.env.BILI_CONFIG_FILE; + process.env.BILI_CONFIG_FILE = biliConfig; + // Only registry.npmjs.org is stubbed — the test's own fetches to the local + // Web UI must pass through to the real server. Call order for registry + // traffic: check-endpoint metadata, install-endpoint metadata, then the + // tarball download (which fails → install reports an error, no crash). + const metadata = { version: "9.9.9", dist: { tarball: "https://registry.npmjs.org/x.tgz" } }; + const stub = stubFetch( + [{ ok: true, body: metadata }, { ok: true, body: metadata }, { ok: false, status: 500, statusText: "Internal Server Error", body: "boom" }], + (url) => url.startsWith("https://registry.npmjs.org"), + ); + const port = await freePort(); + const opts: ProxyOptions = { + port, + host: "127.0.0.1", + upstream: "http://127.0.0.1:1", + routes: {}, + proxy: "", + proxyMode: "direct", + proxySource: "direct", + modelContextLimit: 200_000, + kernelConfig: (await import("acp-kernel")).defaultConfig(200_000), + compress: { injectTool: true, injectNudge: true }, + promptCache: { routing: "auto" }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + updateMode: "manual", + mitm: { enabled: false, domains: [] }, + }; + const proxy = await startServer(opts); + if (!proxy.listening) await once(proxy, "listening"); + const base = `http://127.0.0.1:${port}`; + try { + // Three-state radios live in the settings page. + const ui = await (await fetch(`${base}/__bili/`)).text(); + assert.match(ui, /主动压缩间隔/); + assert.match(ui, /软件更新/); + assert.match(ui, /name="update-mode"/); + assert.match(ui, /value="check"/); + assert.match(ui, /data-nudge="200000"/); + + // Status endpoint reflects mode + no background check in manual mode. + const status = await (await fetch(`${base}/__bili/update/status`)).json() as { mode: string; currentVersion: string }; + assert.equal(status.mode, "manual"); + + // Explicit check works in manual mode (user-triggered only). + const checked = await (await fetch(`${base}/__bili/update/check`, { method: "POST" })).json() as { hasUpdate: boolean; latestVersion: string }; + assert.equal(checked.hasUpdate, true); + assert.equal(checked.latestVersion, "9.9.9"); + + // Install endpoint returns an error report (tarball download fails here). + const install = await (await fetch(`${base}/__bili/update/install`, { method: "POST" })).json() as { ok: boolean; error: string }; + assert.equal(install.ok, false); + assert.match(install.error ?? "", /tarball download failed/); + + // PUT updateMode switches the stored three-state config. + const put = await fetch(`${base}/__bili/config`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ updateMode: "check" }), + }); + assert.equal(put.status, 200); + assert.equal(opts.updateMode, "check", "updateMode hot-reloaded onto opts"); + const readback = JSON.parse(readFileSync(biliConfig, "utf8")); + assert.equal(readback.update.mode, "check"); + } finally { + await close(proxy); + stub.restore(); + cache.restore(); + if (prevConfig === undefined) delete process.env.BILI_CONFIG_FILE; else process.env.BILI_CONFIG_FILE = prevConfig; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("getUpdateStatus defaults when never started", () => { + _resetUpdateForTest(); + const status = getUpdateStatus(); + assert.equal(status.mode, "manual"); + assert.equal(status.currentVersion, "dev"); + assert.equal(status.hasUpdate, undefined); +}); diff --git a/tests/upstream-proxy-routing.test.ts b/tests/upstream-proxy-routing.test.ts index 879ae5e..5d402c4 100644 --- a/tests/upstream-proxy-routing.test.ts +++ b/tests/upstream-proxy-routing.test.ts @@ -121,6 +121,7 @@ test("/codex integration preserves query, subscription, account and thread heade debug: false, passthrough: true, autoUpdate: false, + updateMode: "auto", mitm: { enabled: false, domains: [] }, }; const bili = await startServer(opts); diff --git a/tests/web-routing.test.ts b/tests/web-routing.test.ts index d134a21..99bf626 100644 --- a/tests/web-routing.test.ts +++ b/tests/web-routing.test.ts @@ -71,6 +71,7 @@ test("Web UI exposes route, upstream and history controls without inline handler debug: false, passthrough: false, autoUpdate: false, + updateMode: "auto", mitm: { enabled: false, domains: [] }, }; const proxy = await startServer(opts); @@ -164,6 +165,7 @@ test("PUT /__bili/config with providers takes effect without a separate reload c debug: false, passthrough: false, autoUpdate: false, + updateMode: "auto", mitm: { enabled: false, domains: [] }, }; const proxy = await startServer(opts); diff --git a/tests/zero-config-routing.test.ts b/tests/zero-config-routing.test.ts index 0a34e10..8a0f88c 100644 --- a/tests/zero-config-routing.test.ts +++ b/tests/zero-config-routing.test.ts @@ -17,6 +17,7 @@ const BASE_OPTS: ProxyOptions = { debug: false, passthrough: false, autoUpdate: false, + updateMode: "auto", mitm: { enabled: false, domains: [] }, };