Superpowers Visual Companion 认证加固:从密钥 Bootstrap、同源 WebSocket 到 realpath 目录包含的完整实现
Superpowers 的 brainstorming 视觉伴侣(Visual Companion)是一个由 Node.js 内置模块实现的零依赖本地 HTTP + WebSocket 服务,用于在头脑风暴会话中向浏览器推送可交互屏幕并回传用户选择。本文基于仓库中的实施计划 2026-06-10-visual-companion-auth-hardening.md 展开,完整讲解这次认证加固的威胁模型、十个 TDD 任务(带密钥的根路径 Bootstrap、WebSocket 同源校验、sessionStorage 重连凭证、安全响应头、/files/* realpath 包含、重启重连回归、Shell lint 与 .gitignore 治理、全量自动化验证与安全探针复测),并结合 server.cjs 与 helper.js 的实际源码印证每项机制的落地方式,适合需要为本地 Agent UI 设计"密钥 + Cookie + 同源"三层认证体系的开发者参考。
加固目标、架构与技术栈
该计划开篇明确了三项约束,也是全文的判断标准:
- 目标(Goal):在不破坏"可信同源屏幕 JavaScript"和未来同源内嵌(vendored)UI 库的前提下,加固 brainstorming 视觉伴侣的认证与重连流程;
- 架构(Architecture):带密钥的根路径加载变为一个 Bootstrap 步骤——先设置 Cookie、把密钥存入标签页作用域的
sessionStorage,再导航到裸的/屏幕地址;WebSocket 要求"有效认证 + 浏览器同源Origin"双重通过,/files/*用 realpath 包含(realpath containment)阻止内容目录逃逸; - 技术栈(Tech Stack):Node.js 内置模块(
http、fs、path、crypto)、零运行时依赖、仅测试使用的ws依赖、Bash 启停脚本、仓库自带的 Shell lint 脚本。
计划还包含一条仓库级纪律:执行期间除非明确要求,不得提交——仓库自身的指令覆盖通用计划模板的提交节奏。
配套的设计文档 2026-06-10-visual-companion-auth-hardening-design.md 给出了完整威胁模型,值得先交代清楚,因为它决定了每项修复的边界:
- 受保护资产:伴侣提供的屏幕内容、会话密钥(session key)、Agent 将其作为用户反馈读取的
state/events文件、伴侣会话目录下的本地文件; - 范围内攻击者:另一个
localhost端口上的恶意浏览器标签页;能向伴侣发请求但不应以伴侣 UI 身份认证的浏览器页面;服务器绑定非回环地址时的直连远程客户端;经由 URL 历史、Referrer 或误提交的本地状态造成的意外泄露;指向内容目录之外符号链接或路径技巧; - 范围外(有意为之):恶意的 Agent 生成的屏幕 HTML、恶意的同源内嵌 JavaScript。设计文档明确指出,屏幕属于 Agent 的 UI 表面,今天可以使用内联脚本,未来可能使用 Alpine、Three.js 这类同源库;若要防御恶意屏幕 HTML 需要更重的沙箱 iframe 架构,不属于本次加固范围。
设计文档同时列出了 PR 分支上被自动化与有头浏览器测试发现的五个现有失败:跨源 localhost 页面可以借用 Cookie 打开 WebSocket 并向 state/events 写入攻击者选择;/files/* 会服务内容目录外的符号链接(包括指向 state/server-info、内含带密钥 URL 的链接);会话密钥残留在屏幕页面可见 URL 中;helper 用不带密钥的 ws://host 重连,同端口重启后浏览器不再向重启的服务器出示 Cookie,导致已打开的标签页卡在 tombstone("Companion paused"覆盖层)直到手动刷新;Shell lint 与生命周期测试需要清理以保证在 Codex 环境下稳定。
文件地图:一次加固改动了什么
计划的 File Map 列出了全部改动面,后续十个任务都落在这张地图内:
| 文件 | 改动内容 |
|---|---|
| skills/brainstorming/scripts/server.cjs | 增加 Bootstrap 响应、共享安全响应头、WebSocket Origin 校验、/files/* realpath 包含 |
| skills/brainstorming/scripts/helper.js | 读取存储的会话密钥并拼接到 WebSocket URL |
| tests/brainstorm-server/auth.test.js | Bootstrap、响应头、同源 WS、跨源 WS、Cookie/文件认证回归 |
| tests/brainstorm-server/helper.test.js | 基于模拟浏览器的 sessionStorage WS URL 覆盖 |
| tests/brainstorm-server/server.test.js | /files/* 符号链接包含回归 |
| tests/brainstorm-server/lifecycle.test.js | 让 start-server 超时参数测试强制后台模式;补充重启重连凭证覆盖 |
| skills/brainstorming/scripts/start-server.sh、stop-server.sh | 修复 Shell lint |
仓库根 .gitignore |
新增 .superpowers/ |
| skills/brainstorming/visual-companion.md(可选) | 若行为变化需要面向操作者说明,则补充 Bootstrap URL 剥离与可信同源屏幕 JS 的说明 |
任务 1:带密钥的根路径加载(Bootstrap)
这是整个加固的核心:GET /?key=<token> 不再是屏幕响应,而是一个 Bootstrap 响应。计划要求在 auth.test.js 中先加两个 RED 测试:带有效查询密钥的 GET / 应返回包含 sessionStorage 与 location.replace 的 Bootstrap 页,而不是屏幕 HTML;带有效 Cookie 的裸 GET / 则照常返回屏幕内容。
计划的 RED 测试断言骨架(现仓库中该文件保留了同名测试,且实际实现比计划多了一层防御,见下文"实现与计划的差异"一节):
await test('GET / with valid query returns bootstrap instead of screen content', async () => {
const res = await get('/', { key: TOKEN });
assert.strictEqual(res.status, 200);
assert(res.body.includes('sessionStorage'), 'bootstrap should store the session key in tab storage');
assert(res.body.includes('location.replace'), 'bootstrap should navigate to the bare root URL');
assert(!res.body.includes('Secret screen'), 'bootstrap must not serve screen HTML at the keyed URL');
});
await test('GET / with valid cookie serves the screen after bootstrap', async () => {
const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
assert.strictEqual(res.status, 200);
assert(res.body.includes('Secret screen'), 'cookie-authenticated bare root should serve the screen');
assert(!res.body.includes('sessionStorage'), 'bare screen response should not be the bootstrap page');
});
随后在 server.cjs 中实现最小 Bootstrap 响应。计划给出的实现(与实际合入的 bootstrapPage 几乎一致,实际版本给 sessionStorage.setItem 包了 try/catch,保证存储被浏览器拦截时仍然跳转):
function bootstrapPage(key) {
const jsonKey = JSON.stringify(String(key));
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
<body>
<script>
sessionStorage.setItem('brainstorm-session-key', ${jsonKey});
location.replace('/');
</script>
</body>
</html>`;
}
配合一个从 URL 提取查询密钥的小工具,并在 handleRequest 中于"通过认证、设置 Cookie 之后、返回屏幕 HTML 之前"拦截根路径上的有效查询密钥:
function queryKey(url) {
const q = url.indexOf('?');
if (q < 0) return null;
return new URLSearchParams(url.slice(q + 1)).get('key');
}
// handleRequest 内:
const pathname = pathnameOf(req.url);
const keyFromQuery = queryKey(req.url);
if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
res.end(bootstrapPage(keyFromQuery));
return;
}
实际实现见 server.cjs 的 handleRequest:密钥比较使用 timingSafeEqualStr(内部为 crypto.timingSafeEqual,长度不等直接返回 false,防止时序侧信道),随后走 Bootstrap 分支。计划还特意标注:若先实现任务 1、securityHeaders(任务 4)尚未引入,可临时用 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }),待任务 4 再替换——这体现了计划中任务间依赖的显式管理。
为什么要用 sessionStorage 而不是别的机制?设计文档的解释是:helper 需要一个能扛过同端口重启、且不单纯依赖浏览器 Cookie 行为的"重连凭证";由于屏幕 HTML 本身就是可信的同源 UI,把密钥存入标签页作用域存储在当前威胁模型下可以接受,而且它实质上好过把密钥留在地址栏、历史与 Referrer 面上。sessionStorage 的标签页作用域还意味着密钥不会跨标签页扩散。
任务 2:WebSocket Origin 强制校验
这一步堵住的是计划威胁模型里最危险的攻击:真实伴侣页面设置 Cookie 之后,另一个 localhost 端口上的恶意标签页可以用同样的 Cookie 打开 WebSocket,把 attacker-injected 之类的选择写进 state/events,等同于对在线 Agent 会话做提示注入。
计划在 auth.test.js 中把 wsConnect 扩展为支持 origin 选项(现仓库保留的实现见 auth.test.js#L59-L74),并新增两条测试:
await test('WS upgrade with valid cookie and same-origin Origin opens', async () => {
const { outcome, ws } = await wsConnect({
cookie: `${COOKIE_NAME}=${TOKEN}`,
origin: `http://localhost:${TEST_PORT}`
});
ws.close();
assert.strictEqual(outcome, 'opened');
});
await test('WS upgrade with valid cookie but cross-origin Origin is rejected', async () => {
const eventsFile = path.join(TEST_DIR, 'state', 'events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
const { outcome, ws } = await wsConnect({
cookie: `${COOKIE_NAME}=${TOKEN}`,
origin: 'http://localhost:9999'
});
if (outcome === 'opened') {
ws.send(JSON.stringify({ type: 'choice', choice: 'attacker-injected', text: 'local attacker probe' }));
await sleep(300);
}
ws.close();
assert.strictEqual(outcome, 'rejected', 'cross-origin browser WS must not open even with cookie');
assert(!fs.existsSync(eventsFile), 'cross-origin WS must not write state/events');
});
第二条测试值得细读:它先删掉 state/events,再假设连接真的被打开并尝试注入事件,最后断言"连接被拒绝 且 state/events 不存在"——即使攻击者绕过断言路径,也不会留下写入证据。这是"攻击载荷 + 无副作用"双重断言的写法。
服务端实现在 server.cjs:
function isAllowedWebSocketOrigin(req) {
const origin = req.headers.origin;
if (!origin) return true; // 非浏览器客户端仍然必须携带会话密钥
const host = req.headers.host;
if (!host) return false;
return origin === 'http://' + host;
}
规则是:
- 请求携带
Origin(浏览器必带)时,必须严格等于"http://" + Host。攻击者页面形如Origin: http://localhost:9999/Host: localhost:58088的组合必然被拒;合法页面Origin: http://localhost:58088/Host: localhost:58088在密钥或 Cookie 有效时通过; - 无
Origin的直接客户端(脚本、测试用的ws库)仍然允许,但必须持有会话密钥。设计文档特别强调密钥是统一防线:无论回环绑定、隧道还是远程绑定,Host/Origin 白名单都挡不住 DNS rebinding,而密钥可以。
handleUpgrade 中的校验即计划要求的那一行:
function handleUpgrade(req, socket) {
if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
// ... 校验 Sec-WebSocket-Key 并执行 RFC 6455 握手
}
注意这里直接 socket.destroy() 而不是回复 HTTP 错误码——对升级请求,直接销毁连接是最简且不留握手痕迹的做法。认证侧的 isAuthorized(server.cjs#L341-L353)则实现了"查询密钥优先、否则回退 Cookie"的逻辑,且显式错误密钥不会回退到 Cookie——auth.test.js#L177-L180 专门断言了"错误 key + 有效 Cookie" 仍返回 403,防止攻击者用一个假 key 探测出 Cookie 仍然有效。
任务 3:helper 用存储密钥重连
浏览器端的 helper.js 是注入到每个屏幕末尾的客户端脚本,负责 WebSocket 连接、断线指数退避重连、状态胶囊(Connecting / Connected / Reconnecting / Disconnected)与 tombstone 覆盖层。加固前它连接的是不带密钥的 ws://<host>,完全依赖浏览器 Cookie——这正是同端口重启后标签页卡死在 tombstone 的根因。
计划在 helper.test.js 中基于"模拟浏览器"先写 RED 测试。该测试文件构造了一个 makeEnv() 沙箱(见 helper.test.js#L87-L132):假的 window.sessionStorage、假的 WebSocket(记录 URL 到 sockets 数组)、假的定时器与可推进的时钟,然后用 new Function(...Object.keys(env), src)(...Object.values(env)) 在受控环境里执行 helper 源码,真正驱动重连状态机而不是 grep 源码。计划要求的两条 URL 测试:
test('uses sessionStorage key in the WebSocket URL when present', () => {
const e = makeEnv();
e.state.sessionKey = 'stored-key-abc';
e.boot();
assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777/?key=stored-key-abc');
});
test('uses cookie-only WebSocket URL when no sessionStorage key is present', () => {
const e = makeEnv();
e.state.sessionKey = null;
e.boot();
assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777');
});
第二条是兼容性回退:已经加载、只有 Cookie 而没有存储条目的旧页面仍然走原来的 ws://<host> 行为。生产实现即 helper.js#L25-L35:
function sessionKey() {
try {
return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
} catch (e) {}
return null;
}
function websocketUrl() {
const key = sessionKey();
return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
}
helper.js#L77-L95 的 connect() 用 new WebSocket(websocketUrl()) 建连;另有 reloadAfterRecovery() 在 tombstone 状态下恢复连接时,若存在存储密钥则 location.replace('/?key=' + ...) 走一遍 Bootstrap 以刷新 Cookie,否则退回 location.reload()——这条恢复路径也被 helper.test.js#L174-L194 的模拟浏览器测试覆盖(带密钥时断言发生的是 replace('/?key=stored-key-abc') 而非裸 reload)。
任务 4:共享安全响应头
计划要求给所有 HTML / 文件 / 403 / 404 响应统一加一组"降低泄露 + 防框架化"的保守响应头,并且刻意不加 script-src CSP——因为伴侣现在注入内联 helper 脚本,未来屏幕还可能加载同源内嵌库,过严的 CSP 会破坏可信屏幕这一前提。
RED 测试断言(现仓库以 assertSecurityHeaders 辅助函数统一复用,见 auth.test.js#L28-L34 与 L82-L86):
await test('HTML responses include leak-reduction and anti-framing headers', async () => {
const res = await get('/', { key: TOKEN });
assert.strictEqual(res.headers['referrer-policy'], 'no-referrer');
assert.strictEqual(res.headers['cache-control'], 'no-store');
assert.strictEqual(res.headers['x-frame-options'], 'DENY');
assert.strictEqual(res.headers['content-security-policy'], "frame-ancestors 'none'");
assert.strictEqual(res.headers['cross-origin-resource-policy'], 'same-origin');
});
403 响应同样必须带这套头。服务端实现是 server.cjs 的 securityHeaders:
function securityHeaders(headers = {}) {
return {
'Referrer-Policy': 'no-referrer',
'Cache-Control': 'no-store',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
'Cross-Origin-Resource-Policy': 'same-origin',
...headers
};
}
...headers 展开在末尾,允许调用方附加 Content-Type 而不覆盖安全头。各响应的接入点(handleRequest):403 为 res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })),Bootstrap 与屏幕 HTML 用 200 + 同名头,/files/* 用 securityHeaders({ 'Content-Type': contentType }),其余 404 用 securityHeaders()。各头的语义:no-referrer 确保从屏幕页面发出的任何外发请求不带 Referer(密钥不会再经 Referer 面外泄);no-store 阻止屏幕内容进缓存;X-Frame-Options: DENY 与 frame-ancestors 'none' 双保险防点击劫持;Cross-Origin-Resource-Policy: same-origin 防止本地其他源的页面把它当资源跨源抓取。
另外,auth.test.js#L208-L214 还断言了有效密钥加载设置的 Cookie 为 HttpOnly + SameSite=Strict,对应 server.cjs#L398-L399 的 Set-Cookie 逻辑;且 Cookie 名按实际绑定端口生成(brainstorm-key-<port>,见 onListen),避免 EADDRINUSE 回退换端口后与另一个会话在共享的 localhost Cookie 罐里撞名。
任务 5:/files/* 的 realpath 包含
背景:服务器在 state/server-info 里写入带密钥 URL 的启动信息(权限 0600),而 /files/* 曾直接 readFileSync 拼出来的路径。content/ 目录里若存在一个指向 state/server-info 的符号链接,攻击者即可借合法认证通道读出密钥。
RED 测试(现仓库 server.test.js#L261-L269 保留了同名回归):
await test('does not serve symlinks that escape content dir via /files/', async () => {
const target = path.join(STATE_DIR, 'server-info');
const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
try { fs.unlinkSync(link); } catch (e) {}
fs.symlinkSync(target, link);
const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
assert.strictEqual(res.status, 404, 'symlink to state/server-info must not be served');
assert(!res.body.includes('server-started'), 'response must not include server-info body');
});
包含判定逻辑(计划版本 + 实际实现 isRegularFileInsideContentDir):
function isRegularFileInsideContentDir(filePath) {
let stat, realContentDir, realFilePath;
try {
stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) return false;
if (!stat.isFile()) return false;
if (stat.nlink !== 1) return false; // 实际实现额外增加的硬链接检查
realContentDir = fs.realpathSync(CONTENT_DIR);
realFilePath = fs.realpathSync(filePath);
} catch (e) {
return false;
}
return realFilePath.startsWith(realContentDir + path.sep);
}
要点:lstat 先于 realpath——符号链接本身即被拒,不存在"解析后被放行"的窗口;realpath 前缀比较用 realContentDir + path.sep 结尾,避免 /content-evil 被 /content 前缀误匹配;任何 stat/realpath 异常一律返回 false(fail closed)。/files/* 的最终守卫(server.cjs#L420-L433)保持 path.basename 取文件名——嵌套路径依然不支持——并拒绝空名与点文件:
if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
res.writeHead(404, securityHeaders());
res.end('Not found');
return;
}
实现与计划的差异:合入的实现比计划多了一条 stat.nlink !== 1 检查,把硬链接也判为拒绝——对应的回归测试 does not serve hard links to files outside content dir via /files/ 在 server.test.js#L273-L283。同一函数还被 getNewestScreen 复用:根路径选择"最新屏幕"时同样跳过逃逸链接,server.test.js#L284-L300 验证了根屏幕渲染不会被符号链接带出 state/server-info。这是把一处包含函数变成两个端点共享的安全边界,属于计划 File Map 之外的合理增量。
任务 6:重启重连回归(同端口 + 同密钥)
这条回归验证端到端场景:服务器杀掉后同目录重启、复用原端口,用旧实例启动时的密钥连接新实例的 WebSocket 必须能打开——这正是浏览器标签页"存了密钥、服务重启了"时的真实路径。
测试逻辑(lifecycle.test.js#L281 起):
- 在
fs.mkdtempSync('/tmp/bs-reconnect-')临时目录中分别指定BRAINSTORM_PORT_FILE/BRAINSTORM_TOKEN_FILE,并设置BRAINSTORM_LIFECYCLE_CHECK_MS=100000拉长看门狗; - 以
BRAINSTORM_DIR=<dir>/s1启动实例 A,从 stdout 的server-startedJSON 中取出带?key=的 URL,解析出keyA,杀掉 A; - 以
BRAINSTORM_DIR=<dir>/s2启动实例 B,断言infoB.port === infoA.port(重启复用端口); - 用
ws://localhost:<port>/?key=<keyA>并显式带上同源Origin头发起 WebSocket 升级,断言打开成功; finally中关连接、杀进程、fs.rmSync清理临时目录。
计划特意注明:该测试在任务 2、3 落地后可能一开始就通过,若属此种情况保留它作为覆盖但不称其为 RED——真实浏览器重连行为主要由任务 3 与最终人工/无头浏览器验证兜底。这个措辞体现了对"测试先行"与"集成覆盖"的区分。
端口与密钥的持久化机制值得展开,它解释了"重启为什么能认出旧密钥":server.cjs#L85-L151 中,preferredPort() 依次尝试 BRAINSTORM_PORT 环境变量、PORT_FILE 记录的上次端口(校验整数且落在 1023~65535)、随机高端口(49152~65534);initialToken() 依次尝试 BRAINSTORM_TOKEN 环境变量、TOKEN_FILE 中的既有 token(须匹配 /^[0-9a-f]{32,}$/i,读取后顺手 chmod 0600)、否则 crypto.randomBytes(32) 生成。onListen 只在"拿到了首选端口"时把端口与 token 写回文件——若因端口占用回退到随机端口,就不写回,避免覆盖共享文件、把另一个会话的已开标签页弄丢。密钥文件与 server-info 都以 owner-only 权限落盘。
任务 7:生命周期挂起修复与 Shell lint
两个工程性修复,保证 npm test 在 CI(Codex 环境)下不挂起、lint 干净:
-
Shell lint:计划先复现失败(运行 scripts/lint-shell.sh 检查三个脚本),预期输出:
SC2164: skills/brainstorming/scripts/start-server.sh line 128: cd "$SCRIPT_DIR" SC2034: skills/brainstorming/scripts/start-server.sh line 166: for i in {1..50} SC2034: skills/brainstorming/scripts/stop-server.sh line 57: for i in {1..20}最小修复:
cd "$SCRIPT_DIR"改为cd "$SCRIPT_DIR" || exit 1(SC2164:cd失败要显式处理);两处未被读取的循环变量for i in {1..50}/for i in {1..20}改为for _ in ...(SC2034:未用变量)。 -
生命周期挂起:lifecycle.test.js 中
start-server.sh --idle-timeout-minutes的测试原本不传--background,而 Codex 的CODEX_CI会让 start-server.sh 进入前台模式导致 execFileSync 一直等待。修复是让测试命令强制后台模式:const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background'], { encoding: 'utf8' });验证命令为 lint 退出 0 且
node lifecycle.test.js无挂起地退出 0。
任务 8:用 .gitignore 收纳持久化的伴侣状态
使用 --project-dir 时,伴侣会把状态写到项目下的 .superpowers/ 目录(含 .last-token 等持久化文件)。计划用 git check-ignore 做了 RED→GREEN 的最小闭环:
# RED:确认当前无匹配规则(|| true 吞掉非零退出码)
git check-ignore .superpowers/brainstorm/.last-token || true
# 修复:在 .gitignore 追加
.superpowers/
# GREEN:应回显被忽略的路径
git check-ignore .superpowers/brainstorm/.last-token
预期 GREEN 输出即 .superpowers/brainstorm/.last-token。这一步防止含会话密钥的 .last-token 在真实项目目录里被顺手 git add 提交——密钥文件的安全(0600 + 忽略规则)在仓库内外各有一道。
任务 9:全量自动化验证
无任何代码改动,四层递进:
- 聚焦套件:
cd tests/brainstorm-server后依次node auth.test.js、node helper.test.js、node server.test.js、node lifecycle.test.js,四条命令均须退出 0; - 完整套件:
npm test(tests/brainstorm-server/package.json 定义了测试入口),要求 ws-protocol、helper、auth、server、lifecycle、stop-server 全部通过; - 重复三次以暴露生命周期/文件监听类的偶发抖动:
for i in 1 2 3; do npm test || exit 1; done,三次通过且不挂起; - Shell lint:
scripts/lint-shell.sh对 start-server.sh、stop-server.sh 及tests/brainstorm-server/stop-server.test.sh退出 0。
任务 10:安全探针复测与人工流程
自动化全绿后,复现此前发现问题的攻击探针。计划给出两条路径:有暂存探针则直接 node /tmp/.../probe-pr1720.cjs;没有则在 /tmp 重建最小探针——用固定 token 启动伴侣、无头 Chrome 加载带密钥 URL、在另一个 localhost 端口起攻击者页面、尝试 new WebSocket('ws://localhost:<companion-port>/') 并发送 {"type":"choice","choice":"attacker-injected"}、检查 state/events。修复后的预期矩阵:
| 探针 | 预期 |
|---|---|
| 无密钥 / 错误密钥的 HTTP | 仍 403 |
| 同源 helper 页面 | 达到 Connected |
| 跨源 WebSocket | 打不开 |
state/events |
不含 attacker-injected |
指向 server-info 的符号链接 |
404 |
| 带密钥的浏览器加载 | 最终停在裸 / |
最后跑一遍人工/浏览器流程:用 --project-dir --open 启动伴侣 → 推送一张屏幕 → 确认 URL 被剥离为 / → 状态胶囊到 Connected → 点击选项并核对 state/events → 停止并以同项目重启 → 确认已开标签页自动重连。预期全部步骤无需手动刷新 URL 通过。
自审清单:计划的收尾校验
计划末尾的 Self-Review Checklist 是这份实施文档的方法论浓缩,值得在采用同类流程时借鉴:
- 规格覆盖:每条设计要求至少映射到一个任务(本计划的 10 个任务与 设计文档 的 7 个设计点一一对应,测试策略中的 12 条"必须回归"分散落在任务 1~7);
- 占位符扫描:全文无未解决的占位标记或含糊的边缘情况步骤;
- TDD 顺序:每个生产改动任务都从"聚焦失败测试或能展示当前失败的命令"开始(任务 6 是唯一例外,且计划显式说明了它可能是"出生即 GREEN"的集成覆盖);
- 信任模型:保留可信同源屏幕 JavaScript 与未来同源内嵌库的能力(不加
script-srcCSP、不加 iframe 沙箱——后者被明确列入 Deferred Work); - 不提交规则:执行过程不 commit。
小结:这套加固的可复用要点
从 server.cjs 的最终形态看,这次加固沉淀出一组本地 Agent UI 可直接借鉴的模式:
- 密钥是统一身份:
?key=查询、Cookie、sessionStorage三处承载同一个 32 字节随机 token,比较一律timingSafeEqual,任何一处失效都能被其余两处补偿(密钥扛重启、Cookie 扛子资源、存储扛浏览器 Cookie 行为退化); - Bootstrap 剥离 URL:带密钥 URL 只作为一次性入口,
location.replace('/')让可见地址、历史与 Referrer 面不再含密钥; - 双重门禁而非单一白名单:WebSocket = 认证 且 同源
Origin;文件服务 = 认证 且 realpath 包含 +lstat/nlink检查,认证绕过和路径绕过被解耦成两个独立防线; - 保守头、严边界:只加不破坏内联脚本前提的防泄露/防框架化头,把"恶意屏幕 HTML"这类更大问题显式推到独立的沙箱架构议题;
- 测试即证据:每条安全性质(跨源注入、符号链接逃逸、重启重连、URL 剥离)都有对应自动化回归,且攻击者测试带"无副作用"断言。
读者若要在自己的仓库中审计类似机制,建议从 tests/brainstorm-server 目录读起:auth.test.js 是门禁矩阵、helper.test.js 是模拟浏览器驱动的重连状态机、server.test.js 是文件包含回归、lifecycle.test.js 是跨重启的集成证据,四个文件合起来就是一次完整的安全验收。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00