From f264f2ccae570e29f81884c1bd40ff94aaa5ab0a Mon Sep 17 00:00:00 2001 From: jiayuqi7813 <1783671926@qq.com> Date: Fri, 9 Oct 2026 15:54:38 +0800 Subject: [PATCH] release: v0.4.2 desktop updates and task retention --- .env.example | 7 +- CHANGELOG.md | 25 + README.md | 8 +- README_CN.md | 8 +- apps/desktop/package-lock.json | 4 +- apps/desktop/package.json | 2 +- apps/desktop/renderer/types.ts | 8 + apps/desktop/src/environment.cjs | 44 +- apps/desktop/src/main.cjs | 72 ++- apps/desktop/src/preload.cjs | 1 + apps/desktop/src/update-files.cjs | 19 +- apps/desktop/src/update-runner.cjs | 7 +- apps/desktop/src/updates.cjs | 577 +++++++++++++++++- apps/desktop/test/policy.test.cjs | 17 +- apps/web/routes/runs.py | 32 + apps/web/run_manager.py | 2 + apps/web/run_retention.py | 110 +++- apps/web/server.py | 8 +- .../ui/app/styles/competition-operations.css | 4 +- .../ui/components/PlatformUpdate.module.css | 4 +- apps/web/ui/components/RetentionSettings.tsx | 112 ++++ .../web/ui/components/WorkerOrchestration.tsx | 21 +- .../settings/SettingsCompatibility.tsx | 12 +- .../components/settings/SettingsContent.tsx | 1 + .../ui/components/settings/UpdateSettings.tsx | 85 +++ .../settings/agents/AgentsSettings.tsx | 18 +- apps/web/ui/components/settings/registry.json | 30 + apps/web/ui/lib/contracts/openapi.json | 100 +++ apps/web/ui/lib/contracts/openapi.ts | 19 +- apps/web/ui/lib/desktopChatBridge.ts | 10 + apps/web/ui/package-lock.json | 4 +- apps/web/ui/package.json | 2 +- docker-compose.release.yml | 12 +- docker/worker-slim/build.sh | 4 +- docker/worker/build.sh | 4 +- muteki/cli.py | 2 +- muteki/solver/container_exec.py | 2 +- muteki/version.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 40 files changed, 1262 insertions(+), 141 deletions(-) create mode 100644 apps/web/ui/components/RetentionSettings.tsx create mode 100644 apps/web/ui/components/settings/UpdateSettings.tsx diff --git a/.env.example b/.env.example index f12742c..8076b9e 100644 --- a/.env.example +++ b/.env.example @@ -69,12 +69,11 @@ MUTEKI_DEEPSEEK_API_KEY=sk-your-deepseek-key-here # MUTEKI_MAX_UPLOAD_FILES=20 # ── Optional: run retention policy ─────────────────────────────────────────── -# The web backend auto-archives idle unpinned runs, then deletes old archived -# runs. Disable only if you want to keep every local run forever. +# CTF and pentest default to archive after 15 idle days and delete archived +# runs after 30 idle days. Each mode's settings page stores its own policy; +# this flag disables all automatic actions globally. # MUTEKI_RETENTION_ENABLED=1 # MUTEKI_RETENTION_INTERVAL=3600 # sweep interval, seconds -# MUTEKI_ARCHIVE_DAYS=3 -# MUTEKI_DELETE_DAYS=7 # ── Optional: containerized worker image ───────────────────────────────────── # The single generic worker image used in CONTAINER mode (one image for all diff --git a/CHANGELOG.md b/CHANGELOG.md index a8dd82b..a7bbce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable public release changes are tracked here. +## 0.4.2 - 2026-10-09 + +### Fixed + +- Restored installed Agent CLI and native login discovery in regular desktop workspaces, including user-local, NVM, and Bun installations. Candidate startup checks retain isolated homes and credentials. +- Fixed desktop candidate identity checks to hash the physical ASAR archive and report recovery failures clearly. +- Reused the Agent credential view and fixed Switch CSS and narrow cards in the settings interface. + +### Changed + +- Moved version updates out of the CTF and penetration-testing settings into global settings. Existing update links redirect to the new page. + +### Added + +- Added global desktop update settings with official macOS release checks, GitHub SHA-256 verification, strict signature checks, isolated candidate startup, data backups, and restart-based app replacement. Service updates remain available in Web settings. +- Added separate CTF and penetration-testing retention settings with saved configuration and independent archive and delete switches. Defaults archive after 15 idle days and delete after 30 idle days. Idle time starts at the run's last activity, and deletion applies only to already archived tasks. + +### Upgrade + +- Use `muteki upgrade v0.4.2` or `./run.sh upgrade v0.4.2` for a managed Web installation, and add `--compose` for a container deployment. Existing `.env`, `state/`, credentials, and sessions remain outside the release bundle. +- Download the macOS `.dmg` or `.zip` matching your Mac from the release page. Windows and Linux desktop packages are not included. +- Retention thresholds now come from each mode's saved settings. `MUTEKI_ARCHIVE_DAYS` and `MUTEKI_DELETE_DAYS` no longer set these thresholds; `MUTEKI_RETENTION_ENABLED=0` still disables all automatic retention actions. +- Published 0.4.1 desktop installs need one manual install of 0.4.2 because 0.4.1 has no in-app update screen. Once on 0.4.2, official updates are available in global settings and verify the release SHA-256 and strict signatures before preparing a candidate. +- Local candidate updates require a stable Developer ID signature. The published macOS packages remain ad-hoc signed and are not notarized. There is no unsigned update bypass. + ## 0.4.1 - 2026-10-09 ### Added diff --git a/README.md b/README.md index c77615a..147b8c9 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,11 @@ After a month of engineering work, tuning, and fixes, Muteki is open source with ## Release notes -The current version is **0.4.1**. See [CHANGELOG.md](CHANGELOG.md) for new features, behavior changes, removals, and upgrade notes. +The current version is **0.4.2**. See [CHANGELOG.md](CHANGELOG.md) for new features, behavior changes, removals, and upgrade notes. -The [0.4.1 release](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.1) includes a macOS desktop app for Apple silicon and Intel, with a bundled local service and support for remote Muteki services. Windows and Linux desktop packages are not included in this release. Web and container deployments remain available. +The [0.4.2 release](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.2) includes a macOS desktop app for Apple silicon and Intel, with a bundled local service and support for remote Muteki services. Windows and Linux desktop packages are not included. Web and container deployments remain available. -The macOS packages are ad-hoc signed and are not notarized. Install desktop updates manually; the in-app signed-candidate update flow requires a stable Developer ID signature. +The macOS packages are ad-hoc signed and are not notarized. Published 0.4.1 desktop installs need a manual first install of 0.4.2 because they do not include the update screen. In 0.4.2, global settings can check and install official releases after GitHub SHA-256 and strict signature verification. Local candidate updates require a stable Developer ID signature. --- @@ -295,7 +295,7 @@ The UI remains at `http://localhost:3001` by default. `MUTEKI_HOST_DATA_ROOT` mu ```bash ./run.sh upgrade --check # Check the latest stable release ./run.sh install # Set up a managed installation -muteki upgrade v0.4.1 # Install this release +muteki upgrade v0.4.2 # Install this release muteki rollback # Return to the previous installed release muteki version # Show version and installation kind ``` diff --git a/README_CN.md b/README_CN.md index ba4a9e1..4a15be0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -95,11 +95,11 @@ tsecbench 托管模式下+deepseek-flash,排名13 ## 版本更新 -当前版本为 **0.4.1**。新增功能、行为变化、移除内容及升级说明见 [CHANGELOG.md](CHANGELOG.md)。 +当前版本为 **0.4.2**。新增功能、行为变化、移除内容及升级说明见 [CHANGELOG.md](CHANGELOG.md)。 -[0.4.1 发布页](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.1) 提供 Apple 芯片和 Intel Mac 的 macOS 客户端,内置本地服务,也可连接远程 Muteki 服务。本次暂不提供 Windows、Linux 桌面安装包,Web 和容器部署继续支持。 +[0.4.2 发布页](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.2) 提供 Apple 芯片和 Intel Mac 的 macOS 客户端,内置本地服务,也可连接远程 Muteki 服务。本次不提供 Windows、Linux 桌面安装包,Web 和容器部署继续支持。 -本次 macOS 安装包使用临时签名,尚未公证。桌面更新请手动安装;应用内候选版本更新流程需要稳定的 Developer ID 签名。 +本次 macOS 安装包使用临时签名,尚未公证。已发布的 0.4.1 桌面版没有应用内更新界面,需要先手动安装一次 0.4.2。0.4.2 可在全局设置中检查并安装官方版本,安装前会校验 GitHub SHA-256 和严格签名。本地候选版本更新需要稳定的 Developer ID 签名。 --- @@ -319,7 +319,7 @@ MUTEKI_WEB_PASSWORD='请替换为强密码' \ ```bash ./run.sh upgrade --check # 检查稳定版 ./run.sh install # 安装托管版本 -muteki upgrade v0.4.1 # 更新到本次版本 +muteki upgrade v0.4.2 # 更新到本次版本 muteki rollback # 回滚到上一个已安装版本 muteki version # 查看版本与安装形态 ``` diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 228dc70..105ed0f 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "muteki-desktop", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "muteki-desktop", - "version": "0.4.1", + "version": "0.4.2", "license": "AGPL-3.0-only", "devDependencies": { "@electron/osx-sign": "1.3.3", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ae7a23..a6ee812 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "muteki-desktop", "productName": "Muteki", - "version": "0.4.1", + "version": "0.4.2", "private": true, "type": "module", "description": "Muteki desktop with an isolated local workspace and remote service connections", diff --git a/apps/desktop/renderer/types.ts b/apps/desktop/renderer/types.ts index 128407b..996fbab 100644 --- a/apps/desktop/renderer/types.ts +++ b/apps/desktop/renderer/types.ts @@ -1,5 +1,12 @@ export type DesktopCommand = 'back' | 'forward' | 'reload' | 'sidebar' | 'search' | 'theme' | 'new-chat' | 'settings' | 'shortcuts'; export type DesktopWindowAction = 'minimize' | 'maximize' | 'close'; +export interface DesktopUpdateStatus { + state: 'idle' | 'checking' | 'current' | 'available' | 'downloading' | 'validating' | 'ready' | 'installing' | 'error'; + currentVersion: string; latestVersion?: string; progress?: number; message?: string; + error?: {code: string; message: string}; canInstall?: boolean; installReason?: string; + checkedAt?: string; releaseUrl?: string; + lastInstall?: {state: 'complete' | 'failed'; version: string; at: string; message: string; code?: string}; +} import type { DesktopSpeechInput, DesktopSpeechEvent } from '../../web/ui/lib/desktopChatBridge'; export interface DesktopNotificationScope { connectionVersion: number; serviceId: string; identityId: string } export interface DesktopNotificationEvent extends DesktopNotificationScope { @@ -31,6 +38,7 @@ export type DesktopState = { capabilities?: {version: number; host: string; entries?: Record; [key: string]: unknown}; }; export interface DesktopBridge { + updates: {getStatus(): Promise; check(): Promise; install(): Promise; onStatus(callback: (status: DesktopUpdateStatus) => void): () => void}; connectLocal(): Promise; consumeAnchor(id: number): Promise; getState(): Promise; connect(origin: string): Promise; diff --git a/apps/desktop/src/environment.cjs b/apps/desktop/src/environment.cjs index 17b0470..67f4e8a 100644 --- a/apps/desktop/src/environment.cjs +++ b/apps/desktop/src/environment.cjs @@ -1,5 +1,6 @@ const fs = require('node:fs'); const path = require('node:path'); +const os = require('node:os'); const { randomUUID, createHash } = require('node:crypto'); function atomicJson(file, value) { @@ -37,33 +38,62 @@ function desktopEnvironment({ appData, packaged, channel, root, workspace }) { } function serviceEnvironment(context, parent = process.env) { - // No inherited Python/Node injection, provider secrets, MUTEKI_* settings or - // native CLI homes. Tool binaries are discovered only in explicit locations. + const candidate = context.channel === 'candidate'; + const hostHome = path.resolve(parent.HOME || parent.USERPROFILE || os.homedir()); + // Candidate acceptance must not observe host PATH, HOME, or native logins. + // Regular desktop launches behave like a local app and discover host tools. const env = {}; for (const key of ['SYSTEMROOT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'SSL_CERT_FILE', 'SSL_CERT_DIR']) { if (parent[key]) env[key] = parent[key]; } + const hostPath = candidate ? [] : [ + ...(parent.PATH || '').split(path.delimiter).filter(Boolean), + path.join(hostHome, 'bin'), path.join(hostHome, '.local', 'bin'), + path.join(hostHome, '.bun', 'bin'), path.join(hostHome, '.kimi-code', 'bin'), path.join(hostHome, '.grok', 'bin'), + path.join(hostHome, '.volta', 'bin'), path.join(hostHome, '.npm-global', 'bin'), + ...nodeVersionBinPaths(hostHome), + ]; + const standardPath = [path.join(context.paths.tools, 'bin'), '/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', ...(process.platform === 'win32' ? [path.join(parent.SystemRoot || 'C:\\Windows', 'System32')] : [])]; + const runtimeHome = candidate ? context.paths.home : hostHome; Object.assign(env, { - HOME: context.paths.home, USERPROFILE: context.paths.home, - XDG_CONFIG_HOME: path.join(context.paths.home, '.config'), XDG_CACHE_HOME: path.join(context.paths.home, '.cache'), XDG_DATA_HOME: path.join(context.paths.home, '.local', 'share'), + HOME: runtimeHome, USERPROFILE: runtimeHome, + XDG_CONFIG_HOME: candidate ? path.join(runtimeHome, '.config') : parent.XDG_CONFIG_HOME || path.join(hostHome, '.config'), + XDG_CACHE_HOME: candidate ? path.join(runtimeHome, '.cache') : parent.XDG_CACHE_HOME || path.join(hostHome, '.cache'), + XDG_DATA_HOME: candidate ? path.join(runtimeHome, '.local', 'share') : parent.XDG_DATA_HOME || path.join(hostHome, '.local', 'share'), TMPDIR: context.paths.tmp, TMP: context.paths.tmp, TEMP: context.paths.tmp, - PATH: [path.join(context.paths.tools, 'bin'), '/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', ...(process.platform === 'win32' ? [path.join(parent.SystemRoot || 'C:\\Windows', 'System32')] : [])].join(path.delimiter), + PATH: [...new Set([...hostPath, ...standardPath])].join(path.delimiter), MUTEKI_ENVIRONMENT_ROOT: context.root, MUTEKI_ENVIRONMENT_ID: context.id, MUTEKI_ENVIRONMENT_CHANNEL: context.channel, MUTEKI_MANAGED_DESKTOP: '1', MUTEKI_SESSIONS_ROOT: context.paths.sessions, MUTEKI_STATE_ROOT: context.paths.state, MUTEKI_COORDINATOR_CONTROL_ROOT: path.join(context.paths.state, 'control'), MUTEKI_COORDINATOR_GRAPH_ROOT: path.join(context.paths.state, 'control', 'graphs'), - MUTEKI_ENV_FILE: path.join(context.root, 'config', '.env'), MUTEKI_HOST_DISCOVERY: '0', + MUTEKI_ENV_FILE: path.join(context.root, 'config', '.env'), MUTEKI_HOST_DISCOVERY: candidate ? '0' : '1', MUTEKI_WEB_BIND: '127.0.0.1', MUTEKI_UI_HOST: '127.0.0.1', MUTEKI_CONTROL_BIND: '127.0.0.1', MUTEKI_CONTROL_PORT: '0', PYTHONDONTWRITEBYTECODE: '1', PYTHONNOUSERSITE: '1', PYTHONUNBUFFERED: '1', NODE_ENV: 'production', NEXT_TELEMETRY_DISABLED: '1', NPM_CONFIG_PREFIX: context.paths.tools, NPM_CONFIG_CACHE: path.join(context.paths.home, '.npm'), }); - if (context.channel !== 'candidate') env.MUTEKI_SHARED_CREDENTIALS_ROOT = context.sharedCredentials; + if (!candidate) env.MUTEKI_SHARED_CREDENTIALS_ROOT = context.sharedCredentials; if (context.maintenance) env.MUTEKI_DESKTOP_MAINTENANCE = '1'; return env; } +function nodeVersionBinPaths(home) { + const roots = [path.join(home, '.nvm', 'versions', 'node'), path.join(home, '.fnm', 'node-versions')]; + const result = []; + for (const root of roots) { + let versions; + try { versions = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); } + catch { continue; } + for (const version of versions) { + const base = path.join(root, version); + result.push(path.join(base, 'bin')); + result.push(path.join(base, 'installation', 'bin')); + } + } + return result; +} + function runtimePaths({ packaged, resources, workspace, node = process.env.MUTEKI_DESKTOP_NODE || process.env.npm_node_execpath || process.execPath }) { if (!packaged) { return { code: workspace, python: path.join(workspace, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'), node, diff --git a/apps/desktop/src/main.cjs b/apps/desktop/src/main.cjs index 0274bf4..6138162 100644 --- a/apps/desktop/src/main.cjs +++ b/apps/desktop/src/main.cjs @@ -43,6 +43,15 @@ if (updateJournal) { const patchUpdate = patch => writeUpdateJournal(updateJournal, { ...JSON.parse(fs.readFileSync(updateJournal, 'utf8')), ...patch }); let desktopUpdates, applyUpdate = false, quitApproved = false; let managedService; +function updatesController() { + if (!desktopUpdates) { + const {DesktopUpdates} = require('./updates.cjs'); + desktopUpdates = new DesktopUpdates(app, environment, status => { + for (const record of windows.values()) if (!record.window.isDestroyed()) record.window.webContents.send('desktop:update-status', status); + }); + } + return desktopUpdates; +} function managedRuntime() { if (!managedService) { managedService = new ManagedService(environment, runtimePaths({ packaged: app.isPackaged && buildChannel !== 'dev', resources: process.resourcesPath, workspace })); @@ -989,6 +998,30 @@ function installIPC() { catch (error) { return { ok: false, error: transportError(error) }; } }); handle('desktop:state', record => ({ ...record.state })); handle('desktop:connect', connect); + handle('desktop:update-status', () => updatesController().getStatus()); + handle('desktop:update-check', () => updatesController().check()); + handle('desktop:update-install', async () => { + const controller = updatesController(); + if (preparingUpdate) throw new DesktopError('update.busy', '更新流程正在进行中。'); + preparingUpdate = true; + try { + const ready = await controller.download(); + if (ready.state !== 'ready') return ready; + const { response } = await dialog.showMessageBox({ + type: 'question', + message: text(`版本 ${ready.latestVersion} 已验证,可以安装`, `Version ${ready.latestVersion} is verified and ready to install`), + detail: text('安装会等待当前任务结束、保存草稿并重启。日常数据将先备份。', 'Installation saves drafts, waits for active work to finish, backs up daily data, and restarts.'), + buttons: [text('稍后', 'Later'), text('应用更新', 'Install update')], + defaultId: 0, + cancelId: 0, + }); + if (response !== 1) return controller.publish({ state: 'ready', message: '更新包已验证。' }); + await applyPreparedUpdate(); + return controller.publish({ state: 'installing', message: '正在退出并安装更新。' }); + } finally { + preparingUpdate = false; + } + }); handle('desktop:connect-local', connectLocal); handle('desktop:configure', record => { disposePreview(record); record.remote?.setVisible(false); emit(record, { configuring: true }); }); handle('desktop:resume', record => { if (record.scope && (!record.scope.closed || record.state.draftRecovery)) { record.remote?.setVisible(true); emit(record, { configuring: false }); } }); @@ -1187,28 +1220,35 @@ async function installCandidate() { try { const picked = await dialog.showOpenDialog({title: text('选择已构建的 Muteki 候选应用', 'Select a built Muteki candidate'), properties: ['openFile'], filters: [{name: 'Muteki', extensions: ['app']}]}); if (picked.canceled || picked.filePaths.length !== 1) return; - const { DesktopUpdates } = require('./updates.cjs'); - desktopUpdates = new DesktopUpdates(app, environment); + desktopUpdates = updatesController(); const prepared = await desktopUpdates.prepare(picked.filePaths[0]); app.setAsDefaultProtocolClient('muteki'); const {response} = await dialog.showMessageBox({type: 'question', message: text(`候选版本 ${prepared.version} 已通过启动验收`, `Candidate ${prepared.version} passed startup acceptance`), detail: text('应用更新会等待当前任务结束、保存草稿并重启。日常数据将先备份,开发数据不会导入。', 'The update saves drafts and restarts after current tasks finish. Daily data is backed up; development data is not imported.'), buttons: [text('稍后', 'Later'), text('应用更新', 'Apply update')], defaultId: 0, cancelId: 0}); - if (response !== 1) return; - const records = [...windows.values()]; - try { - for (const record of records) { - record.window.hide(); - const saved = await persistenceBarrier(record, 'update'); - if (!saved.persisted) throw new Error(saved.error || '草稿未保存,更新已暂停。'); - } - if (managedService?.ready) await managedService.drain(); - for (const record of records) record.allowClose = true; - applyUpdate = true; quitApproved = true; app.quit(); - } catch (error) { - for (const record of records) if (!record.window.isDestroyed()) record.window.show(); - throw error; + if (response !== 1) { + desktopUpdates.discardPreparedLocal(); + return; } + await applyPreparedUpdate(); } finally { preparingUpdate = false; } } +async function applyPreparedUpdate() { + const records = [...windows.values()]; + try { + for (const record of records) { + record.window.hide(); + const saved = await persistenceBarrier(record, 'update'); + if (!saved.persisted) throw new Error(saved.error || '草稿未保存,更新已暂停。'); + } + if (managedService?.ready) await managedService.drain(); + for (const record of records) record.allowClose = true; + applyUpdate = true; + quitApproved = true; + app.quit(); + } catch (error) { + for (const record of records) if (!record.window.isDestroyed()) record.window.show(); + throw error; + } +} async function deepLink(value) { let url; try { url = new URL(value); } catch { throw new DesktopError('desktop.deep_link_invalid', '聊天链接无效。'); } diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs index 33abc3d..c14d688 100644 --- a/apps/desktop/src/preload.cjs +++ b/apps/desktop/src/preload.cjs @@ -12,6 +12,7 @@ function subscribe(channel, callback) { } contextBridge.exposeInMainWorld('mutekiDesktop', { getState: () => invoke('desktop:state'), connect: origin => invoke('desktop:connect', origin), + updates: { getStatus: () => invoke('desktop:update-status'), check: () => invoke('desktop:update-check'), install: () => invoke('desktop:update-install'), onStatus: callback => subscribe('desktop:update-status', callback) }, connectLocal: () => invoke('desktop:connect-local'), configure: () => invoke('desktop:configure'), resume: () => invoke('desktop:resume'), consumeAnchor: id => invoke('desktop:anchor-consumed', id), diff --git a/apps/desktop/src/update-files.cjs b/apps/desktop/src/update-files.cjs index 8da7be0..e888fca 100644 --- a/apps/desktop/src/update-files.cjs +++ b/apps/desktop/src/update-files.cjs @@ -1,4 +1,7 @@ -const fs = require('node:fs'); +// Electron patches fs APIs to expose virtual files inside ASAR archives. Bundle +// hashes must describe the physical .app tree so the standalone update runner +// computes the same digest after Electron has prepared the candidate. +const fs = process.versions.electron ? require('original-fs') : require('node:fs'); const path = require('node:path'); const { createHash } = require('node:crypto'); const { execFileSync } = require('node:child_process'); @@ -24,7 +27,7 @@ function bundleHash(root) { }; walk(root); return hash.digest('hex'); } -function bundleIdentity(root) { +function bundleIdentity(root, {allowAdhoc = false, expectedArch} = {}) { if (process.platform !== 'darwin' || !root.endsWith('.app')) throw new Error('Local candidate installation currently requires a macOS .app bundle'); execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', root], {stdio: ['ignore', 'pipe', 'pipe']}); const plist = key => execFileSync('/usr/libexec/PlistBuddy', ['-c', `Print :${key}`, path.join(root, 'Contents/Info.plist')], {encoding: 'utf8'}).trim(); @@ -32,10 +35,18 @@ function bundleIdentity(root) { const signed = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', root], {encoding: 'utf8'}); if (signed.status !== 0) throw new Error(signed.stderr); const team = signed.stderr.match(/^TeamIdentifier=(.+)$/m)?.[1]; - if (!team || team === 'not set') throw new Error('Candidate must be signed with a stable signing identity'); + if ((!team || team === 'not set') && !allowAdhoc) throw new Error('Candidate must be signed with a stable signing identity'); const executable = plist('CFBundleExecutable'); if (path.basename(executable) !== executable) throw new Error('Invalid bundle executable'); - return { id: plist('CFBundleIdentifier'), version: plist('CFBundleShortVersionString'), executable, team }; + const id = plist('CFBundleIdentifier'), version = plist('CFBundleShortVersionString'); + if (expectedArch) { + const archs = execFileSync('/usr/bin/lipo', ['-archs', path.join(root, 'Contents/MacOS', executable)], {encoding: 'utf8'}).trim().split(/\s+/); + const binaryArch = expectedArch === 'x64' ? 'x86_64' : expectedArch; + if (!archs.includes(binaryArch)) throw new Error(`Application executable does not contain ${binaryArch}`); + const runtime = JSON.parse(fs.readFileSync(path.join(root, 'Contents/Resources/runtime/manifest.json'), 'utf8')); + if (runtime.platform !== 'darwin' || runtime.arch !== expectedArch) throw new Error(`Application runtime does not match darwin/${expectedArch}`); + } + return { id, version, executable, team: team && team !== 'not set' ? team : null }; } function copyBundle(source, destination) { execFileSync('/usr/bin/ditto', ['--rsrc', '--extattr', source, destination], {stdio: ['ignore', 'pipe', 'pipe']}); diff --git a/apps/desktop/src/update-runner.cjs b/apps/desktop/src/update-runner.cjs index f8818bd..b9e32fe 100644 --- a/apps/desktop/src/update-runner.cjs +++ b/apps/desktop/src/update-runner.cjs @@ -22,8 +22,11 @@ async function run(file, recover = false) { try { if (recover) throw new Error('Recovered an interrupted update before workspace writes were admitted'); if (!['prepared', 'waiting-for-owner'].includes(journal.phase)) throw new Error('Update transaction is not prepared'); - const identity = bundleIdentity(journal.candidate); - if (identity.id !== journal.identity.id || identity.team !== journal.identity.team || bundleHash(journal.candidate) !== journal.sha256) throw new Error('Candidate changed after acceptance'); + const officialProvenance = journal.mode === 'official' && journal.integrity?.kind === 'github-release-asset' && journal.integrity.repository === 'FishCodeTech/muteki' && /^https:\/\/github\.com\/FishCodeTech\/muteki\/releases\/tag\/v?\d+\.\d+\.\d+$/.test(journal.integrity.releaseUrl || '') && typeof journal.integrity.assetName === 'string' && journal.integrity.sha256 === journal.assetDigest && /^sha256:[a-f0-9]{64}$/.test(journal.assetDigest || ''); + if (journal.mode === 'official' && !officialProvenance) throw new Error('Official update integrity provenance is missing or invalid'); + const officialAdhoc = officialProvenance && !journal.candidateIdentity.team && !journal.identity.team; + const identity = bundleIdentity(journal.candidate, {allowAdhoc: officialAdhoc, expectedArch: process.arch}); + if (identity.id !== journal.identity.id || identity.team !== journal.candidateIdentity.team || (officialAdhoc ? Boolean(identity.team) : identity.team !== journal.identity.team) || bundleHash(journal.candidate) !== journal.sha256) throw new Error('Candidate changed after acceptance'); if (bundleHash(journal.current) !== journal.previousSha256) throw new Error('Installed application changed during update preparation'); fs.mkdirSync(backup, {mode: 0o700}); write({phase: 'backing-up'}); diff --git a/apps/desktop/src/updates.cjs b/apps/desktop/src/updates.cjs index 0a0ecea..c472ccd 100644 --- a/apps/desktop/src/updates.cjs +++ b/apps/desktop/src/updates.cjs @@ -1,59 +1,570 @@ const fs = require('node:fs'); const path = require('node:path'); -const { randomUUID } = require('node:crypto'); -const { spawn } = require('node:child_process'); -const { once } = require('node:events'); +const os = require('node:os'); +const { randomUUID, createHash } = require('node:crypto'); +const { spawn, execFileSync } = require('node:child_process'); +const {once} = require('node:events'); const { bundleHash, bundleIdentity, copyBundle, atomicJson } = require('./update-files.cjs'); +const OWNER = 'FishCodeTech/muteki'; +const API = `https://api.github.com/repos/${OWNER}/releases/latest`; +const MAX_ASSET = 1024 * 1024 * 1024; +const DOWNLOAD_CHUNK_SIZE = 1024 * 1024; +function compareVersions(left, right) { + const a = left.split('.').map(Number); + const b = right.split('.').map(Number); + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const difference = (a[index] || 0) - (b[index] || 0); + if (difference) return difference; + } + return 0; +} +function pathEntryExists(file) { + try { fs.lstatSync(file); return true; } + catch (error) { if (error.code === 'ENOENT') return false; throw error; } +} class DesktopUpdates { - constructor(app, environment) { - this.app = app; this.environment = environment; this.prepared = null; + constructor(app, environment, onStatus = () => {}) { + this.app = app; + this.environment = environment; + this.onStatus = onStatus; + this.prepared = null; + this.operation = null; this.current = path.resolve(app.getPath('exe'), '../../..'); + this.downloads = app.getPath('downloads'); + this.status = this.readLastInstall() || { state: 'idle', currentVersion: app.getVersion() }; + } + publish(patch) { + this.status = { ...this.status, ...patch }; + this.onStatus(this.status); + return this.status; + } + getStatus() { return { ...this.status }; } + readLastInstall() { + const directory = path.join(this.environment.root, 'updates'); + try { + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) return null; + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + const outcomes = []; + for (const id of fs.readdirSync(directory)) { + const transactionDirectory = path.join(directory, id); + const transactionFile = path.join(transactionDirectory, 'transaction.json'); + try { + const directoryStat = fs.lstatSync(transactionDirectory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) continue; + const transactionStat = fs.lstatSync(transactionFile); + if (!transactionStat.isFile() || transactionStat.isSymbolicLink()) continue; + const journal = JSON.parse(fs.readFileSync(transactionFile, 'utf8')); + if (journal.version !== 1 || journal.id !== id || journal.environmentRoot !== this.environment.root) continue; + if (journal.candidateIdentity?.id !== 'tech.fishcode.muteki.desktop' || !/^\d+\.\d+\.\d+$/.test(journal.candidateIdentity.version || '')) continue; + if (!['complete', 'rolled-back', 'failed', 'repair-required', 'recovery-required'].includes(journal.phase)) continue; + outcomes.push({ journal, at: transactionStat.mtime, file: transactionFile }); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw Object.assign(new Error(`无法读取更新记录 ${transactionFile}:${error.message}`), { code: 'update.history_read_failed' }); + } + } + outcomes.sort((a, b) => b.at - a.at); + const latest = outcomes[0]; + if (!latest) return null; + const { journal, at } = latest; + const failed = journal.phase !== 'complete'; + const code = ['repair-required', 'recovery-required'].includes(journal.phase) ? 'update.recovery_required' : 'update.last_install_failed'; + const detail = typeof journal.error === 'string' ? journal.error.slice(0, 1000) : ''; + const version = journal.candidateIdentity.version; + const message = failed + ? `上次更新到 ${version} 未完成。${detail ? ` ${detail}` : ''}` + : `上次更新已完成,安装版本为 ${version}。`; + return { + state: failed ? 'error' : 'idle', + currentVersion: this.app.getVersion(), + message, + ...(failed ? { error: { code, message } } : {}), + lastInstall: { state: failed ? 'failed' : 'complete', version, at: at.toISOString(), message, ...(failed ? { code } : {}) }, + }; + } + discardPreparedLocal() { + if (this.prepared?.mode === 'local') this.prepared = null; + } + async copyVerifiedFile(source, destination, asset, onProgress = () => {}) { + const before = fs.lstatSync(source); + if (!before.isFile() || before.isSymbolicLink() || before.size !== asset.size) { + throw Object.assign(new Error('Existing release package is not a regular file with the published size.'), { code: 'update.cached_asset_invalid' }); + } + const noFollow = fs.constants.O_NOFOLLOW || 0; + const input = fs.openSync(source, fs.constants.O_RDONLY | noFollow); + const output = fs.openSync(destination, 'wx', 0o600); + const hash = createHash('sha256'); + let total = 0; + try { + if (!fs.fstatSync(input).isFile()) throw new Error('Existing release package is not a regular file.'); + const buffer = Buffer.alloc(DOWNLOAD_CHUNK_SIZE); + for (;;) { + const length = fs.readSync(input, buffer, 0, buffer.length, null); + if (!length) break; + const chunk = buffer.subarray(0, length); + total += length; + if (total > asset.size) throw Object.assign(new Error('Existing release package exceeds the published size.'), { code: 'update.cached_asset_invalid' }); + hash.update(chunk); + let written = 0; + while (written < length) written += fs.writeSync(output, chunk, written, length - written); + onProgress(total); + } + fs.fsyncSync(output); + } finally { + fs.closeSync(input); + fs.closeSync(output); + } + if (total !== asset.size || hash.digest('hex') !== asset.digest.slice(7).toLowerCase()) { + throw Object.assign(new Error('Existing release package does not match GitHub size and SHA-256 metadata.'), { code: 'update.cached_asset_invalid' }); + } + } + async downloadAsset(asset, destination) { + const response = await fetch(asset.browser_download_url, { + headers: { 'User-Agent': 'Muteki-Desktop-Updater' }, + signal: AbortSignal.timeout(180000), + }); + const finalUrl = new URL(response.url); + const trustedHosts = ['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com']; + if (!response.ok || finalUrl.protocol !== 'https:' || !trustedHosts.includes(finalUrl.hostname)) { + throw Object.assign(new Error(`Release download failed (HTTP ${response.status}).`), { code: 'update.download_failed' }); + } + const contentLength = response.headers.get('content-length'); + if ((contentLength && Number(contentLength) !== asset.size) || !response.body) { + throw Object.assign(new Error('Downloaded release size does not match GitHub metadata.'), { code: 'update.asset_size_mismatch' }); + } + const hash = createHash('sha256'); + let total = 0; + const fd = fs.openSync(destination, 'wx', 0o600); + try { + for await (const chunk of response.body) { + const part = Buffer.from(chunk); + total += part.length; + if (total > asset.size || total > MAX_ASSET) { + throw Object.assign(new Error('Downloaded release exceeds its published size.'), { code: 'update.asset_size_mismatch' }); + } + hash.update(part); + let written = 0; + while (written < part.length) written += fs.writeSync(fd, part, written, part.length - written); + this.publish({ progress: Math.floor(total * 100 / asset.size) }); + } + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + if (total !== asset.size) { + throw Object.assign(new Error('Downloaded release size does not match GitHub metadata.'), { code: 'update.asset_size_mismatch' }); + } + if (hash.digest('hex') !== asset.digest.slice(7).toLowerCase()) { + throw Object.assign(new Error('Downloaded release SHA-256 does not match GitHub metadata.'), { code: 'update.digest_mismatch' }); + } + } + assertInstallable() { + if (!this.app.isPackaged || process.platform !== 'darwin' || this.environment.channel !== 'stable') { + throw Object.assign(new Error('安装桌面更新仅支持已安装的 macOS 日常版。'), { code: 'update.unsupported' }); + } } - async prepare(source) { - if (!this.app.isPackaged || process.platform !== 'darwin' || this.environment.channel !== 'stable') throw new Error('请在已安装的 macOS 日常版中应用候选更新。'); + async check() { + if (this.operation) return this.operation; + this.operation = (async () => { + this.publish({ state: 'checking', error: undefined, progress: undefined, message: undefined, lastInstall: undefined }); + try { + const response = await fetch(API, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Muteki-Desktop-Updater', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: AbortSignal.timeout(30000), + }); + if (!response.ok) { + throw Object.assign(new Error(`GitHub release check failed (HTTP ${response.status}).`), { code: 'update.release_check_failed' }); + } + const release = await response.json(); + if (release.draft || release.prerelease || !/^v?\d+\.\d+\.\d+$/.test(release.tag_name || '')) { + throw Object.assign(new Error('Latest GitHub release is not a stable semantic version.'), { code: 'update.release_invalid' }); + } + const version = release.tag_name.replace(/^v/, ''); + const expectedReleaseUrl = `https://github.com/${OWNER}/releases/tag/${encodeURIComponent(release.tag_name)}`; + if (release.html_url !== expectedReleaseUrl) { + throw Object.assign(new Error('GitHub release URL does not match the trusted repository and tag.'), { code: 'update.release_invalid' }); + } + const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null; + const name = `Muteki-${version}-${arch}.zip`; + const asset = release.assets?.find(item => item.name === name && item.state === 'uploaded'); + const expectedAssetUrl = `https://github.com/${OWNER}/releases/download/${encodeURIComponent(release.tag_name)}/${encodeURIComponent(name)}`; + if (!arch || !asset || !/^sha256:[a-f0-9]{64}$/i.test(asset.digest || '') || asset.browser_download_url !== expectedAssetUrl) { + throw Object.assign(new Error(`Trusted ${arch || process.arch} release asset or its SHA-256 digest is unavailable.`), { code: 'update.asset_unavailable' }); + } + const canInstall = this.app.isPackaged && process.platform === 'darwin' && this.environment.channel === 'stable'; + const currentVersion = this.app.getVersion(); + const current = compareVersions(version, currentVersion) <= 0; + return this.publish({ + state: current ? 'current' : 'available', + currentVersion, + latestVersion: version, + assetName: name, + digest: asset.digest, + releaseUrl: release.html_url, + checkedAt: new Date().toISOString(), + canInstall, + installReason: canInstall ? undefined : '安装更新仅支持已安装的 macOS 日常版。', + message: current ? '已是最新版本。' : undefined, + progress: 0, + }); + } catch (error) { + return this.publish({ + state: 'error', + error: { code: error.code || 'update.release_check_failed', message: error.message }, + message: error.message, + }); + } finally { + this.operation = null; + } + })(); + return this.operation; + } + async download() { + if (this.prepared?.mode === 'official' && this.status.state === 'ready') return this.getStatus(); + if (this.prepared?.mode === 'local') { + throw Object.assign(new Error('A local candidate is prepared; use the local candidate confirmation to continue.'), { code: 'update.mode_conflict' }); + } + if (this.operation) return this.operation; + this.operation = (async () => { + let temporary; + try { + this.assertInstallable(); + if (this.status.state !== 'available') { + throw Object.assign(new Error('Check for an available update before installing.'), { code: 'update.not_available' }); + } + this.publish({ state: 'downloading', progress: 0, error: undefined }); + const releaseResponse = await fetch(API, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Muteki-Desktop-Updater', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: AbortSignal.timeout(30000), + }); + if (!releaseResponse.ok) { + throw Object.assign(new Error(`GitHub release check failed (HTTP ${releaseResponse.status}).`), { code: 'update.release_check_failed' }); + } + const release = await releaseResponse.json(); + const version = release.tag_name?.replace(/^v/, ''); + const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null; + const name = `Muteki-${version}-${arch}.zip`; + const asset = release.assets?.find(item => item.name === name && item.state === 'uploaded'); + const expectedAssetUrl = `https://github.com/${OWNER}/releases/download/${encodeURIComponent(release.tag_name)}/${encodeURIComponent(name)}`; + if (release.draft || release.prerelease || release.html_url !== this.status.releaseUrl || version !== this.status.latestVersion || !asset || asset.state !== 'uploaded' || asset.name !== this.status.assetName || asset.digest !== this.status.digest || asset.browser_download_url !== expectedAssetUrl || !Number.isSafeInteger(asset.size) || asset.size < 1 || asset.size > MAX_ASSET || !/^sha256:[a-f0-9]{64}$/i.test(asset.digest || '')) { + throw Object.assign(new Error('Release metadata changed since the update check. Check again.'), { code: 'update.release_changed' }); + } + temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'muteki-update-')); + const zip = path.join(temporary, name); + const extracted = path.join(temporary, 'extracted'); + fs.mkdirSync(extracted, { mode: 0o700 }); + const updateDirectory = path.join(this.environment.root, 'updates'); + fs.mkdirSync(updateDirectory, { recursive: true, mode: 0o700 }); + if (!fs.lstatSync(updateDirectory).isDirectory() || fs.lstatSync(updateDirectory).isSymbolicLink()) { + throw Object.assign(new Error('Update storage directory is not private and safe.'), { code: 'update.cache_unavailable' }); + } + fs.chmodSync(updateDirectory, 0o700); + const downloadsCache = path.join(updateDirectory, 'downloads'); + fs.mkdirSync(downloadsCache, { recursive: true, mode: 0o700 }); + if (!fs.lstatSync(downloadsCache).isDirectory() || fs.lstatSync(downloadsCache).isSymbolicLink()) { + throw Object.assign(new Error('Update download cache is not private and safe.'), { code: 'update.cache_unavailable' }); + } + fs.chmodSync(downloadsCache, 0o700); + const cachedFile = path.join(downloadsCache, `${asset.digest.slice(7)}.zip`); + const userDownload = path.join(this.downloads, name); + const existing = pathEntryExists(cachedFile) ? cachedFile : pathEntryExists(userDownload) ? userDownload : null; + if (existing) { + this.publish({ message: '正在验证已有的官方安装包…', progress: 0 }); + try { + await this.copyVerifiedFile(existing, zip, asset, total => this.publish({ progress: Math.floor(total * 100 / asset.size) })); + } catch (error) { + if (error.code === 'update.cached_asset_invalid') throw error; + throw Object.assign(new Error(`Existing release package could not be verified: ${error.message}`), { code: 'update.cached_asset_invalid' }); + } + } else { + this.publish({ message: '正在从 GitHub 下载官方安装包…', progress: 0 }); + await this.downloadAsset(asset, zip); + fs.copyFileSync(zip, cachedFile, fs.constants.COPYFILE_EXCL); + fs.chmodSync(cachedFile, 0o600); + } + const digest = asset.digest.slice(7).toLowerCase(); + this.publish({ state: 'validating', progress: 100, downloadDigest: digest }); + this.assertZipSafe(fs.readFileSync(zip)); + execFileSync('/usr/bin/ditto', ['-x', '-k', zip, extracted], { stdio: ['ignore', 'pipe', 'pipe'] }); + const extractedEntries = fs.readdirSync(extracted); + const apps = extractedEntries.filter(entry => entry.endsWith('.app')); + if (apps.length !== 1 || extractedEntries.length !== 1) { + throw Object.assign(new Error('Release archive must contain exactly one application bundle.'), { code: 'update.archive_invalid' }); + } + const source = path.join(extracted, apps[0]); + const identity = bundleIdentity(source, { allowAdhoc: true, expectedArch: arch }); + if (identity.id !== 'tech.fishcode.muteki.desktop' || identity.version !== version) { + throw Object.assign(new Error('Downloaded application identity or version does not match the checked release.'), { code: 'update.identity_mismatch' }); + } + const currentIdentity = bundleIdentity(this.current, { allowAdhoc: true, expectedArch: arch }); + if (identity.id !== currentIdentity.id || Boolean(identity.team) !== Boolean(currentIdentity.team) || (identity.team && identity.team !== currentIdentity.team)) { + throw Object.assign(new Error('Release signing identity does not match the installed application.'), { code: 'update.signing_mismatch' }); + } + this.prepared = await this.prepare(source, { + mode: 'official', + expectedSha256: digest, + version, + assetDigest: asset.digest, + assetName: name, + releaseUrl: release.html_url, + }); + return this.publish({ + state: 'ready', + progress: 100, + latestVersion: version, + preparedVersion: version, + message: '更新已通过完整性与启动检查。', + }); + } catch (error) { + return this.publish({ + state: 'error', + error: { code: error.code || 'update.install_prepare_failed', message: error.message }, + message: error.message, + }); + } finally { + if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); + this.operation = null; + } + })(); + return this.operation; + } + assertZipSafe(bytes) { + let eocd = -1; + for (let index = Math.max(0, bytes.length - 65557); index <= bytes.length - 22; index++) { + if (bytes.readUInt32LE(index) === 0x06054b50) eocd = index; + } + if (eocd < 0 || bytes.readUInt16LE(eocd + 4) !== 0 || bytes.readUInt16LE(eocd + 6) !== 0 || eocd + 22 + bytes.readUInt16LE(eocd + 20) !== bytes.length) { + throw Object.assign(new Error('Release archive uses an unsupported ZIP layout.'), { code: 'update.archive_invalid' }); + } + + const count = bytes.readUInt16LE(eocd + 10); + const size = bytes.readUInt32LE(eocd + 12); + const centralStart = bytes.readUInt32LE(eocd + 16); + const centralEnd = centralStart + size; + if (bytes.readUInt16LE(eocd + 8) !== count) { + throw Object.assign(new Error('Release archive entry counts do not match.'), { code: 'update.archive_invalid' }); + } + if (centralEnd !== eocd) { + throw Object.assign(new Error('Release archive central directory bounds are invalid.'), { code: 'update.archive_invalid' }); + } + + let offset = centralStart; + let expandedSize = 0; + const entries = []; + const seenPaths = new Set(); + for (let index = 0; index < count; index++) { + if (bytes.readUInt32LE(offset) !== 0x02014b50) { + throw Object.assign(new Error('Release archive directory is invalid.'), { code: 'update.archive_invalid' }); + } + const flags = bytes.readUInt16LE(offset + 8); + const mode = bytes.readUInt32LE(offset + 38) >>> 16; + const method = bytes.readUInt16LE(offset + 10); + const compressedSize = bytes.readUInt32LE(offset + 20); + const uncompressedSize = bytes.readUInt32LE(offset + 24); + const nameLength = bytes.readUInt16LE(offset + 28); + const extraLength = bytes.readUInt16LE(offset + 30); + const commentLength = bytes.readUInt16LE(offset + 32); + const localOffset = bytes.readUInt32LE(offset + 42); + const name = bytes.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'); + const kind = mode & 0o170000; + const parts = name.split('/'); + if (!name || name.includes('\\') || name.includes('\0') || name.startsWith('/') || /^[A-Za-z]:/.test(name) || parts.some(part => part === '..' || part === '.') || (kind && ![0o100000, 0o040000, 0o120000].includes(kind)) || ![0, 8].includes(method) || (flags & 1) || uncompressedSize > MAX_ASSET) { + throw Object.assign(new Error('Release archive contains an unsafe or unsupported path.'), { code: 'update.archive_unsafe' }); + } + const canonical = name.endsWith('/') ? name.slice(0, -1) : name; + const canonicalParts = canonical.split('/'); + if (canonicalParts[0] !== 'Muteki.app' || canonicalParts.some(part => !part)) { + throw Object.assign(new Error('Release archive contains an unexpected top-level path.'), { code: 'update.archive_unsafe' }); + } + if (localOffset >= centralStart || bytes.readUInt32LE(localOffset) !== 0x04034b50) { + throw Object.assign(new Error('Release archive local entry is invalid.'), { code: 'update.archive_invalid' }); + } + const localNameLength = bytes.readUInt16LE(localOffset + 26); + const localExtraLength = bytes.readUInt16LE(localOffset + 28); + const localName = bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength).toString('utf8'); + const dataOffset = localOffset + 30 + localNameLength + localExtraLength; + const localFlags = bytes.readUInt16LE(localOffset + 6); + const localMethod = bytes.readUInt16LE(localOffset + 8); + const localCrc = bytes.readUInt32LE(localOffset + 14); + const centralCrc = bytes.readUInt32LE(offset + 16); + const localCompressedSize = bytes.readUInt32LE(localOffset + 18); + const localUncompressedSize = bytes.readUInt32LE(localOffset + 22); + if (localName !== name || localFlags !== flags || localMethod !== method || dataOffset + compressedSize > centralStart || (!(flags & 8) && (localCrc !== centralCrc || localCompressedSize !== compressedSize || localUncompressedSize !== uncompressedSize))) { + throw Object.assign(new Error('Release archive entry path or bounds are invalid.'), { code: 'update.archive_invalid' }); + } + expandedSize += uncompressedSize; + if (expandedSize > 2 * 1024 * 1024 * 1024) { + throw Object.assign(new Error('Release archive expands beyond the allowed size.'), { code: 'update.archive_unsafe' }); + } + let symlinkTarget = Buffer.alloc(0); + if (kind === 0o120000) { + if (uncompressedSize > 4096) { + throw Object.assign(new Error('Release archive symlink target is too long.'), { code: 'update.archive_unsafe' }); + } + const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize); + symlinkTarget = method === 0 ? compressed : require('node:zlib').inflateRawSync(compressed, { maxOutputLength: 4096 }); + if (symlinkTarget.length !== uncompressedSize) { + throw Object.assign(new Error('Release archive symlink target is invalid.'), { code: 'update.archive_invalid' }); + } + } + if (seenPaths.has(canonical)) { + throw Object.assign(new Error('Release archive contains duplicate paths.'), { code: 'update.archive_unsafe' }); + } + seenPaths.add(canonical); + entries.push({ name: canonical, symlinkTarget, mode, isDirectory: name.endsWith('/') }); + offset += 46 + nameLength + extraLength + commentLength; + } + if (offset !== centralEnd) { + throw Object.assign(new Error('Release archive directory size is invalid.'), { code: 'update.archive_invalid' }); + } + + const symlinks = new Set(entries.filter(entry => (entry.mode & 0o170000) === 0o120000).map(entry => entry.name)); + const kinds = new Map(entries.map(entry => [entry.name, (entry.mode & 0o170000) || (entry.isDirectory ? 0o040000 : 0o100000)])); + for (const entry of entries) { + const segments = entry.name.split('/'); + for (let index = 1; index < segments.length; index++) { + const ancestor = segments.slice(0, index).join('/'); + if (symlinks.has(ancestor)) { + throw Object.assign(new Error('Release archive writes through a symlink path.'), { code: 'update.archive_unsafe' }); + } + if (kinds.has(ancestor) && kinds.get(ancestor) !== 0o040000) { + throw Object.assign(new Error('Release archive contains a file used as a directory.'), { code: 'update.archive_unsafe' }); + } + } + if (symlinks.has(entry.name)) { + const target = entry.symlinkTarget.toString('utf8'); + const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(entry.name), target)); + if (!target || path.posix.isAbsolute(target) || resolved === '..' || resolved.startsWith('../') || !resolved.startsWith('Muteki.app/')) { + throw Object.assign(new Error('Release archive symlink escapes its application bundle.'), { code: 'update.archive_unsafe' }); + } + } + } + } + async prepare(source,{mode='local',expectedSha256,version,assetDigest,assetName,releaseUrl}={}) { + if (mode === 'local') this.prepared = null; + this.assertInstallable(); source = fs.realpathSync(source); if (source === this.current) throw new Error('请选择新构建的候选应用。'); - const identity = bundleIdentity(this.current), candidateIdentity = bundleIdentity(source); - if (identity.id !== candidateIdentity.id || identity.team !== candidateIdentity.team) throw new Error('候选版本的应用身份或签名团队与日常版不同。'); - const id = randomUUID(), transaction = path.join(this.environment.root, 'updates', id); - fs.mkdirSync(transaction, {recursive: true, mode: 0o700}); + const identityOptions = { allowAdhoc: mode === 'official', expectedArch: process.arch }; + const currentIdentity = bundleIdentity(this.current, identityOptions); + const candidateIdentity = bundleIdentity(source, identityOptions); + const sameTeam = currentIdentity.team === candidateIdentity.team; + if (currentIdentity.id !== candidateIdentity.id || (mode === 'local' && !sameTeam) || (mode === 'official' && !sameTeam)) { + throw new Error('候选版本的应用身份或签名团队与日常版不同。'); + } + if (mode === 'official' && candidateIdentity.version !== version) { + throw Object.assign(new Error('Release version does not match its checked metadata.'), { code: 'update.version_mismatch' }); + } + const currentVersion = this.app.getVersion(); + if (compareVersions(candidateIdentity.version, currentVersion) <= 0) { + throw Object.assign(new Error('更新版本必须高于当前版本。'), { code: 'update.not_newer' }); + } + const id = randomUUID(); + const transaction = path.join(this.environment.root, 'updates', id); + fs.mkdirSync(transaction, { recursive: true, mode: 0o700 }); const candidate = path.join(transaction, 'candidate.app'); copyBundle(source, candidate); - bundleIdentity(candidate); + bundleIdentity(candidate, identityOptions); const sha256 = bundleHash(candidate); - if (sha256 !== bundleHash(source)) throw new Error('候选应用在准备过程中发生变化,请重新构建。'); + if (sha256 !== bundleHash(source) || (expectedSha256 && expectedSha256 !== this.status.downloadDigest)) { + throw new Error('候选应用在准备过程中发生变化,请重新下载。'); + } + const profile = path.join(transaction, 'acceptance'); - const log = fs.openSync(path.join(transaction, 'acceptance.log'), 'a', 0o600); - const env = {...process.env, MUTEKI_DESKTOP_USER_DATA: path.join(profile, 'desktop'), MUTEKI_DESKTOP_REGISTER_PROTOCOL: '0'}; - for (const key of ['MUTEKI_DESKTOP_URL', 'MUTEKI_ENVIRONMENT_ROOT', 'MUTEKI_DESKTOP_WORKSPACE', 'NODE_OPTIONS', 'NODE_PATH', 'ELECTRON_RUN_AS_NODE', 'PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV']) delete env[key]; - const child = spawn(path.join(candidate, 'Contents/MacOS', candidateIdentity.executable), ['--muteki-channel=candidate', `--muteki-environment-root=${profile}`, '--muteki-self-check'], {env, stdio: ['ignore', log, log]}); + const acceptanceLog = path.join(transaction, 'acceptance.log'); + const log = fs.openSync(acceptanceLog, 'a', 0o600); + const env = { + ...process.env, + MUTEKI_DESKTOP_USER_DATA: path.join(profile, 'desktop'), + MUTEKI_DESKTOP_REGISTER_PROTOCOL: '0', + }; + for (const key of ['MUTEKI_DESKTOP_URL', 'MUTEKI_ENVIRONMENT_ROOT', 'MUTEKI_DESKTOP_WORKSPACE', 'NODE_OPTIONS', 'NODE_PATH', 'ELECTRON_RUN_AS_NODE', 'PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV']) { + delete env[key]; + } + const child = spawn(path.join(candidate, 'Contents/MacOS', candidateIdentity.executable), [ + '--muteki-channel=candidate', + `--muteki-environment-root=${profile}`, + '--muteki-self-check', + ], { env, stdio: ['ignore', log, log] }); fs.closeSync(log); const timer = setTimeout(() => child.kill('SIGTERM'), 150000); let code; - try { [code] = await once(child, 'exit'); } finally { clearTimeout(timer); } + try { + [code] = await once(child, 'exit'); + } finally { + clearTimeout(timer); + } const resultFile = path.join(profile, 'candidate-result.json'); - if (code !== 0 || !fs.existsSync(resultFile) || JSON.parse(fs.readFileSync(resultFile, 'utf8')).status !== 'passed') throw new Error(`候选版本验收失败,请查看 ${path.join(transaction, 'acceptance.log')}`); + const passed = code === 0 && fs.existsSync(resultFile) && JSON.parse(fs.readFileSync(resultFile, 'utf8')).status === 'passed'; + if (!passed) throw new Error(`候选版本验收失败,请查看 ${acceptanceLog}`); if (bundleHash(candidate) !== sha256) throw new Error('候选版本运行时修改了应用包,不能安装。'); - const file = path.join(transaction, 'transaction.json'); - const runtime = JSON.parse(fs.readFileSync(path.join(process.resourcesPath, 'runtime/manifest.json'), 'utf8')); - fs.copyFileSync(path.join(process.resourcesPath, 'runtime', runtime.node), path.join(transaction, 'node')); - fs.chmodSync(path.join(transaction, 'node'), 0o700); - for (const name of ['update-runner.cjs', 'update-files.cjs']) fs.copyFileSync(path.join(__dirname, name), path.join(transaction, name)); - atomicJson(file, {version: 1, id, phase: 'prepared', current: this.current, candidate, identity, candidateIdentity, sha256, previousSha256: bundleHash(this.current), environmentRoot: this.environment.root}); - this.prepared = {file, transaction, version: candidateIdentity.version}; + + const runtimeManifest = path.join(process.resourcesPath, 'runtime', 'manifest.json'); + const runtime = JSON.parse(fs.readFileSync(runtimeManifest, 'utf8')); + const transactionNode = path.join(transaction, 'node'); + fs.copyFileSync(path.join(process.resourcesPath, 'runtime', runtime.node), transactionNode); + fs.chmodSync(transactionNode, 0o700); + for (const name of ['update-runner.cjs', 'update-files.cjs']) { + fs.copyFileSync(path.join(__dirname, name), path.join(transaction, name)); + } + const journal = { + version: 1, + id, + phase: 'prepared', + mode, + current: this.current, + candidate, + identity: currentIdentity, + candidateIdentity, + sha256, + previousSha256: bundleHash(this.current), + environmentRoot: this.environment.root, + ...(mode === 'official' ? { + assetDigest, + integrity: { kind: 'github-release-asset', repository: OWNER, releaseUrl, assetName, sha256: assetDigest }, + } : {}), + }; + const transactionFile = path.join(transaction, 'transaction.json'); + atomicJson(transactionFile, journal); + this.prepared = { file: transactionFile, transaction, version: candidateIdentity.version, mode }; return this.prepared; } - async launch(recover = false) { + async launch(recover=false) { if (!this.prepared) throw new Error('No accepted candidate is prepared'); - const {file, transaction} = this.prepared; + const { file, transaction } = this.prepared; const log = fs.openSync(path.join(transaction, 'install.log'), 'a', 0o600); - const env = {...process.env}; delete env.NODE_OPTIONS; delete env.NODE_PATH; - const child = spawn(path.join(transaction, 'node'), [path.join(transaction, 'update-runner.cjs'), file, ...(recover ? ['--recover'] : [])], {env, detached: true, stdio: ['pipe', log, log]}); - fs.closeSync(log); await once(child, 'spawn'); child.unref(); + const env = { ...process.env }; + delete env.NODE_OPTIONS; + delete env.NODE_PATH; + const args = [path.join(transaction, 'update-runner.cjs'), file]; + if (recover) args.push('--recover'); + const child = spawn(path.join(transaction, 'node'), args, { + env, + detached: true, + stdio: ['pipe', log, log], + }); + fs.closeSync(log); + await once(child, 'spawn'); + child.unref(); const journal = JSON.parse(fs.readFileSync(file, 'utf8')); - atomicJson(file, {...journal, helperPid: child.pid, ...(!recover ? {phase: 'waiting-for-owner'} : {})}); - // The app owns this pipe until it exits. The helper cannot replace a live bundle. + atomicJson(file, { + ...journal, + helperPid: child.pid, + ...(!recover ? { phase: 'waiting-for-owner' } : {}), + }); this.helper = child; } } diff --git a/apps/desktop/test/policy.test.cjs b/apps/desktop/test/policy.test.cjs index 5d56cb3..2a533a8 100644 --- a/apps/desktop/test/policy.test.cjs +++ b/apps/desktop/test/policy.test.cjs @@ -110,12 +110,23 @@ test('desktop environment identity persists and runtime inheritance is explicit' assert.equal(first.id, second.id); assert.notEqual(first.generation, second.generation); assert.throws(() => desktopEnvironment({...options, channel: 'candidate'})); - const env = serviceEnvironment(first, {PYTHONPATH: '/checkout', NODE_OPTIONS: '--inspect', MUTEKI_STATE_ROOT: '/wrong', OPENAI_API_KEY: 'fixture', LANG: 'en_US.UTF-8'}); + const host = path.join(root, 'host'); + fs.mkdirSync(path.join(host, '.nvm', 'versions', 'node', 'v99.1.0', 'bin'), {recursive: true}); + const env = serviceEnvironment(first, {HOME: host, PATH: '/host/path', PYTHONPATH: '/checkout', NODE_OPTIONS: '--inspect', MUTEKI_STATE_ROOT: '/wrong', OPENAI_API_KEY: 'fixture', LANG: 'en_US.UTF-8'}); for (const name of ['PYTHONPATH', 'NODE_OPTIONS', 'OPENAI_API_KEY']) assert.equal(env[name], undefined); assert.equal(env.MUTEKI_STATE_ROOT, first.paths.state); - assert.equal(env.MUTEKI_HOST_DISCOVERY, '0'); - assert.equal(env.HOME, first.paths.home); + assert.equal(env.MUTEKI_HOST_DISCOVERY, '1'); + assert.equal(env.HOME, host); + assert.ok(env.PATH.split(path.delimiter).includes('/host/path')); + assert.ok(env.PATH.split(path.delimiter).includes(path.join(host, '.nvm', 'versions', 'node', 'v99.1.0', 'bin'))); assert.equal(env.LANG, 'en_US.UTF-8'); + const candidate = desktopEnvironment({...options, root: path.join(root, 'candidate'), channel: 'candidate'}); + const candidateEnv = serviceEnvironment(candidate, {HOME: host, PATH: '/host/path', OPENAI_API_KEY: 'fixture', MUTEKI_HOST_DISCOVERY: '1'}); + assert.equal(candidateEnv.HOME, candidate.paths.home); + assert.equal(candidateEnv.MUTEKI_HOST_DISCOVERY, '0'); + assert.ok(!candidateEnv.PATH.split(path.delimiter).includes('/host/path')); + assert.ok(!candidateEnv.PATH.includes(path.join(host, '.nvm'))); + assert.equal(candidateEnv.OPENAI_API_KEY, undefined); } finally { fs.rmSync(root, {recursive: true, force: true}); } }); diff --git a/apps/web/routes/runs.py b/apps/web/routes/runs.py index 2ef81f9..35c3b0d 100644 --- a/apps/web/routes/runs.py +++ b/apps/web/routes/runs.py @@ -24,6 +24,7 @@ UploadFile, ) from fastapi.responses import JSONResponse, PlainTextResponse, Response +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, model_validator from sse_starlette.sse import EventSourceResponse from apps.web.run_manager import RunManager @@ -40,12 +41,43 @@ account_store_root, ) + +class RunRetentionPolicyBody(BaseModel): + model_config = ConfigDict(extra="forbid") + + archive_enabled: StrictBool + archive_after_days: StrictInt = Field(ge=1, le=3650) + delete_enabled: StrictBool + delete_after_days: StrictInt = Field(ge=1, le=3650) + + @model_validator(mode="after") + def validate_order(self) -> "RunRetentionPolicyBody": + if (self.archive_enabled and self.delete_enabled + and self.delete_after_days <= self.archive_after_days): + raise ValueError( + "delete_after_days must be greater than archive_after_days") + return self + def register(app: FastAPI) -> None: h = app.state.route_helpers _reject_temporarily_disabled_engine = h._reject_temporarily_disabled_engine _dispatch_run_command = h._dispatch_run_command _run_receipt_status = h._run_receipt_status + @app.get("/api/settings/run-retention") + async def get_run_retention_settings() -> Any: + return {"policies": app.state.manager.retention_policies.all()} + + @app.put("/api/settings/run-retention/{mode}") + async def put_run_retention_settings( + mode: str, policy: RunRetentionPolicyBody, + ) -> Any: + if mode not in {"ctf", "pentest"}: + raise HTTPException(status_code=404, detail="unknown run mode") + saved = app.state.manager.retention_policies.set( + mode, policy.model_dump()) + return {"policy": saved} + @app.get("/api/runs") async def list_runs(archived: int = 0) -> Any: # rich summaries (name/category/status/pinned/archived) for the thread diff --git a/apps/web/run_manager.py b/apps/web/run_manager.py index 68e6c6b..3f8ce1a 100644 --- a/apps/web/run_manager.py +++ b/apps/web/run_manager.py @@ -83,6 +83,8 @@ def __init__(self, *, sessions_root: "str | Path | None" = None, self._control_submit_locks: dict[str, asyncio.Lock] = {} self._seq = 0 self.meta = RunMetaStore(root=self.state_root) + self.retention_policies = _run_retention.RunRetentionPolicyStore( + self.state_root) self.run_summaries = RunSummaryStore(root=self.state_root) # CORE-04: binding key → run_id 的幂等索引,重启后可恢复。 self.bound_runs = BoundRunStore(root=self.state_root) diff --git a/apps/web/run_retention.py b/apps/web/run_retention.py index b541234..44ba591 100644 --- a/apps/web/run_retention.py +++ b/apps/web/run_retention.py @@ -3,7 +3,12 @@ from __future__ import annotations import asyncio +import json +import os +import tempfile import time +from pathlib import Path +from typing import Any from apps.web.run_state import ( # noqa: F401 @@ -12,6 +17,82 @@ _apply_blackboard_meta, _apply_operator_meta, ) + +DEFAULT_POLICY = { + "archive_enabled": True, + "archive_after_days": 15, + "delete_enabled": True, + "delete_after_days": 30, +} + + +class RunRetentionPolicyStore: + """Small persistent per-mode retention settings store.""" + + MODES = ("ctf", "pentest") + + def __init__(self, root: str | Path) -> None: + self.path = Path(root) / "_run_retention_policies.json" + self._policies = {mode: dict(DEFAULT_POLICY) for mode in self.MODES} + self._load() + + @staticmethod + def _valid_policy(value: Any) -> bool: + return (isinstance(value, dict) + and set(value) == set(DEFAULT_POLICY) + and type(value.get("archive_enabled")) is bool + and type(value.get("archive_after_days")) is int + and type(value.get("delete_enabled")) is bool + and type(value.get("delete_after_days")) is int + and 1 <= value["archive_after_days"] <= 3650 + and 1 <= value["delete_after_days"] <= 3650 + and not (value["archive_enabled"] and value["delete_enabled"] + and value["delete_after_days"] <= value["archive_after_days"])) + + def _load(self) -> None: + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Cannot read run retention policies: {self.path}") from exc + policies = raw.get("policies") if isinstance(raw, dict) else None + if (not isinstance(raw, dict) or raw.get("version") != 1 + or not isinstance(policies, dict) + or set(policies) != set(self.MODES) + or any(not self._valid_policy(policies[mode]) for mode in self.MODES)): + raise ValueError(f"Invalid run retention policies: {self.path}") + self._policies = {mode: dict(policies[mode]) for mode in self.MODES} + + def all(self) -> dict[str, dict[str, Any]]: + return {mode: dict(policy) for mode, policy in self._policies.items()} + + def get(self, mode: str) -> dict[str, Any]: + return dict(self._policies[mode]) + + def set(self, mode: str, policy: dict[str, Any]) -> dict[str, Any]: + if mode not in self.MODES or not self._valid_policy(policy): + raise ValueError("Invalid run retention policy") + candidate = {**self._policies, mode: dict(policy)} + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary: str | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=self.path.parent, + prefix=".run-retention-", suffix=".tmp", delete=False, + ) as stream: + temporary = stream.name + json.dump({"version": 1, "policies": candidate}, stream, + ensure_ascii=False, separators=(",", ":")) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + finally: + if temporary and os.path.exists(temporary): + os.unlink(temporary) + self._policies = candidate + return self.get(mode) + def _last_activity(self, run: "Run") -> float: """Epoch seconds of a run's most recent event (its idle clock). 0.0 when unknown (no persisted events) → such a run is never auto-touched.""" @@ -21,11 +102,12 @@ def _last_activity(self, run: "Run") -> float: return 0.0 -async def retention_sweep(self, *, now: float, archive_after_s: float, - delete_after_s: float) -> dict[str, list[str]]: - """One retention pass: archive started runs idle > archive_after_s, and - DELETE already-archived runs idle > delete_after_s. PINNED runs are never - auto-touched; runs with an unknown idle clock (ts==0) are skipped. Returns +async def retention_sweep(self, *, now: float, + archive_after_s: float | None = None, + delete_after_s: float | None = None) -> dict[str, list[str]]: + """One pass using idle time since the run's last persisted event for both + thresholds. Archive eligible started runs, then DELETE already-archived + runs. PINNED runs are never auto-touched; unknown idle clocks are skipped. Returns {"archived": [...], "deleted": [...]} for logging/tests.""" archived: list[str] = [] deleted: list[str] = [] @@ -37,31 +119,35 @@ async def retention_sweep(self, *, now: float, archive_after_s: float, continue # can't date it → leave it alone idle = now - ts meta = self.meta.get(run.run_id) + policy = self.retention_policies.get(run.mode) if meta["archived"]: - if idle > delete_after_s: + delete_s = (policy["delete_after_days"] * 86400 + if delete_after_s is None else delete_after_s) + if policy["delete_enabled"] and idle > delete_s: if await self.delete(run.run_id): deleted.append(run.run_id) LOG.info( "retention: deleted stale archived run %s (idle %.0fs)", run.run_id, idle) - elif idle > archive_after_s: + else: + archive_s = (policy["archive_after_days"] * 86400 + if archive_after_s is None else archive_after_s) + if not policy["archive_enabled"] or idle <= archive_s: + continue self.set_archived(run.run_id, True, now=now) archived.append(run.run_id) LOG.info("retention: archived idle run %s (idle %.0fs)", run.run_id, idle) return {"archived": archived, "deleted": deleted} -async def retention_loop(self, *, interval_s: float, archive_after_s: float, - delete_after_s: float) -> None: +async def retention_loop(self, *, interval_s: float) -> None: """Background task: run retention_sweep every interval_s until cancelled. Sleeps FIRST so startup isn't blocked and a short-lived test process never triggers a sweep. A sweep failure is logged and the loop continues.""" while True: try: await asyncio.sleep(interval_s) - await self.retention_sweep( - now=time.time(), archive_after_s=archive_after_s, - delete_after_s=delete_after_s) + await self.retention_sweep(now=time.time()) except asyncio.CancelledError: break except Exception: diff --git a/apps/web/server.py b/apps/web/server.py index ef0f58c..adb022b 100644 --- a/apps/web/server.py +++ b/apps/web/server.py @@ -75,10 +75,8 @@ def create_app(manager: Optional[RunManager] = None) -> FastAPI: platform_stack = WebPlatformStack(mgr) - # Retention policy (BE-auto-archive): auto-archive idle runs, then delete the - # ones that stay idle. Defaults: archive after 3 days, delete after 7 days, - # sweep hourly. All env-tunable; set MUTEKI_RETENTION_ENABLED=0 to disable - # (pinned runs are NEVER auto-touched). + # Retention settings are persisted per mode. The environment flag remains a + # global kill switch; each sweep reads the manager's latest stored policies. @asynccontextmanager async def normal_lifespan(app: FastAPI): # Start the reverse-connect control receiver: the in-container supervisors @@ -154,8 +152,6 @@ async def normal_lifespan(app: FastAPI): if enabled: retention_task = asyncio.create_task(mgr.retention_loop( interval_s=_env_float("MUTEKI_RETENTION_INTERVAL", 3600.0), - archive_after_s=_env_float("MUTEKI_ARCHIVE_DAYS", 3.0) * 86400.0, - delete_after_s=_env_float("MUTEKI_DELETE_DAYS", 7.0) * 86400.0, )) runtime_refresh_task: Optional[asyncio.Task] = None runtime_refresh_interval = max( diff --git a/apps/web/ui/app/styles/competition-operations.css b/apps/web/ui/app/styles/competition-operations.css index 0f0836a..282b041 100644 --- a/apps/web/ui/app/styles/competition-operations.css +++ b/apps/web/ui/app/styles/competition-operations.css @@ -115,7 +115,7 @@ .workspace-nav-tools { display:flex;align-items:center;gap:4px; } .workspace-nav-settings :where(a,button),.workspace-nav-command{min-height:36px;font-size:12px!important}.workspace-home-footer{font-size:12px}.workspace-home-footer a{min-height:36px;display:inline-flex;align-items:center} .agent-runtime-settings :where(button,input,select){min-height:38px!important;font-size:13px!important}.agent-runtime-settings :where(label,p,li,dt,dd){font-size:12px!important;line-height:1.5}.agent-runtime-settings button:disabled{cursor:not-allowed;opacity:.58} -.wsettings-page :where(button,input,select,textarea,a){font-size:12px!important}.wsettings-page button:not(.num-field-step){min-height:36px} +.wsettings-page:not([data-section="credentials"]):not([data-section="retention"]) :where(button,input,select,textarea,a){font-size:12px!important}.wsettings-page:not([data-section="credentials"]):not([data-section="retention"]) button:not(.num-field-step){min-height:36px} .workspace-nav-menu { display:none;width:40px;height:40px;border:1px solid var(--line2);border-radius:9px;background:var(--panel);color:var(--bright);font-size:20px; } .task-center-trigger { min-height:36px;font-family:var(--font-sans);font-size:12px; } .task-center-backdrop { position:fixed;z-index:300;inset:0;background:#10182845;backdrop-filter:blur(3px); } @@ -762,7 +762,7 @@ .competition-toolbar-meta{margin-left:0;width:100%}.competition-policy-fields{grid-template-columns:1fr} } @media(max-width:520px){ - .workspace-nav-brand>span:last-child{display:none}.workspace-nav-command{min-width:44px;min-height:44px;justify-content:center}.workspace-nav-badge{font-size:10px}.agent-runtime-settings :where(button,input,select){min-height:44px!important}.wsettings-page button:not(.num-field-step){min-height:44px} + .workspace-nav-brand>span:last-child{display:none}.workspace-nav-command{min-width:44px;min-height:44px;justify-content:center}.workspace-nav-badge{font-size:10px}.agent-runtime-settings :where(button,input,select){min-height:44px!important}.wsettings-page:not([data-section="credentials"]):not([data-section="retention"]) button:not(.num-field-step){min-height:44px} .competition-table-scroll{display:none}.competition-mobile-cards{display:grid;gap:10px}.competition-challenge-card{padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);display:grid;gap:10px}.competition-challenge-card>header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:9px}.competition-challenge-card header input{min-height:0!important;width:17px;height:17px}.competition-challenge-card header>div{display:grid;gap:3px}.competition-challenge-card header strong{font-size:14px}.competition-challenge-card header small{color:var(--muted);font-size:12px}.competition-challenge-card dl{margin:0;display:grid;grid-template-columns:58px minmax(0,1fr);gap:6px;font-size:12px}.competition-challenge-card dt{color:var(--muted)}.competition-challenge-card dd{margin:0;overflow-wrap:anywhere}.competition-card-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.competition-card-actions button{width:100%;padding:0 8px;border:1px solid var(--line2);border-radius:8px;background:var(--panel);color:var(--text)}.competition-override-backdrop{padding:8px;align-items:end}.competition-override-dialog{border-radius:14px 14px 0 0;padding-bottom:max(18px,env(safe-area-inset-bottom))} .competition-lobby{padding:20px 12px 52px!important}.competition-lobby>div>div{grid-template-columns:1fr!important}.competition-setup-step{min-width:0}.competition-tabs{display:none!important}.competition-mobile-tab-select{display:grid;gap:5px;color:var(--muted);font-size:12px}.competition-mobile-tab-select select{width:100%;padding:0 10px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2);color:var(--text)}.connection-probe-details{grid-template-columns:1fr} } diff --git a/apps/web/ui/components/PlatformUpdate.module.css b/apps/web/ui/components/PlatformUpdate.module.css index 1f5ef5a..5e0ea6a 100644 --- a/apps/web/ui/components/PlatformUpdate.module.css +++ b/apps/web/ui/components/PlatformUpdate.module.css @@ -1,6 +1,5 @@ .page { - width: 100%; height: 100%; min-width: 0; min-height: 0; - overflow: auto; padding: 28px; background: var(--bg); + width: 100%; min-width: 0; min-height: 0; } .layout { width: 100%; max-width: 1220px; margin-inline: auto; @@ -69,7 +68,6 @@ .aside { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 620px) { - .page { padding: 18px 14px 28px; } .layout { gap: 18px; } .main, .aside { gap: 16px; } .aside { grid-template-columns: minmax(0, 1fr); } diff --git a/apps/web/ui/components/RetentionSettings.tsx b/apps/web/ui/components/RetentionSettings.tsx new file mode 100644 index 0000000..a3c677b --- /dev/null +++ b/apps/web/ui/components/RetentionSettings.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Input, Switch } from "@/components/chat/ui"; +import { SettingsNote, SettingsPage, SettingsRow, SettingsSection } from "@/components/settings/primitives"; +import { apiFetch } from "@/lib/useRun"; + +type Mode = "ctf" | "pentest"; +type Policy = { archive_enabled: boolean; archive_after_days: number; delete_enabled: boolean; delete_after_days: number }; +const initialPolicy: Policy = { archive_enabled: false, archive_after_days: 15, delete_enabled: false, delete_after_days: 30 }; + +export function RetentionSettings({ mode }: { mode: Mode }) { + const [policy, setPolicy] = useState(initialPolicy); + const [days, setDays] = useState({ archive: "15", delete: "30" }); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [loadRevision, setLoadRevision] = useState(0); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + let active = true; + setLoading(true); + setLoadFailed(false); + setError(""); + void apiFetch("/api/settings/run-retention").then(async (response) => { + if (!response.ok) throw new Error(`读取设置失败(${response.status})`); + const data = await response.json() as { policies: Record }; + if (!active) return; + const value = data.policies[mode]; + setPolicy(value); + setDays({ archive: String(value.archive_after_days), delete: String(value.delete_after_days) }); + }).catch((cause) => { if (active) { setLoadFailed(true); setError(cause instanceof Error ? cause.message : "读取保留设置失败"); } }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [mode, loadRevision]); + + const setDaysValue = (key: "archive" | "delete", value: string) => { + setDays((current) => ({ ...current, [key]: value })); + setError(""); + const parsed = Number(value); + if (value !== "" && Number.isInteger(parsed) && parsed > 0) { + setPolicy((current) => ({ ...current, [key === "archive" ? "archive_after_days" : "delete_after_days"]: parsed })); + } + setFeedback(""); + }; + + const save = async () => { + const archiveDays = Number(days.archive); + const deleteDays = Number(days.delete); + if (!Number.isInteger(archiveDays) || archiveDays < 1 || archiveDays > 3650 || !Number.isInteger(deleteDays) || deleteDays < 1 || deleteDays > 3650) { + setError("天数必须是 1 到 3650 之间的整数。"); + return; + } + if (policy.archive_enabled && policy.delete_enabled && deleteDays <= archiveDays) { + setError("同时启用归档和删除时,删除天数必须大于归档天数。"); + return; + } + const next = { ...policy, archive_after_days: archiveDays, delete_after_days: deleteDays }; + setSaving(true); setError(""); setFeedback(""); + try { + const response = await apiFetch(`/api/settings/run-retention/${mode}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next), + }); + if (!response.ok) throw new Error(`保存失败(${response.status})`); + const data = await response.json() as { policy: Policy }; + setPolicy(data.policy); + setDays({ archive: String(data.policy.archive_after_days), delete: String(data.policy.delete_after_days) }); + setFeedback("保留设置已保存"); + } catch (cause) { setError(cause instanceof Error ? cause.message : "保存失败,请重试。"); } + finally { setSaving(false); } + }; + + return
+ + {loadFailed ? : null} + + } + > + + { setPolicy((current) => ({ ...current, archive_enabled: checked })); setError(""); setFeedback(""); }} />} + /> + setDaysValue("archive", event.target.value)} disabled={loading || loadFailed} />天
} + /> + + + { setPolicy((current) => ({ ...current, delete_enabled: checked })); setError(""); setFeedback(""); }} />} + /> + setDaysValue("delete", event.target.value)} disabled={loading || loadFailed} />天} + /> + + {error ? {error} : null} + {feedback ? {feedback} : null} + + ; +} diff --git a/apps/web/ui/components/WorkerOrchestration.tsx b/apps/web/ui/components/WorkerOrchestration.tsx index 803908a..d25c4ff 100644 --- a/apps/web/ui/components/WorkerOrchestration.tsx +++ b/apps/web/ui/components/WorkerOrchestration.tsx @@ -8,7 +8,8 @@ import { Icon, type IconName } from "@/components/Icon"; import { EngineLogo } from "@/components/EngineLogo"; import { ModelTestTerminal } from "@/components/ModelTestTerminal"; import { NumberField } from "@/components/NumberField"; -import { PlatformUpdate } from "@/components/PlatformUpdate"; +import { RetentionSettings } from "@/components/RetentionSettings"; +import { SettingsPage } from "@/components/settings/primitives"; import { type GlobalCredential, type ModelEndpoint, @@ -57,8 +58,8 @@ type Seat = NonNullable[number]; type Credential = NonNullable[number]; type ReviewPolicy = NonNullable; type VerifierPolicy = NonNullable; -type SettingsSection = "roster" | "credentials" | "runtime" | "scheduling" | "models" | "system"; -const WORKER_SECTIONS: SettingsSection[] = ["roster", "credentials", "runtime", "system", "scheduling", "models"]; +type SettingsSection = "roster" | "credentials" | "runtime" | "scheduling" | "models" | "retention"; +const WORKER_SECTIONS: SettingsSection[] = ["roster", "credentials", "runtime", "scheduling", "models", "retention"]; function isWorkerSection(value: string): value is SettingsSection { return WORKER_SECTIONS.includes(value as SettingsSection); } @@ -1738,6 +1739,10 @@ export function WorkerOrchestration({ defaultReturnTo = "/", navigation }: { def const params = new URLSearchParams(window.location.search); const requested = (params.get("section") || params.get("tab") || window.location.hash.replace(/^#/, "")).trim(); if (!requested) return; + if (requested === "system") { + window.location.replace("/settings/update"); + return; + } if (requested === "appearance") { window.location.replace("/settings/appearance"); return; @@ -2352,7 +2357,7 @@ export function WorkerOrchestration({ defaultReturnTo = "/", navigation }: { def { id: "runtime", label: "运行环境", icon: "terminal" }, { id: "scheduling", label: "调度与预算", icon: "gear" }, { id: "models", label: "推理模型", icon: "sparkles" }, - { id: "system", label: "系统更新", icon: "refresh" }, + { id: "retention", label: "任务保留", icon: "archive" }, ]; const refreshCredentials = (action?: "save" | "import" | "create" | "delete") => { void getGlobalCredentials().then((rows) => { @@ -2374,7 +2379,7 @@ export function WorkerOrchestration({ defaultReturnTo = "/", navigation }: { def runtime: "运行环境", scheduling: "调度与预算", models: "推理模型", - system: "系统更新", + retention: "任务保留", }; if (!config) return ( @@ -2419,7 +2424,7 @@ export function WorkerOrchestration({ defaultReturnTo = "/", navigation }: { def
-

{titles[section]}

{section === "system" ? 稳定通道 : section === "credentials" ? null : <>{dirty ? "未保存" : "已保存"}
}
+ {section === "retention" || section === "credentials" ? null :

{titles[section]}

{dirty ? "未保存" : "已保存"}
}
{section === "roster" ?
@@ -2431,11 +2436,11 @@ export function WorkerOrchestration({ defaultReturnTo = "/", navigation }: { def : selectedSeat && updateSeat(selectedSeat.id, patch)} onEngine={(engine) => selectedSeat && changeEngine(selectedSeat.id, engine)} onAccount={(key, globalCredential) => selectedSeat && bindAccount(selectedSeat.id, key, globalCredential)} onDiscoverModels={refreshWorkerModels} discoveringModels={discoveringModels} onDuplicate={() => selectedSeat && duplicateSeat(selectedSeat.id)} onDelete={() => selectedSeat && deleteSeat(selectedSeat.id)} onTest={() => selectedSeat && void testSeat(selectedSeat)} />}
- : section === "credentials" ?
+ : section === "credentials" ?
: section === "runtime" ? { setBackend(next); setTestResults({}); markDirty(); if (next === "container" && !imageStatus) void refreshImage(); }} onNetwork={(next) => { setNetwork(next); setTestResults({}); markDirty(); }} onContainerScope={(next) => { setContainerScope(next); markDirty(); }} onWorkerPrivilege={(next) => { setWorkerPrivilege(next); markDirty(); }} onVpnEnabled={(next) => { setVpnEnabled(next); markDirty(); }} onVpnUpload={(file) => { setVpnUploading(true); void uploadOpenVpnConfig(file).then((status) => { setVpnStatus(status); setVpnEnabled(true); markDirty(); showFeedback("OpenVPN 配置已上传"); }).catch((error) => showFeedback(`上传失败:${error instanceof Error ? error.message : String(error)}`)).finally(() => setVpnUploading(false)); }} onRefreshImage={() => void refreshImage()} onPullImage={() => void pullImage()} /> : section === "scheduling" ? { if (patch.raceTimeout !== undefined) setRaceTimeout(patch.raceTimeout); if (patch.dispatchMode !== undefined) setDispatchMode(patch.dispatchMode); if (patch.startWorkers !== undefined) setStartWorkers(patch.startWorkers); if (patch.maxTotal !== undefined) setMaxTotal(patch.maxTotal); if (patch.wallClock !== undefined) setWallClock(patch.wallClock); if (patch.costBudget !== undefined) setCostBudget(patch.costBudget); markDirty(); }} /> : section === "models" ? { setLlmProfiles(next); markDirty(); }} onEndpointSaved={(endpoint) => setModelEndpoints((current) => [...current.filter((item) => item.id !== endpoint.id), endpoint])} /> - : } + : } {feedback ?
{feedback}
: null}
diff --git a/apps/web/ui/components/settings/SettingsCompatibility.tsx b/apps/web/ui/components/settings/SettingsCompatibility.tsx index 0b64973..d7a8e18 100644 --- a/apps/web/ui/components/settings/SettingsCompatibility.tsx +++ b/apps/web/ui/components/settings/SettingsCompatibility.tsx @@ -10,10 +10,12 @@ import { SettingsNote } from "./primitives"; export function SettingsCompatibility({page, children}: {page: SettingsPageId | null; children: ReactNode}) { const prefs = useSyncExternalStore(subscribeUiPreferences, uiPreferenceSnapshot, serverUiPreferenceSnapshot); const native = useNativeDesktopState(); + const deviceUpdate = page === "update" && native.desktop; const {lang} = useLang(); const en = lang === "en"; const [health, setHealth] = useState<{version?: string; features?: Record} | null>(null); const [error, setError] = useState(""); useEffect(() => { + if (deviceUpdate) return; const abort = new AbortController(); void apiFetch("/api/health", {signal: abort.signal}).then(async response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -23,12 +25,12 @@ export function SettingsCompatibility({page, children}: {page: SettingsPageId | if (!abort.signal.aborted) setHealth(value); }).catch(cause => { if (!abort.signal.aborted) setError(String(cause)); }); return () => abort.abort(); - }, []); - const missing = page && health?.features ? SETTINGS_PAGES[page].requiredFeatures.filter(feature => health.features?.[feature] !== 1) : []; + }, [deviceUpdate]); + const missing = !deviceUpdate && page && health?.features ? SETTINGS_PAGES[page].requiredFeatures.filter(feature => health.features?.[feature] !== 1) : []; return <> {prefs.status === "saving" ? {en ? "Saving shared preferences…" : "正在保存共享偏好…"} : null} - {error ? {en ? "Could not read service compatibility: " : "无法读取服务兼容信息:"}{error} : null} - {health && (!health.features || missing.length > 0) ? {!health.features + {error && !deviceUpdate ? {en ? "Could not read service compatibility: " : "无法读取服务兼容信息:"}{error} : null} + {!deviceUpdate && health && (!health.features || missing.length > 0) ? {!health.features ? (en ? "This service has not declared its settings features. Update the service to verify compatibility." : "当前服务尚未声明设置功能支持情况,请更新服务后确认兼容性。") : `${en ? "The service does not support these required features: " : "服务不支持此页面所需的功能:"}${missing.join(", ")}`} : null}
{en ? "Version compatibility" : "版本兼容信息"} @@ -38,6 +40,6 @@ export function SettingsCompatibility({page, children}: {page: SettingsPageId | {native.state?.remoteUiBuild && native.state.remoteUiBuild !== "unknown" && native.state.remoteUiBuild !== process.env.NEXT_PUBLIC_MUTEKI_UI_BUILD ?

{en ? "The local and remote interfaces use different builds; page content may differ. Feature availability follows the service contract." : "本地与远程界面的构建不同,页面内容可能存在差异;功能可用性以服务声明为准。"}

: null} {native.state?.remoteUiBuild && native.state.remoteAppearanceContract !== "1" ?

远程 Web 界面未声明外观同步契约,请更新服务端 Web 界面。

: null} : null}
- {health && missing.length === 0 ? children : !health && !error ?

{en ? "Checking service compatibility…" : "正在检查服务兼容性…"}

: null} + {deviceUpdate || (health && missing.length === 0) ? children : !health && !error ?

{en ? "Checking service compatibility…" : "正在检查服务兼容性…"}

: null} ; } diff --git a/apps/web/ui/components/settings/SettingsContent.tsx b/apps/web/ui/components/settings/SettingsContent.tsx index 9b257e9..5d6f222 100644 --- a/apps/web/ui/components/settings/SettingsContent.tsx +++ b/apps/web/ui/components/settings/SettingsContent.tsx @@ -17,6 +17,7 @@ export const SETTINGS_LOADERS: Record = { import: lazy(() => import("./ImportSettings")), extensions: lazy(() => import("../ExtensionSettings").then(m => ({default: () =>
}))), operations: lazy(() => import("../OperationsSettings").then(m => ({default: () =>
}))), + update: lazy(() => import("./UpdateSettings").then(m => ({default: m.UpdateSettings}))), access: lazy(() => import("./AccessSettings").then(m => ({default: m.AccessSettings}))), desktop: lazy(async () => { const { DesktopPage } = await import("./DesktopConnectionSettings"); return {default: function Desktop() { const host = useSettingsHost(); return host.desktop ? : null; }}; }), }; diff --git a/apps/web/ui/components/settings/UpdateSettings.tsx b/apps/web/ui/components/settings/UpdateSettings.tsx new file mode 100644 index 0000000..8d32c1d --- /dev/null +++ b/apps/web/ui/components/settings/UpdateSettings.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Alert, Button, Card, Chip, ProgressBar } from "@heroui/react"; +import { Icon } from "@/components/Icon"; +import { PlatformUpdate } from "@/components/PlatformUpdate"; +import styles from "@/components/PlatformUpdate.module.css"; +import { desktopChatBridge, type DesktopUpdateStatus } from "@/lib/desktopChatBridge"; +import { useLang } from "@/lib/i18n"; +import { useSettingsHost } from "./SettingsHost"; + +const ACTIVE = new Set(["checking", "downloading", "validating", "installing"]); +const RELEASES = "https://github.com/FishCodeTech/muteki/releases/latest"; + +function DesktopUpdateSettings() { + const { lang } = useLang(); + const t = (zh: string, en: string) => lang === "en" ? en : zh; + const bridge = desktopChatBridge(); + const updates = bridge?.updates; + const [status, setStatus] = useState(null); + const [action, setAction] = useState<"check" | "install" | null>(null); + const [error, setError] = useState(""); + useEffect(() => { + if (!updates) return; + let active = true; + let revision = 0; + const accept = (next: DesktopUpdateStatus) => { if (active) { revision++; setStatus(next); } }; + const off = updates.onStatus(accept); + const initialRevision = revision; + void updates.getStatus().then(next => { if (revision === initialRevision) accept(next); }).catch(cause => { + if (active) setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { active = false; off(); }; + }, [updates]); + const run = async (kind: "check" | "install") => { + if (!updates) return; + setAction(kind); setError(""); + try { setStatus(await updates[kind]()); } + catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } + finally { setAction(null); } + }; + const openRelease = async () => { + try { await bridge?.openExternal?.(status?.releaseUrl || RELEASES); } + catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } + }; + const busy = Boolean(action || (status && ACTIVE.has(status.state))); + const available = status?.state === "available" || status?.state === "ready"; + const labels: Record = { + idle: t("等待检查", "Ready to check"), checking: t("检查中…", "Checking…"), current: t("当前已是最新版本", "Up to date"), + available: t("发现新版本", "Update available"), downloading: t("下载中…", "Downloading…"), validating: t("正在验证更新", "Validating update"), + ready: t("更新已准备好", "Update ready"), installing: t("正在安装更新", "Installing update"), error: t("更新失败", "Update failed"), + }; + const failure = error || status?.error?.message; + return
+
+ + +
{t("客户端版本", "Desktop version")}
+ +
+ +
{t("当前运行版本", "Installed version")}{status?.currentVersion ? `v${status.currentVersion}` : "—"}
+
{t("最新稳定版本", "Latest stable version")}{status?.latestVersion ? `v${status.latestVersion}` : t("待检查", "Not checked")}{t("上次检查:", "Last checked: ")}{status?.checkedAt ? new Date(status.checkedAt).toLocaleString(lang === "en" ? "en-US" : "zh-CN") : t("尚未检查", "Never")}
+
+ {status ? labels[status.state] : t("正在读取", "Loading")}{status?.message || t("检查客户端及内置服务的完整版本。", "Check the complete desktop app and bundled service.")} + {busy && status?.state !== "checking" ?
{status?.message}
: null} +
+ {failure || !updates ? {t("更新未完成", "Update unavailable")}{failure || t("当前客户端未提供更新接口,请从发布页面安装新版客户端。", "This desktop app does not provide the update interface. Install the latest app from the release page.")} : null} + +
{t("客户端更新", "Desktop updates")}
+
{t("安装稳定版本", "Install stable release")}

{status?.installReason || t("下载后验证版本,保存草稿并重启客户端。", "Download and validate the update, save drafts, and restart the app.")}

+ {t("更新前备份工作台数据,启动验收失败时恢复原版本。", "Workspace data is backed up before updating. Failed startup checks restore the previous app.")} +
+
+ +
; +} + +export function UpdateSettings() { + return useSettingsHost().client === "desktop" ? : ; +} diff --git a/apps/web/ui/components/settings/agents/AgentsSettings.tsx b/apps/web/ui/components/settings/agents/AgentsSettings.tsx index 92115bf..fcbdacd 100644 --- a/apps/web/ui/components/settings/agents/AgentsSettings.tsx +++ b/apps/web/ui/components/settings/agents/AgentsSettings.tsx @@ -266,6 +266,14 @@ export function AgentsSettings({ taskSettings = false, navigation, onCredentials /* Selection: URL query (?engine=&instance=&credential=) when the host passes * navigation, otherwise plain local state (task settings embed). */ const [localSelection, setLocalSelection] = useState({ engine: null, instanceId: "", credentialId: "" }); + const taskSelectionInitialized = useRef(false); + useEffect(() => { + if (!taskSettings || navigation || taskSelectionInitialized.current) return; + const first = engines.find((item) => item.supportStatus === "supported"); + if (!first) return; + taskSelectionInitialized.current = true; + setLocalSelection({ engine: first.engine, instanceId: "", credentialId: "" }); + }, [engines, navigation, taskSettings]); const selection = useMemo( () => navigation ? readSelection(navigation.searchParams, descriptors) : localSelection, [descriptors, navigation, localSelection], @@ -897,10 +905,8 @@ export function AgentsSettings({ taskSettings = false, navigation, onCredentials return (
-
-

- {taskSettings ? "在这里新增、导入、测试和管理做题 Worker 使用的凭据。带 Base URL 的自定义端点也可供 Reason / Titler 使用。" : "选择左侧引擎,在右侧管理它的凭据、接入方式与模型。"} -

+
+ {!taskSettings ?

选择左侧引擎,在右侧管理它的凭据、接入方式与模型。

: null}
{lastCheckedAt ? {lastCheckedAt} : null}
-
+
@@ -986,7 +992,7 @@ export function AgentsSettings({ taskSettings = false, navigation, onCredentials {currentInstance ? ` · ${versionStatus(currentInstance.health?.version_check)}` : ""}

-
+
启用 ; "include_tool_summaries"?: boolean; "message_ids"?: Array | null; "redact_paths"?: boolean }; export type TerminalSessionCreateBody = { "cols"?: number; "name"?: string; "rows"?: number }; @@ -1442,6 +1443,22 @@ export interface OpenApiOperations { body: never; response: unknown | OpenApiSchemas.HTTPValidationError; }; + "get_run_retention_settings_api_settings_run_retention_get": { + method: "GET"; + path: "/api/settings/run-retention"; + pathParams: { }; + query: { }; + body: never; + response: unknown; + }; + "put_run_retention_settings_api_settings_run_retention__mode__put": { + method: "PUT"; + path: "/api/settings/run-retention/{mode}"; + pathParams: { "mode": string }; + query: { }; + body: OpenApiSchemas.RunRetentionPolicyBody; + response: unknown | OpenApiSchemas.HTTPValidationError; + }; "get_openvpn_config_api_settings_runtime_openvpn_get": { method: "GET"; path: "/api/settings/runtime/openvpn"; diff --git a/apps/web/ui/lib/desktopChatBridge.ts b/apps/web/ui/lib/desktopChatBridge.ts index ac371dd..cf196cb 100644 --- a/apps/web/ui/lib/desktopChatBridge.ts +++ b/apps/web/ui/lib/desktopChatBridge.ts @@ -44,6 +44,14 @@ export interface DesktopWorkspaceGrant { host: "desktop-client"; mapping: "user-selected"; } +export interface DesktopUpdateStatus { + state: "idle" | "checking" | "current" | "available" | "downloading" | "validating" | "ready" | "installing" | "error"; + currentVersion: string; latestVersion?: string; progress?: number; message?: string; + error?: { code: string; message: string }; canInstall?: boolean; installReason?: string; + checkedAt?: string; releaseUrl?: string; + lastInstall?: { state: "complete" | "failed"; version: string; at: string; message: string; code?: string }; +} + /** Provider-driven operation on the thread's native preview (see apps/desktop main.cjs). */ export type DesktopBrowserControlInput = { threadId: string; timeoutMs: number } & DesktopBrowserAction; export type DesktopBrowserAction = ( @@ -83,6 +91,8 @@ export interface DesktopPickedElement { } export interface DesktopChatBridge { + updates?: { getStatus: () => Promise; check: () => Promise; install: () => Promise; onStatus: (callback: (status: DesktopUpdateStatus) => void) => () => void }; + openExternal?: (url: string) => Promise; createVisualization?: (input: { threadId: string; html: string }) => Promise<{ id: string; url: string }>; releaseVisualization?: (id: string) => Promise; removeAttachment?: (input: { id: string; draftId: string }) => Promise; diff --git a/apps/web/ui/package-lock.json b/apps/web/ui/package-lock.json index d396288..e449a54 100644 --- a/apps/web/ui/package-lock.json +++ b/apps/web/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "muteki-command-deck", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "muteki-command-deck", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "@heroui/react": "3.2.4", "@heroui/styles": "3.2.4", diff --git a/apps/web/ui/package.json b/apps/web/ui/package.json index 7bb6e53..88087cd 100644 --- a/apps/web/ui/package.json +++ b/apps/web/ui/package.json @@ -1,6 +1,6 @@ { "name": "muteki-command-deck", - "version": "0.4.1", + "version": "0.4.2", "private": true, "type": "module", "description": "Project Muteki web command deck — a conversation-first frontend with a live agent collaboration map, evidence views, worker controls, and HITL operations.", diff --git a/docker-compose.release.yml b/docker-compose.release.yml index f3085f4..6cb2b22 100644 --- a/docker-compose.release.yml +++ b/docker-compose.release.yml @@ -1,11 +1,11 @@ # Project Muteki 版本化生产部署。 # # 公开默认镜像仓库是 ghcr.io/fishcodetech。 -# Git / GHCR 标签带 v,例如 v0.4.1。 +# Git / GHCR 标签带 v,例如 v0.4.2。 # 私有 GHCR 需要先 docker login ghcr.io,并设置 MUTEKI_IMAGE_REGISTRY。 # # MUTEKI_IMAGE_REGISTRY=ghcr.io/fishcodetech \ -# MUTEKI_RELEASE_VERSION=v0.4.1 \ +# MUTEKI_RELEASE_VERSION=v0.4.2 \ # MUTEKI_HOST_DATA_ROOT=/opt/muteki/data \ # MUTEKI_WEB_PASSWORD=... docker compose -f docker-compose.release.yml up -d --wait @@ -17,7 +17,7 @@ networks: services: web-api: - image: ${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}/muteki-web:${MUTEKI_RELEASE_VERSION:-v0.4.1} + image: ${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}/muteki-web:${MUTEKI_RELEASE_VERSION:-v0.4.2} networks: [muteki_net] ports: - "${MUTEKI_API_PORT:-8000}:8000" @@ -37,8 +37,8 @@ services: MUTEKI_COORDINATOR_CONTROL_ROOT: "${MUTEKI_HOST_DATA_ROOT}/state/control" MUTEKI_WEB_BIND: "0.0.0.0" MUTEKI_WEB_PASSWORD: "${MUTEKI_WEB_PASSWORD:?set MUTEKI_WEB_PASSWORD}" - MUTEKI_WORKER_IMAGE: "${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}/muteki-worker:${MUTEKI_RELEASE_VERSION:-v0.4.1}" - MUTEKI_WORKER_IMAGE_VERSION: "${MUTEKI_RELEASE_VERSION:-v0.4.1}" + MUTEKI_WORKER_IMAGE: "${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}/muteki-worker:${MUTEKI_RELEASE_VERSION:-v0.4.2}" + MUTEKI_WORKER_IMAGE_VERSION: "${MUTEKI_RELEASE_VERSION:-v0.4.2}" MUTEKI_RELEASE_REPOSITORY: "${MUTEKI_RELEASE_REPOSITORY:-FishCodeTech/muteki}" MUTEKI_IMAGE_REGISTRY: "${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}" MUTEKI_GITHUB_TOKEN: "${MUTEKI_GITHUB_TOKEN:-}" @@ -54,7 +54,7 @@ services: restart: unless-stopped ui: - image: ${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}/muteki-ui:${MUTEKI_RELEASE_VERSION:-v0.4.1} + image: ${MUTEKI_IMAGE_REGISTRY:-ghcr.io/fishcodetech}/muteki-ui:${MUTEKI_RELEASE_VERSION:-v0.4.2} networks: [muteki_net] ports: - "${MUTEKI_UI_PORT:-3001}:3001" diff --git a/docker/worker-slim/build.sh b/docker/worker-slim/build.sh index 8da7c1c..d4b507e 100755 --- a/docker/worker-slim/build.sh +++ b/docker/worker-slim/build.sh @@ -9,7 +9,7 @@ # # Usage: ./docker/worker-slim/build.sh [repo] [version] [arch] # repo: image repository (default: muteki-worker-slim; e.g. ghcr.io/fishcodetech/muteki-worker-slim) -# version: version tag (default: v0.4.1; GHCR release tags keep the leading v) +# version: version tag (default: v0.4.2; GHCR release tags keep the leading v) # arch: amd64 | arm64 (default: HOST arch — arm64 on Apple Silicon) # Tags built: : AND :latest. # @@ -28,7 +28,7 @@ set -euo pipefail REPO_IMAGE="${1:-muteki-worker-slim}" -VERSION="${2:-v0.4.1}" +VERSION="${2:-v0.4.2}" # Default arch = host arch (uname -m → docker/go naming). Override with 3rd arg. _host_arch="$(uname -m)" case "${_host_arch}" in diff --git a/docker/worker/build.sh b/docker/worker/build.sh index ea0ce4c..0b5c0b7 100755 --- a/docker/worker/build.sh +++ b/docker/worker/build.sh @@ -9,12 +9,12 @@ # # Usage: ./docker/worker/build.sh [repo] [version] # repo: image repository (default: muteki-worker; e.g. ghcr.io/fishcodetech/muteki-worker) -# version: version tag (default: v0.4.1; GHCR release tags keep the leading v) +# version: version tag (default: v0.4.2; GHCR release tags keep the leading v) # Tags built: : AND :latest (code defaults to :latest). set -euo pipefail REPO_IMAGE="${1:-muteki-worker}" -VERSION="${2:-v0.4.1}" +VERSION="${2:-v0.4.2}" TAG="${REPO_IMAGE}:${VERSION}" LATEST="${REPO_IMAGE}:latest" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/muteki/cli.py b/muteki/cli.py index db74be5..a433e57 100644 --- a/muteki/cli.py +++ b/muteki/cli.py @@ -57,7 +57,7 @@ def build_parser() -> argparse.ArgumentParser: status.add_argument("--root", type=Path) upgrade = subparsers.add_parser("upgrade", help="检查或安装指定版本") - upgrade.add_argument("target", nargs="?", help="目标版本,例如 v0.4.1;省略时使用最新稳定版") + upgrade.add_argument("target", nargs="?", help="目标版本,例如 v0.4.2;省略时使用最新稳定版") upgrade.add_argument("--check", action="store_true", help="只检查,不下载或切换") upgrade.add_argument("--force", action="store_true", help="重新安装相同版本") upgrade.add_argument("--json", action="store_true") diff --git a/muteki/solver/container_exec.py b/muteki/solver/container_exec.py index c5b1995..57645cd 100644 --- a/muteki/solver/container_exec.py +++ b/muteki/solver/container_exec.py @@ -71,7 +71,7 @@ # at runtime; do not bake claude/codex/cursor login state into this image. # One generic worker image (NOT a per-recipe tag), published to Docker Hub so any # host can `docker pull` it. Default to the moving :latest; override with -# MUTEKI_WORKER_IMAGE to pin a version (e.g. ghcr.io/fishcodetech/muteki-worker:v0.4.1). +# MUTEKI_WORKER_IMAGE to pin a version (e.g. ghcr.io/fishcodetech/muteki-worker:v0.4.2). WORKER_IMAGE = os.environ.get("MUTEKI_WORKER_IMAGE", "ghcr.io/fishcodetech/muteki-worker:latest") CONTAINER_WORKSPACE = "/home/kali/workspace" CONTAINER_CONTROL_DIR = "/run/muteki/control" # bind-mounted; carries the per-run token diff --git a/muteki/version.py b/muteki/version.py index a88217e..6565d27 100644 --- a/muteki/version.py +++ b/muteki/version.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -FALLBACK_VERSION = "0.4.1" +FALLBACK_VERSION = "0.4.2" def project_root() -> Path: diff --git a/pyproject.toml b/pyproject.toml index 551b838..17bc6cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-muteki" -version = "0.4.1" +version = "0.4.2" description = "Project Muteki (無敵): autonomous multi-model CTF-solving AI agent swarm" requires-python = ">=3.13" dependencies = [ diff --git a/uv.lock b/uv.lock index 123e1f3..3aec591 100644 --- a/uv.lock +++ b/uv.lock @@ -765,7 +765,7 @@ wheels = [ [[package]] name = "project-muteki" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "anyio" },