aps-agent/apps/desktop/updater.cjs

571 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* APS 桌面端离线升级模块(纯 Node,无 Electron 依赖,可在 node --test 下直接测试)。
*
* 升级流程(applyUpdate):
* ① 校验升级包(manifest 版本号 / sha256 / size / 平台)
* ② 升级前备份当前安装目录(copy 或 listing 两种模式)
* ③ 触发安装(默认 NSIS 静默 /S;可注入 install 函数便于测试)
* ④ 安装后健康检查(GET /api/health,可选期望版本)
* ⑤ 任一步失败 → 自动回滚(恢复备份)+ 审计记录(本地 JSONL 为主,可选上报 /api/gov/audit)
*
* 审计取舍说明:
* - 本地日志文件({apsHome}/logs/upgrade-audit.jsonl,append-only)为默认审计通道;
* - 上报 /api/gov/audit/events(round-45 HH,矩阵 103 收口)为默认尝试的旁路通道:
* sidecar 启动时生成一次性审计 token(仅限本机、进程内,经 Electron main 注入
* auditApiUrl + auditToken + sidecarNonce);配置 auditApiUrl 即默认上报(带 Bearer
* token + x-aps-sidecar-nonce,过 SidecarIdentityApp nonce 门禁),端点不存在/403/
* 网络错误 → fail-soft 记本地、不阻塞升级流程(保持 round-43 语义);
* - web 模式该端点要求登录身份;desktop 模式由 nonce 门禁保护(见 docs/architecture/harness.md)。
*
* 回滚模式说明:
* - mode 'copy'(默认):整目录拷贝备份,可完整恢复文件内容;
* - mode 'listing':仅记录文件清单 + 各文件 sha256(适合大目录/仅审计),
* 此模式无法恢复内容,restoreBackup 会抛出 UpgradeError(需重装或改用 copy)。
*/
const crypto = require('crypto');
const fs = require('fs');
const fsp = fs.promises;
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const LOOPBACK_HOST = '127.0.0.1';
class UpgradeError extends Error {
/**
* @param {string} code 机器可读错误码(CHECKSUM_MISMATCH / SIZE_MISMATCH / VERSION_NOT_NEWER / ...)
* @param {string} message 人类可读描述
* @param {object} [details] 附加字段(期望值/实际值)
*/
constructor(code, message, details = {}) {
super(message);
this.name = 'UpgradeError';
this.code = code;
this.details = details;
}
}
/** 语义化版本比较:返回 -1 / 0 / 1(支持 主.次.补 及任意数字段) */
function compareVersions(a, b) {
const parse = (v) => String(v).split('.').map((part) => {
const n = Number.parseInt(part, 10);
return Number.isNaN(n) ? 0 : n;
});
const pa = parse(a);
const pb = parse(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
/** 计算文件 SHA-256(小写十六进制) */
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
/** 读取并校验升级清单;返回规范化对象 */
function loadManifest(manifestPath) {
let manifest;
try {
manifest = readJson(manifestPath);
} catch (error) {
throw new UpgradeError('MANIFEST_READ_FAILED', `升级清单读取失败: ${manifestPath}`, { cause: String(error) });
}
const required = ['schemaVersion', 'product', 'version', 'package'];
for (const key of required) {
if (manifest[key] === undefined || manifest[key] === null || manifest[key] === '') {
throw new UpgradeError('MANIFEST_INVALID', `升级清单缺少必需字段: ${key}`);
}
}
const pkg = manifest.package;
if (!pkg.fileName || typeof pkg.size !== 'number' || !/^[0-9a-f]{64}$/.test(pkg.sha256 || '')) {
throw new UpgradeError('MANIFEST_INVALID', '升级清单 package 字段不完整(需 fileName/size/sha256[64hex])');
}
return manifest;
}
/**
* 校验升级包本身:存在性 + size + sha256 + 平台。
* @param {object} options
* @param {object} options.manifest 已 loadManifest 的清单
* @param {string} options.packagePath 安装包绝对路径
* @param {string} [options.platform] 期望平台(默认 process.platform)
*/
async function verifyPackage({ manifest, packagePath, platform = process.platform }) {
const pkg = manifest.package;
let stat;
try {
stat = await fsp.stat(packagePath);
} catch (error) {
throw new UpgradeError('PACKAGE_NOT_FOUND', `升级安装包不存在: ${packagePath}`, { path: packagePath });
}
if (!stat.isFile()) {
throw new UpgradeError('PACKAGE_NOT_FILE', `升级安装包不是文件: ${packagePath}`);
}
if (manifest.platform && manifest.platform !== platform) {
throw new UpgradeError('PLATFORM_MISMATCH', `升级包平台不匹配: 清单=${manifest.platform} 本机=${platform}`, {
expected: manifest.platform,
actual: platform,
});
}
if (stat.size !== pkg.size) {
throw new UpgradeError('SIZE_MISMATCH', `升级包大小不匹配: 期望=${pkg.size} 实际=${stat.size}`, {
expected: pkg.size,
actual: stat.size,
});
}
const actualSha = await sha256File(packagePath);
if (actualSha !== pkg.sha256) {
throw new UpgradeError('CHECKSUM_MISMATCH', `升级包 SHA-256 校验失败: 期望=${pkg.sha256} 实际=${actualSha}`, {
expected: pkg.sha256,
actual: actualSha,
});
}
return { verified: true, size: stat.size, sha256: actualSha };
}
/**
* 校验版本:目标必须严格高于当前版本(拒绝回退/同版本)。
*/
function ensureNewerVersion({ currentVersion, targetVersion }) {
const cmp = compareVersions(targetVersion, currentVersion);
if (cmp <= 0) {
throw new UpgradeError(
'VERSION_NOT_NEWER',
`拒绝升级: 目标版本 ${targetVersion} 不高于当前版本 ${currentVersion}`,
{ currentVersion, targetVersion },
);
}
return true;
}
function copyDirRecursive(src, dest) {
return fsp.cp(src, dest, { recursive: true, force: true });
}
async function walkFiles(root, base = root, acc = []) {
const entries = await fsp.readdir(root, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(root, entry.name);
if (entry.isDirectory()) {
await walkFiles(absolute, base, acc);
} else {
acc.push(path.relative(base, absolute));
}
}
return acc;
}
async function sha256OfTree(root) {
const files = await walkFiles(root);
const listing = [];
for (const rel of files.sort()) {
const absolute = path.join(root, rel);
const stat = await fsp.stat(absolute);
listing.push({ path: rel, size: stat.size, sha256: await sha256File(absolute) });
}
return listing;
}
/**
* 备份安装目录。
* @param {object} options
* @param {string} options.installDir
* @param {string} options.backupDir
* @param {'copy'|'listing'} [options.mode] 默认 copy
*/
async function backupInstall({ installDir, backupDir, mode = 'copy' }) {
const resolvedInstall = path.resolve(installDir);
const resolvedBackup = path.resolve(backupDir);
if (resolvedInstall === resolvedBackup) {
throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同');
}
if (!fs.existsSync(resolvedInstall)) {
throw new UpgradeError('INSTALL_DIR_MISSING', `安装目录不存在: ${resolvedInstall}`);
}
await fsp.rm(resolvedBackup, { recursive: true, force: true });
await fsp.mkdir(resolvedBackup, { recursive: true });
const listing = await sha256OfTree(resolvedInstall);
if (mode === 'copy') {
await copyDirRecursive(resolvedInstall, path.join(resolvedBackup, 'files'));
} else if (mode !== 'listing') {
throw new UpgradeError('BACKUP_MODE_INVALID', `未知备份模式: ${mode}`);
}
await fsp.writeFile(
path.join(resolvedBackup, 'backup-manifest.json'),
`${JSON.stringify({ mode, createdAt: new Date().toISOString(), files: listing }, null, 2)}\n`,
'utf8',
);
return { mode, backupDir: resolvedBackup, fileCount: listing.length };
}
/**
* 从备份恢复安装目录(copy 模式可完整恢复;listing 模式仅能校验、不能恢复内容)。
*/
async function restoreBackup({ backupDir, installDir, expectedMode = 'copy' }) {
const resolvedBackup = path.resolve(backupDir);
const resolvedInstall = path.resolve(installDir);
if (resolvedInstall === resolvedBackup) {
throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同');
}
const backupManifestPath = path.join(resolvedBackup, 'backup-manifest.json');
if (!fs.existsSync(backupManifestPath)) {
throw new UpgradeError('BACKUP_MISSING', `备份不存在: ${backupManifestPath}`);
}
const backup = readJson(backupManifestPath);
if (backup.mode !== expectedMode) {
throw new UpgradeError(
'BACKUP_MODE_MISMATCH',
`备份模式不匹配: 备份=${backup.mode} 期望=${expectedMode}(listing 模式无法恢复内容,需重装)`,
{ backupMode: backup.mode, expectedMode },
);
}
if (backup.mode === 'listing') {
throw new UpgradeError('BACKUP_LISTING_ONLY', 'listing 模式备份不含文件内容,无法自动回滚(请重装)');
}
const filesSource = path.join(resolvedBackup, 'files');
if (!fs.existsSync(filesSource)) {
throw new UpgradeError('BACKUP_FILES_MISSING', `备份文件缺失: ${filesSource}`);
}
// 清空安装目录后整体恢复;不做跨盘/越界检查之外的危险操作。
await fsp.rm(resolvedInstall, { recursive: true, force: true });
await fsp.mkdir(resolvedInstall, { recursive: true });
await copyDirRecursive(filesSource, resolvedInstall);
return { restored: true, installDir: resolvedInstall, fileCount: backup.files.length };
}
/**
* 健康检查(round-44 FF):GET {base}/api/health,期望 HTTP 200。
* 版本比对:若 expectedVersion 且响应体含 version 字符串,则版本必须与 expectedVersion
* 完全一致(不一致 → HEALTH_VERSION_MISMATCH);响应体无 version 字段(或非字符串)时
* 退化为仅 HTTP 200 检查,不因缺失版本而失败(兼容旧网关/开发环境)。
* nonce 续接(round-45 HH):sidecarNonce 提供时携带 x-aps-sidecar-nonce 头——升级后新
* sidecar 进程启用 nonce 门禁(缺失/不匹配 → 403),安装后健康检查必须用新网关的 nonce;
* 403 且已携带 nonce 时错误 details.nonceMismatch=true,便于调用方区分“nonce 过期”与普通故障。
*/
function probeHealth({ base, expectedVersion, timeoutMs = 5_000, sidecarNonce = null }) {
const url = new URL('/api/health', base);
return new Promise((resolve, reject) => {
const headers = sidecarNonce ? { 'x-aps-sidecar-nonce': sidecarNonce } : {};
const request = http.get(url, { timeout: timeoutMs, headers }, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new UpgradeError('HEALTH_HTTP_STATUS', `健康检查 HTTP ${response.statusCode}`, {
url: url.href,
statusCode: response.statusCode,
nonceMismatch: response.statusCode === 403 && Boolean(sidecarNonce),
}));
return;
}
let parsed = null;
try { parsed = JSON.parse(body); } catch { /* 非 JSON 仍视为健康 */ }
const version = parsed && typeof parsed.version === 'string' ? parsed.version : null;
if (expectedVersion && version && version !== expectedVersion) {
reject(new UpgradeError('HEALTH_VERSION_MISMATCH', `健康检查版本不匹配: 期望=${expectedVersion} 实际=${version}`, {
expectedVersion,
actual: version,
}));
return;
}
resolve({
ok: true, statusCode: 200, version,
interfaceVersion: parsed?.interfaceVersion ?? null,
nonce: sidecarNonce, // 本次探测使用的 nonce(可追溯)
});
});
});
request.once('timeout', () => request.destroy(new UpgradeError('HEALTH_TIMEOUT', '健康检查超时')));
request.once('error', (error) => reject(error instanceof UpgradeError ? error : new UpgradeError('HEALTH_UNREACHABLE', `健康检查失败: ${error.message}`)));
});
}
/**
* NSIS 静默安装触发(默认安装器;测试注入 install 函数替代)。
* NSIS 参数:/S 静默;/D= 安装目录(须为最后参数、不带引号)。
*/
function nsisInstall({ installerPath, installDir }) {
return new Promise((resolve, reject) => {
const args = ['/S'];
if (installDir) args.push(`/D=${installDir}`);
const child = spawn(installerPath, args, {
windowsHide: true,
stdio: 'ignore',
detached: false,
});
const timer = setTimeout(() => {
child.kill();
reject(new UpgradeError('INSTALL_TIMEOUT', '安装器执行超时'));
}, 10 * 60 * 1000);
child.once('error', (error) => {
clearTimeout(timer);
reject(new UpgradeError('INSTALL_SPAWN_FAILED', `安装器启动失败: ${error.message}`));
});
child.once('exit', (code) => {
clearTimeout(timer);
if (code === 0) resolve({ exitCode: 0 });
else reject(new UpgradeError('INSTALL_EXIT_NONZERO', `安装器退出码非零: ${code}`, { exitCode: code }));
});
});
}
class Updater {
/**
* @param {object} options
* @param {string} [options.apsHome] 用户目录(审计日志写 {apsHome}/logs/upgrade-audit.jsonl)
* @param {object} [options.logger] console 兼容 logger
* @param {string} [options.auditApiUrl] 可选:上报 /api/gov/audit 的 base(如 sidecar origin)
* @param {string} [options.auditToken] 可选:sidecar 一次性审计 token(Bearer 携带)
* @param {string} [options.sidecarNonce] 可选:sidecar 进程 nonce(x-aps-sidecar-nonce 头,过网关身份门禁)
*/
constructor({ apsHome, logger = console, auditApiUrl = null, auditToken = null, sidecarNonce = null } = {}) {
this.apsHome = apsHome || path.join(require('os').homedir(), '.aps');
this.logger = logger;
this.auditApiUrl = auditApiUrl;
this.auditToken = auditToken;
this.sidecarNonce = sidecarNonce;
}
/**
* ① 检查更新:读取清单 + 校验安装包 + 版本比较。
* @returns {Promise<{updateAvailable:boolean,currentVersion:string,targetVersion:string,manifest:object,verified:boolean}>}
*/
async checkForUpdate({ manifestPath, packagePath, currentVersion, platform }) {
const manifest = loadManifest(manifestPath);
let verified = false;
let verifyError = null;
try {
if (packagePath) {
await verifyPackage({ manifest, packagePath, platform });
verified = true;
}
} catch (error) {
verifyError = error instanceof UpgradeError ? error : new UpgradeError('VERIFY_FAILED', String(error));
}
const updateAvailable = (() => {
try {
ensureNewerVersion({ currentVersion, targetVersion: manifest.version });
return true;
} catch {
return false;
}
})();
return {
updateAvailable,
currentVersion,
targetVersion: manifest.version,
manifest,
verified,
...(verifyError ? { verifyError: { code: verifyError.code, message: verifyError.message } } : {}),
};
}
/** 写本地审计 JSONL(append-only);配置 auditApiUrl 即默认尝试上报 /api/gov/audit/events。 */
async writeAudit({ event, category = 'UPGRADE', result = 'SUCCESS', payload = {} }) {
const entry = {
ts: new Date().toISOString(),
event,
category,
result,
payload,
};
const logsDir = path.join(this.apsHome, 'logs');
await fsp.mkdir(logsDir, { recursive: true });
const auditFile = path.join(logsDir, 'upgrade-audit.jsonl');
await fsp.appendFile(auditFile, `${JSON.stringify(entry)}\n`, 'utf8');
// round-45 HH:配置 auditApiUrl 即默认尝试上报(带 nonce+token);端点不存在/403/网络
// 错误一律 fail-soft——本地 JSONL 是主通道,上报失败不抛错、不阻塞升级(保持 round-43 语义)。
if (this.auditApiUrl) {
await this.reportAuditToApi(entry).catch((error) => {
this.logger.warn?.('[updater] 审计上报失败(仅本地记录):', error.message);
});
}
return entry;
}
/** 上报 /api/gov/audit/events(round-45 HH:端点不存在/403 → 抛错,由 writeAudit fail-soft 兜底)。
* payload 转换:本地 entry {ts,event,category,result,payload} → 服务端审计链契约
* {category,action,power,actor,rationale,target,timestamp,result,idempotencyKey};
* idempotencyKey = sha256(event+category+result+ts+payload) 的确定性指纹——同一 entry
* 重试(如响应丢失)会被服务端幂等去重,不重复落链。 */
async reportAuditToApi(entry) {
const url = new URL('/api/gov/audit/events', this.auditApiUrl);
const headers = {
'content-type': 'application/json',
};
if (this.auditToken) headers.authorization = `Bearer ${this.auditToken}`;
if (this.sidecarNonce) headers['x-aps-sidecar-nonce'] = this.sidecarNonce;
const idempotencyKey = crypto
.createHash('sha256')
.update(JSON.stringify([entry.event, entry.category, entry.result, entry.ts, entry.payload ?? {}]))
.digest('hex');
const body = {
category: entry.category || 'UPGRADE',
action: entry.event,
power: 'P0',
actor: 'desktop-updater',
target: { type: 'UPGRADE' },
rationale: { source: 'desktop-updater', payload: entry.payload ?? {} },
timestamp: entry.ts,
result: entry.result || 'SUCCESS',
idempotencyKey,
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(3_000),
});
if (!response.ok) {
throw new UpgradeError('AUDIT_API_HTTP', `审计上报 HTTP ${response.status}`, { status: response.status });
}
}
/**
* 完整升级流程(含失败自动回滚)。
* @param {object} options
* @param {string} options.manifestPath 升级清单路径
* @param {string} options.packagePath 安装包路径
* @param {string} options.installDir 安装目录
* @param {string} options.backupDir 备份目录
* @param {string} options.currentVersion 当前版本
* @param {string} [options.healthBase] 健康检查 base URL(默认 http://127.0.0.1:8000)
* @param {string} [options.expectedVersion] 健康检查期望版本(可选)
* @param {string} [options.sidecarNonce] 安装后健康检查用的 nonce(round-45 HH:升级后新
* sidecar 进程启用 nonce 门禁,探测必须带新网关的 nonce;缺省回落到构造时的
* this.sidecarNonce。新 nonce 由调用方(Electron main)在 sidecar 重启后经 SidecarManager
* 重新生成并传入,或在升级完成后由 main 更新 nonce 后再做最终健康检查)
* @param {'copy'|'listing'} [options.backupMode]
* @param {Function} [options.install] 安装函数(默认 nsisInstall);测试注入
* @param {boolean} [options.skipHealthCheck] 测试/无 sidecar 场景可跳过
* @returns {Promise<{ok:boolean,manifest:object,backup:object,health?:object}>}
*/
async applyUpdate(options) {
const {
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion,
healthBase = `http://${LOOPBACK_HOST}:8000`,
expectedVersion = null,
backupMode = 'copy',
install = nsisInstall,
skipHealthCheck = false,
sidecarNonce = this.sidecarNonce,
} = options;
const manifest = loadManifest(manifestPath);
const report = {
ok: false,
manifest,
currentVersion,
targetVersion: manifest.version,
nonce: sidecarNonce ?? null, // round-45 HH:本次流程使用的 sidecar nonce(供调用方续接)
};
try {
// ① 校验
this.logger.info?.('[updater] 校验升级包…');
await verifyPackage({ manifest, packagePath });
ensureNewerVersion({ currentVersion, targetVersion: manifest.version });
await this.writeAudit({
event: 'upgrade.verified',
payload: { fromVersion: currentVersion, toVersion: manifest.version, fileName: manifest.package.fileName },
});
// ② 备份
this.logger.info?.('[updater] 备份当前安装目录…');
const backup = await backupInstall({ installDir, backupDir, mode: backupMode });
report.backup = backup;
await this.writeAudit({ event: 'upgrade.backup', payload: backup });
// ③ 安装
this.logger.info?.('[updater] 执行安装…');
await install({ installerPath: packagePath, installDir, manifest });
await this.writeAudit({ event: 'upgrade.installed', payload: { toVersion: manifest.version } });
// ④ 健康检查
let health = null;
if (!skipHealthCheck) {
this.logger.info?.('[updater] 安装后健康检查…');
health = await probeHealth({ base: healthBase, expectedVersion, sidecarNonce });
report.health = health;
await this.writeAudit({ event: 'upgrade.health-check', payload: health });
}
report.ok = true;
await this.writeAudit({ event: 'upgrade.succeeded', payload: { fromVersion: currentVersion, toVersion: manifest.version } });
return report;
} catch (error) {
// ⑤ 失败自动回滚(round-46:升级途中 sidecar 重启导致的 nonce 轮换除外——
// 安装已成功,健康检查 403 仅因 nonce 过期 → 返回 pending 语义,由调用方
// (Electron main 的 upgrade-nonce 协调器)以新 nonce 补验,不误触发回滚)
const reason = error instanceof UpgradeError ? error.code : 'UNKNOWN';
const isNonceMismatch = error instanceof UpgradeError
&& error.details && error.details.nonceMismatch === true;
if (isNonceMismatch) {
await this.writeAudit({
event: 'upgrade.health-pending', result: 'PENDING',
payload: { reason: 'NONCE_MISMATCH', message: error.message },
}).catch(() => {});
this.logger.warn?.('[updater] 健康检查 nonce 已轮换(sidecar 重启),跳过回滚,交由调用方以新 nonce 补验');
report.ok = false;
report.deferredVerification = true;
report.reason = 'NONCE_MISMATCH';
return report;
}
await this.writeAudit({ event: 'upgrade.failed', result: 'FAILED', payload: { reason, message: error.message } }).catch(() => {});
this.logger.error?.('[updater] 升级失败,自动回滚:', error.message);
const rollback = await this.rollback({
backupDir,
installDir,
reason,
backupMode,
}).catch((rollbackError) => {
this.logger.error?.('[updater] 回滚失败:', rollbackError.message);
return { rollbackFailed: true, error: rollbackError.message };
});
report.rollback = rollback;
throw error;
}
}
/**
* 手动/自动回滚:恢复备份 + 审计。
*/
async rollback({ backupDir, installDir, reason = 'manual', backupMode = 'copy' }) {
const restored = await restoreBackup({ backupDir, installDir, expectedMode: backupMode });
await this.writeAudit({ event: 'rollback.done', result: 'SUCCESS', payload: { reason, ...restored } });
return restored;
}
/** 供测试/外部直接调用备份 */
backupInstall(options) {
return backupInstall(options);
}
}
module.exports = {
UpgradeError,
Updater,
compareVersions,
sha256File,
loadManifest,
verifyPackage,
ensureNewerVersion,
backupInstall,
restoreBackup,
probeHealth,
nsisInstall,
LOOPBACK_HOST,
};