Files
jkooe 1249c90725 feat(agent): selfupdate 代理出网 + LKG 内容指针 + L2/AgentUpdate 契约补全
P0-1 被控端自更新境内必然失败(裸 fetch 不读代理):
- 新增 apps/agent/src/net/proxy.ts + index.ts:sidecar 已验证出网方案的
  TS 移植(NA_PROXY → 环境变量 → 系统代理;PAC/socks 明确报错不静默降级;
  重定向链共享超时预算;CONNECT 自管 abort;TLS 默认校验证书)。
  只依赖 node 内置模块,不 import 项目内任何模块(依赖严格单向)
- selfupdate.download 改走代理;成功/失败均带回 via/proxy/proxy_source/
  outbound_notes 诊断;错误分类同款移植 sidecar main.mjs:146-165
  (ECONNRESET 不得报成「请求超时」——真机教训)
- httpGet 增 maxBytes 双闸(content-length 预检 + 流式中断),
  保住「64MB 上限」安全属性
- 回环目标恒直连(CI/dev 机 HTTP_PROXY 不得误送自建镜像进代理——
  e2e 实踩:代理另一侧的 localhost 不是本机 → 404)

A-P0-3 LKG 只有指纹没有内容指针:
- LkgRecord 只增 hash_full(64 位全量)+ backup_glob(.bak*)两字段;
  新增 lkgHashMatches(全量优先、老记录回落 12 位前缀,不猜)
- selfupdate 备份保留最近 3 个(planBackupCleanup 纯函数,最旧先删)

P1-4 system.audit.l2 returns_schema 空壳 → 四 action 属性并集补全;
AgentUpdate returns 增 via/proxy/proxy_source/outbound_notes 四个只增字段

安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 /
TLS 默认校验证书 / 只接受 http(s)

测试:新增 agent-proxy 17 例 + selfupdate-backup-cleanup 8 例 +
lkg 新增 4 例;单测 403/403、e2e 33/33、typecheck 全绿
2026-10-25 23:58:30 +08:00

130 lines
5.7 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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
/**
* LKG(最后已知可用版本)守卫 —— 交付流水线 §6。
*
* 为什么需要守卫:LKG 是回滚的**目标**。若它记错了(记成尚未验证的版本),
* 回滚就会回到一个坏版本 —— 比没有 LKG 更糟,因为它给人虚假的安全感。
* 故这里钉死三条:
* 1. 记录内容必须与当前入口脚本真实一致;
* 2. 文件不存在/损坏时**返回 null,不做任何猜测**;
* 3. matches 的判定必须用同一套 hash 口径(与 system.info 的 build.hash 同)。
*/
// 通过 NODEAGENT_HOME 指向临时目录,避免污染真实数据目录
const dir = mkdtempSync(join(tmpdir(), 'na-lkg-'));
process.env['NODEAGENT_HOME'] = dir;
const { recordLKG, readLKG, lkgStatus, lkgFilePath, lkgHashMatches } = await import('../../apps/agent/dist/lkg.js');
test('LKG 落在 NODEAGENT_HOME 指定的数据目录', () => {
assert.ok(lkgFilePath().startsWith(dir), `LKG 路径应在临时目录内,实际: ${lkgFilePath()}`);
});
test('未记录时 readLKG 返回 null(不猜测)', () => {
assert.equal(readLKG(), null);
});
test('记录后可读回,且 hash 与当前入口脚本真实一致', () => {
const rec = recordLKG();
assert.ok(rec, 'recordLKG 应返回记录');
const back = readLKG();
assert.ok(back, '应能读回');
assert.equal(back.hash, rec.hash);
assert.equal(back.bytes, rec.bytes);
// 独立重算:与直接对 argv[1] 求 sha256 前 12 位比对
const p = process.argv[1];
const expect = createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 12);
assert.equal(back.hash, expect, 'LKG hash 必须等于入口脚本真实 sha256 前 12 位');
});
test('lkgStatus.matches 在记录后为 true,byte_delta 为 0', () => {
recordLKG();
const s = lkgStatus();
assert.equal(s.matches, true);
assert.equal(s.byte_delta, 0);
assert.equal(s.current_hash, s.lkg.hash);
});
test('LKG 文件损坏时 readLKG 返回 null 而非抛错', () => {
recordLKG();
writeFileSync(lkgFilePath(), '{ this is not json', 'utf8');
assert.equal(readLKG(), null, '损坏的 LKG 必须返回 null,不得抛错或猜值');
});
test('LKG 记录缺少 hash 字段时视为无效', () => {
writeFileSync(lkgFilePath(), JSON.stringify({ bytes: 1 }), 'utf8');
assert.equal(readLKG(), null);
});
test('故障注入:篡改 LKG 的 hash 会被 matches=false 检出', () => {
recordLKG();
const good = readLKG();
// 模拟「更新后还没起来」:LKG 仍是旧值,而当前脚本已被替换成别的 hash
writeFileSync(
lkgFilePath(),
JSON.stringify({ ...good, hash: 'deadbeefcafe' }, null, 2),
'utf8',
);
const s = lkgStatus();
assert.equal(s.matches, false, 'hash 不一致必须检出 matches=false(这是「暂缓放量」的判据)');
assert.ok(s.lkg, 'LKG 记录本身仍应可读,供控制端定位回滚目标');
});
test('LKG 记录含回滚所需的全部字段', () => {
recordLKG();
const r = readLKG();
for (const k of ['hash', 'bytes', 'script_path', 'recorded_at', 'node_version', 'platform']) {
assert.ok(k in r, `LKG 缺少回滚所需字段: ${k}`);
}
assert.ok(existsSync(lkgFilePath()), 'LKG 文件应真实落盘');
});
// ---------------------------------------------------------------------------
// v2.1.0 新增字段(只增):hash_full + backup_glob —— 回滚内容定位用
// ---------------------------------------------------------------------------
test('v2.1.0:recordLKG 写 hash_full(64 位)与 backup_glob', () => {
const rec = recordLKG();
assert.ok(rec, 'recordLKG 应返回记录');
assert.match(rec.hash_full, /^[0-9a-f]{64}$/, 'hash_full 必须是完整 sha256(64 位十六进制)');
assert.equal(rec.hash, rec.hash_full.slice(0, 12), '12 位 hash 必须是 hash_full 的前缀(同口径)');
// 独立重算:与直接对 argv[1] 求 sha256 全量比对
const p = process.argv[1];
const full = createHash('sha256').update(readFileSync(p)).digest('hex');
assert.equal(rec.hash_full, full, 'hash_full 必须等于入口脚本真实 sha256');
assert.match(rec.backup_glob, /\.bak\*$/, 'backup_glob 应为 <basename>.bak*(覆盖 deploy 固定名与 selfupdate 时间戳名)');
const back = readLKG();
assert.equal(back.hash_full, full, '读回的记录必须保留 hash_full');
assert.equal(back.backup_glob, rec.backup_glob);
});
test('v2.1.0:lkgHashMatches 有 hash_full 按全量比', () => {
const rec = recordLKG();
const full = rec.hash_full;
assert.equal(lkgHashMatches(rec, full), true);
const flipped = `${'0'.repeat(63)}${full.slice(-1) === '0' ? '1' : '0'}`;
assert.equal(lkgHashMatches(rec, flipped), false, '全量不一致必须不中');
});
test('v2.1.0:老记录(无 hash_full)回落 12 位前缀比对,不猜测', () => {
// 模拟 v2.1.0 之前写的 LKG 文件:只有 12 位 hash
const p = process.argv[1];
const full = createHash('sha256').update(readFileSync(p)).digest('hex');
writeFileSync(lkgFilePath(), JSON.stringify({ hash: full.slice(0, 12), bytes: 1, script_path: p }), 'utf8');
const old = readLKG();
assert.ok(old, '老记录必须仍可读');
assert.equal(old.hash_full, undefined, '老记录没有 hash_full');
assert.equal(lkgHashMatches(old, full), true, '回落:12 位前缀命中');
assert.equal(lkgHashMatches(old, 'f'.repeat(64)), false, '前缀不中必须不中');
assert.equal(lkgHashMatches(null, full), false, '无记录不猜');
assert.equal(lkgHashMatches(old, undefined), false, '无候选值不猜');
});