From 92a0f56ddea353ae76af1857bbc31b5660c9d9bc Mon Sep 17 00:00:00 2001 From: fakehank Date: Fri, 26 Jun 2026 22:10:11 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.23=20=E2=80=94=20full-length=20?= =?UTF-8?q?session=5Fhistory=20docs=20+=20HTTP=20MCP=20works=20with=20any?= =?UTF-8?q?=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - session_history *.md docs are no longer truncated to ~3KB. slimCC treated whole human/agent-written docs as tool output (2KB head + 1KB tail), gutting anything longer (e.g. agent-task-*.md specs). slimCC now keeps session_history_markdown/meta records in full. Pipeline generation 3 -> 4 re-collects affected *.md from upload_folders on next sync. - Secret redaction spares obvious placeholders/identifiers (YOUR_API_KEY_HERE, , changeme-*, xxxx, example-*) via a placeholder allowlist shared by ASSIGN_JSONL and GENERIC_RULE. Real high-entropy secrets still masked. - HTTP-transport MCP (POST /mcp) works with bare HTTP clients. The Streamable-HTTP SDK rejected anything not sending Accept: application/json, text/event-stream + Content-Type: application/json (plain curl / hand-written bots got 406/415), and GET /mcp hung the stateless connection. /mcp now pins those headers via rawHeaders (which Hono's request-builder actually reads) and returns 405 for non-POST. README/DEPLOY gained copy-paste Claude Code / mcp.json / curl config. Co-Authored-By: Claude Opus 4.8 (1M context) --- DEPLOY.md | 2 ++ README.md | 23 +++++++++++++++++++++- client/sync.mjs | 16 ++++++++++------ core/redact.mjs | 10 ++++++++-- core/slim.mjs | 3 +++ core/version.mjs | 4 +++- docs/CHANGELOG.en.md | 7 +++++++ docs/CHANGELOG.zh-CN.md | 7 +++++++ package.json | 2 +- server/mcphttp.mjs | 21 +++++++++++++++++++++ server/server.mjs | 10 +++++----- test/mcphttp.test.mjs | 42 ++++++++++++++++++++++++++++++++++------- test/slim.test.mjs | 25 +++++++++++++++++++++++- 13 files changed, 148 insertions(+), 24 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 40e8b6b..df1700c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -90,6 +90,8 @@ brain.yourdomain.com { Point the domain's A record at this VPS; Caddy issues and renews certs automatically. +> The reverse proxy forwards everything on the host, so `POST https://brain.yourdomain.com/mcp` (the HTTP-transport MCP endpoint for remote/cloud agents — see [README](./README.md)) is reachable with no extra config — it shares the same member tokens as every other endpoint. Server-side only (no CORS): point agents at it, not browsers. + ### 1.6 Run as a service (systemd) `/etc/systemd/system/team-brain.service`: diff --git a/README.md b/README.md index 1768de8..4d911c9 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,28 @@ Server-side queries run via `git grep` / `git ls-files` / `git log` / `fs` — * > **Wiring other editors:** the MCP server is a stdio server; the command is always ` /mcp/server.mjs` (`brain mcp` prints your exact path). Add that as a stdio MCP server in Claude Code, Codex, or any MCP-capable client (Gemini CLI / Cursor / Cline / opencode…). > -> **Remote / cloud agents (HTTP transport):** an agent that can't run the local stdio binary can mount the memory over HTTP instead — point it at `https://your-server/mcp` with your member token as a `Bearer` header. Same tools, no local install. +> **Remote / cloud agents (HTTP transport):** an agent that can't run the local stdio binary can mount the memory over HTTP instead — same tools, no local install. Point any MCP client at `POST https://your-server/mcp` with your member token as a `Bearer` header. +> +> Claude Code: +> ```bash +> claude mcp add --transport http team-brain https://your-server/mcp \ +> --header "Authorization: Bearer " +> ``` +> Cursor / Cline / any `mcp.json`: +> ```json +> { "mcpServers": { "team-brain": { +> "type": "http", +> "url": "https://your-server/mcp", +> "headers": { "Authorization": "Bearer " } +> } } } +> ``` +> Smoke-test from a shell (the endpoint accepts plain JSON-RPC — no special `Accept` header needed): +> ```bash +> curl -s https://your-server/mcp -H "Authorization: Bearer " \ +> -H "Content-Type: application/json" \ +> -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +> ``` +> It's a stateless, one-shot endpoint (POST in, JSON out — no session, no server-side LLM); multi-turn context stays on the calling agent's side. ### ② Browse the web UI diff --git a/client/sync.mjs b/client/sync.mjs index f3e144c..62fa166 100644 --- a/client/sync.mjs +++ b/client/sync.mjs @@ -68,16 +68,20 @@ function loadState() { } // 流水线升级 → 一次性重收受影响的历史(与"范围变了清 seen"同一套路)。 -// 历来场景都在 Codex:代次 2 = slim 之前整丢 token_count;代次 3 = redact 误抹数值型 token 计数(统计为 0) -// + slim 改每北京日留末条 token_count(Codex token 按天精确)。都得用新 slim/redact 重新蒸馏才补得回。 -// 只清【Codex】的 seen(本机原文还在 ~/.codex)→ 重传量最小;CC 历史的 token 在服务端 rebuild-cards 补,不必重传。 +// 代次 2 = slim 之前整丢 token_count;代次 3 = redact 误抹数值型 token 计数(统计为 0)+ slim 每北京日留末条 +// token_count(Codex token 按天精确)→ 都只能从本机 ~/.codex 原文重新蒸馏捞回,故清【Codex】seen。 +// 代次 4 = slim 不再把 session_history *.md 截成头尾 3KB → 原文还在 upload_folders,清这些 .md 的 seen 重收整篇。 +// 只清受影响来源的 seen(本机原文还在)→ 重传量最小;CC 历史的 token 在服务端 rebuild-cards 补,不必重传。 function reconcilePipeline() { if (prevPipeline >= PIPELINE_VERSION) return; - let cleared = 0; + let codex = 0, history = 0; for (const file of [...seenSession.keys()]) { - if (file.startsWith(CODEX_ROOT)) { seenSession.delete(file); cleared++; } + if (file.startsWith(CODEX_ROOT)) { seenSession.delete(file); codex++; } + // session_history 文档:upload_folders 内 session_history/ 下的 *.md(CC/Codex/Trae 的 seen key 都是 .jsonl)。 + // 按「session_history/ 目录 + .md」认,比单看后缀稳——不会误清将来可能进 seen 的其它 .md。 + else if (file.endsWith(".md") && /[\\/]session_history[\\/]/.test(file)) { seenSession.delete(file); history++; } } - if (cleared) log.info("[pipeline-upgrade] generation bumped -> re-collect Codex history (recover token usage)", { from: prevPipeline, to: PIPELINE_VERSION, codex_resync: cleared }); + if (codex || history) log.info("[pipeline-upgrade] generation bumped -> re-collect affected history", { from: prevPipeline, to: PIPELINE_VERSION, codex_resync: codex, session_history_resync: history }); prevPipeline = PIPELINE_VERSION; } diff --git a/core/redact.mjs b/core/redact.mjs index 10a5a96..79d1834 100644 --- a/core/redact.mjs +++ b/core/redact.mjs @@ -16,8 +16,14 @@ export const PREFIX_RULES = [ [/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED_JWT]"], ]; +// 占位/示例值放行:文档正文里 YOUR_API_KEY_HERE、、changeme、example-xxx、xxxx… 这类明显是 +// 占位符/标识符、绝不是真密钥 → 不脱敏(纯精度收益,不抬漏抹风险)。整段值匹配、大小写无关。 +const PLACEHOLDER = /^(?:x{3,}|\*{3,}|\.{3,}|-{3,}|<[^>]*>|\{\{?[^}]*\}?\}|your[_-].*|my[_-].*|example[_.-].*|placeholder.*|change[_-]?me.*|redacted.*|todo.*|dummy.*|sample.*|test[_-].*|none|null|undefined|true|false|enabled|disabled)$/i; + // 「赋值型」兜底(贪婪,吃掉「键名+值」整段):只给 markdown/纯文本用(不保 JSON 结构)。 -const GENERIC_RULE = [/(?:Bearer|token|api[_-]?key|password|secret)["'\s:=]+[A-Za-z0-9._\-]{12,}/gi, "[REDACTED_SECRET]"]; +// 值单列一组,便于放行占位符;命中真密钥时整段(键名+值)一起换占位符。 +const GENERIC_RULE = [/((?:Bearer|token|api[_-]?key|password|secret)["'\s:=]+)([A-Za-z0-9._\-]{12,})/gi, + (m, _head, val) => (PLACEHOLDER.test(val) ? m : "[REDACTED_SECRET]")]; export const RULES = [...PREFIX_RULES, GENERIC_RULE]; export const redact = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s || ""); @@ -36,7 +42,7 @@ const URL_CRED = /([a-z][a-z0-9+.-]*:\/\/[^\s:@/"'`\\]+):([^\s:@/"'`\\]+)@/gi; // 目的:真相库的 .jsonl 原文本身就不含密钥——不再只靠派生 .md / 读出口(redactReadable)兜底;完整原文留产出者本机。 export const redactJsonl = (s) => PREFIX_RULES.reduce((a, [re, to]) => a.replace(re, to), String(s || "")) - .replace(ASSIGN_JSONL, (m, head, val) => (/^[\d.]+$/.test(val) ? m : head + "[REDACTED_SECRET]")) + .replace(ASSIGN_JSONL, (m, head, val) => (/^[\d.]+$/.test(val) || PLACEHOLDER.test(val) ? m : head + "[REDACTED_SECRET]")) .replace(URL_CRED, (_m, head) => head + ":[REDACTED_SECRET]@"); // 把本机家目录路径抹成 ~(agent 配置里满是机器级绝对路径,跨人/入库前必须中和)。 diff --git a/core/slim.mjs b/core/slim.mjs index 5140694..c2ee782 100644 --- a/core/slim.mjs +++ b/core/slim.mjs @@ -78,6 +78,9 @@ function capContent(c) { // tool_result.content:string } function slimCC(o) { if (CC_DROP.has(o.type)) return null; + // session_history 本地 .md:整篇人/agent 写的文档本身就是信号,不能当 tool 输出走兜底 capDeep + // 截成头尾 3KB(图片/base64 已在 feedLine 文本级剥过;超大文档由 sync 的 MAX_RAW/MAX_UPLOAD 闸门挡)。 + if (o.type === "session_history_markdown" || o.type === "session_history_meta") return o; const c = o.message && o.message.content; if (Array.isArray(c)) { for (const b of c) { diff --git a/core/version.mjs b/core/version.mjs index 420c621..0de223b 100644 --- a/core/version.mjs +++ b/core/version.mjs @@ -17,4 +17,6 @@ export const CLIENT_VERSION = v; // 3 → ① redact 不再误伤数值型 token 计数(input_tokens 等 ≥8 位曾被抹成 [REDACTED_SECRET]、还破坏 JSON → // 重度 Codex 用户 token 统计为 0);② slim 每北京日留末条 token_count、parse 作差 → Codex token 按天精确。 // 两者都只能从本机原文捞回 → 重收 Codex 历史。 -export const PIPELINE_VERSION = 3; +// 4 → slim 不再把 session_history *.md 文档当 tool 输出截成头尾 3KB(之前 >3KB 的文档中段全丢)。 +// 原文还在 upload_folders → 重收 session_history *.md 以补回整篇正文。 +export const PIPELINE_VERSION = 4; diff --git a/docs/CHANGELOG.en.md b/docs/CHANGELOG.en.md index 25b8a16..1555729 100644 --- a/docs/CHANGELOG.en.md +++ b/docs/CHANGELOG.en.md @@ -8,6 +8,13 @@ This project follows [Semantic Versioning](https://semver.org/), formatted after --- +## [0.1.23] - 2026-06-26 · Full-length session_history docs + HTTP MCP works with any client + +### Fixed +- **`session_history` `*.md` docs are no longer truncated to ~3KB**: the upload-time slimmer treated whole human/agent-written docs as tool output and capped them to a 2KB head + 1KB tail, gutting the middle of anything longer (e.g. `agent-task-*.md` specs). `slimCC` now keeps `session_history_markdown` / `session_history_meta` records in full (images/base64 are still stripped; oversized docs are still bounded by the existing `MAX_RAW` / `MAX_UPLOAD` size gates). A pipeline-generation bump (3 → 4) re-collects affected `*.md` from `upload_folders` so the lost body is restored on next sync. +- **Secret redaction spares obvious placeholders/identifiers**: doc text like `token: example-token-here`, `api_key: YOUR_API_KEY_HERE`, ``, `changeme-…`, `xxxx…` is no longer masked to `[REDACTED_SECRET]`. Both the JSON-safe `ASSIGN_JSONL` rule and the markdown `GENERIC_RULE` now skip a placeholder allowlist before redacting. Real, high-entropy secrets are still masked — this is a conservative tightening, so example values that genuinely look like real keys are still redacted. +- **HTTP-transport MCP (`POST /mcp`) works with bare HTTP clients**: the Streamable-HTTP SDK rejected anything not sending `Accept: application/json, text/event-stream` + `Content-Type: application/json` (so plain `curl` / hand-written bots got `406` / `415`), and a `GET /mcp` hung the connection on the stateless endpoint. `/mcp` now pins those headers on the inbound request (via `rawHeaders`, which the underlying Hono request-builder actually reads) and returns `405` for non-`POST`, so "URL + Bearer token" mounts the memory from any client. Docs: README now has copy-paste Claude Code / `mcp.json` / `curl` config; DEPLOY notes the endpoint rides the same HTTPS proxy + member tokens. + ## [0.1.22] - 2026-06-25 · Hardened read-only queries + observable status ### Added diff --git a/docs/CHANGELOG.zh-CN.md b/docs/CHANGELOG.zh-CN.md index 59ad4dd..6e7aebc 100644 --- a/docs/CHANGELOG.zh-CN.md +++ b/docs/CHANGELOG.zh-CN.md @@ -8,6 +8,13 @@ --- +## [0.1.23] - 2026-06-26 · session_history 文档不再被截断 + HTTP MCP 任意客户端都能连 + +### 修复 +- **`session_history` `*.md` 文档不再被截成 ~3KB**:上传前的蒸馏把整篇人/agent 写的文档当成 tool 输出,截成头 2KB + 尾 1KB,超过的中段全丢(如 `agent-task-*.md` 这类方案文档)。`slimCC` 现在对 `session_history_markdown` / `session_history_meta` 整篇保留(图片/base64 仍剥;超大文档仍由 `MAX_RAW` / `MAX_UPLOAD` 大小闸门挡)。流水线代次 3 → 4,下次同步会从 `upload_folders` 重收受影响的 `*.md`,补回丢掉的正文。 +- **脱敏放行明显的占位/标识符**:文档里 `token: example-token-here`、`api_key: YOUR_API_KEY_HERE`、``、`changeme-…`、`xxxx…` 这类不再被抹成 `[REDACTED_SECRET]`。保结构的 `ASSIGN_JSONL` 与 markdown 的 `GENERIC_RULE` 在脱敏前都先过一遍占位放行清单。真·高熵密钥照常抹——这是保守的一档收紧,所以长得像真密钥的示例值仍会被抹。 +- **HTTP 传输的 MCP(`POST /mcp`)裸客户端也能连**:Streamable-HTTP 的 SDK 强制要 `Accept: application/json, text/event-stream` + `Content-Type: application/json`,否则 `406` / `415`(裸 `curl` / 手写 bot 默认头会被挡);无状态端点上 `GET /mcp` 还会挂住连接。现在 `/mcp` 在入口把这些头钉到请求上(改 `rawHeaders`——底层 Hono 重建请求实际读它,改 `headers` 无效),非 `POST` 一律 `405`,「URL + Bearer token」从任意客户端都能挂载。文档:README 补了 Claude Code / `mcp.json` / `curl` 的可复制配置;DEPLOY 说明该端点走同一套 HTTPS 反代 + 成员 token。 + ## [0.1.22] - 2026-06-25 · 只读查询加固 + status 看得见采集足迹 ### 新增 diff --git a/package.json b/package.json index 02874ff..09a547c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "klear-team-brain", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "Self-hosted team memory for AI coding agents — fuses Claude Code/Codex sessions, GitHub code state, and team docs into one git truth store, queryable from your editor over MCP.", "license": "Apache-2.0", diff --git a/server/mcphttp.mjs b/server/mcphttp.mjs index 5a765b3..9da340b 100644 --- a/server/mcphttp.mjs +++ b/server/mcphttp.mjs @@ -9,6 +9,27 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { registerTools, INSTRUCTIONS, SERVER_INFO } from "../mcp/tools.mjs"; import { makeLocalExec } from "./mcpexec.mjs"; +// 覆写/追加一个请求头——同时改 req.headers 与 req.rawHeaders。必须改 rawHeaders: +// StreamableHTTP transport 经 Hono getRequestListener 从 rawHeaders(而非 headers)重建 Web Request。 +function setRaw(req, name, value) { + req.headers[name] = value; + const rh = req.rawHeaders; + for (let i = 0; i < rh.length; i += 2) if (rh[i].toLowerCase() === name) { rh[i + 1] = value; return; } + rh.push(name, value); +} + +// /mcp 入口预处理:让裸 curl / 手写 bot 也能开箱即用。 +// SDK 严格要求 Accept 同含 application/json + text/event-stream、Content-Type 为 json,否则 406/415; +// 默认 */*、漏填、或 fetch 自动塞的 text/plain 都会被挡。这里统一钉成规范头(服务器本就是无状态 JSON 响应, +// 且 body 由路由自己 JSON.parse 校验 → 强制 json 无副作用)。返回 false = 非 POST,调用方回 405(GET 的 SSE +// 长流在无状态下永不出数据、白占连接,对低内存机器纯负担)。 +export function normalizeMcpRequest(req) { + if (req.method !== "POST") return false; + setRaw(req, "accept", "application/json, text/event-stream"); + setRaw(req, "content-type", "application/json"); + return true; +} + export function makeMcpHttpHandler(ctx) { const exec = makeLocalExec(ctx); // body:POST 时由 server.mjs 预解析好的 JSON-RPC 体;GET/DELETE 传 undefined(无状态下传输自行处理)。 diff --git a/server/server.mjs b/server/server.mjs index 212e7b9..3f10b84 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -29,7 +29,7 @@ import { syncNotionDocs } from "./notiondocs.mjs"; import { syncGoogleDocs } from "./googledocs.mjs"; import { grepTruth, findTruth, lsTruth, logTruth, sessionsTruth, statsTruth, frontmatterOf, spaceStatsTruth } from "./query.mjs"; import { canonicalizePath, canonicalSpaceKey } from "../core/identity.mjs"; -import { makeMcpHttpHandler } from "./mcphttp.mjs"; +import { makeMcpHttpHandler, normalizeMcpRequest } from "./mcphttp.mjs"; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const TRUTH = process.env.TRUTH_DIR || join(ROOT, "truth-server"); @@ -315,11 +315,11 @@ async function handle(req, res, u) { // 鉴权复用 authMember(与所有 query 端点同一套成员 token)。无状态:POST 带 JSON-RPC 体,传输回 JSON。 if (u.pathname === "/mcp") { if (!authMember(req)) return json(res, 401, { error: "invalid token" }); + // 非 POST(含会挂住连接的 GET SSE)→ 405;POST 则把 Accept/Content-Type 钉成 SDK 要的规范头(见 normalizeMcpRequest)。 + if (!normalizeMcpRequest(req)) { res.setHeader("allow", "POST"); return json(res, 405, { error: "method not allowed; POST only" }); } let body; - if (req.method === "POST") { - try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "bad json" }); } - } - return mcpHandler(req, res, body); // transport 自行按 method(POST/GET/DELETE) 处理 + try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "bad json" }); } + return mcpHandler(req, res, body); } // --- 校验 token 身份(brain join 自检用)--- diff --git a/test/mcphttp.test.mjs b/test/mcphttp.test.mjs index f56597a..b92cf57 100644 --- a/test/mcphttp.test.mjs +++ b/test/mcphttp.test.mjs @@ -10,7 +10,7 @@ import { join, dirname } from "node:path"; import { execFileSync } from "node:child_process"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { makeMcpHttpHandler } from "../server/mcphttp.mjs"; +import { makeMcpHttpHandler, normalizeMcpRequest } from "../server/mcphttp.mjs"; // 临时 git 真相库:铺两条带 frontmatter 的 session 卡片(grep/read 要能命中正文)。 function gitTruth() { @@ -38,12 +38,11 @@ async function startServer(TRUTH) { resolvePath: (p) => p || "", resolveSpace: (k) => k, }); const server = http.createServer(async (req, res) => { - let body; - if (req.method === "POST") { - const chunks = []; - for await (const c of req) chunks.push(c); - try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { body = undefined; } - } + // 复刻 server.mjs /mcp 路由:非 POST → 405;POST 经 normalizeMcpRequest 钉规范头再交给 handler。 + if (!normalizeMcpRequest(req)) { res.setHeader("allow", "POST"); res.writeHead(405, { "content-type": "application/json" }); return res.end(JSON.stringify({ error: "method not allowed; POST only" })); } + const chunks = []; + for await (const c of req) chunks.push(c); + let body; try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { body = undefined; } handler(req, res, body); }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); @@ -87,3 +86,32 @@ test("HTTP MCP: sessions 按人查命中工作时间", () => withClient(async (c assert.match(text, /user2/); assert.match(text, /2026-06-20/); })); + +// 裸 HTTP 客户端(curl / 手写 bot):默认 Accept */*、漏 Content-Type 也要能用,不被 SDK 的严格头校验挡成 406/415。 +const INIT = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "curl", version: "0" } } }; +async function raw(method, headers, body) { + const { server, url } = await startServer(gitTruth()); + try { + const r = await fetch(url, { method, headers, body: body && JSON.stringify(body) }); + return { status: r.status, text: await r.text() }; + } finally { server.close(); } +} + +test("HTTP MCP: 裸 POST(Accept */*、Content-Type 缺/text-plain)也能 initialize → 200", async () => { + for (const headers of [ + { accept: "*/*", "content-type": "application/json" }, // curl 默认 Accept + { accept: "application/json" }, // 只列 json(缺 sse) + {}, // 啥都不带(fetch 会塞 text/plain) + ]) { + const r = await raw("POST", headers, INIT); + assert.equal(r.status, 200, `headers=${JSON.stringify(headers)} -> ${r.text.slice(0, 80)}`); + assert.match(r.text, /protocolVersion/); + } +}); + +test("HTTP MCP: 非 POST(GET/DELETE)→ 405,不挂连接", async () => { + for (const method of ["GET", "DELETE"]) { + const r = await raw(method, { accept: "application/json, text/event-stream" }); + assert.equal(r.status, 405, `${method} 应 405`); + } +}); diff --git a/test/slim.test.mjs b/test/slim.test.mjs index be74c2c..bec7c44 100644 --- a/test/slim.test.mjs +++ b/test/slim.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { slimRaw } from "../core/slim.mjs"; -import { redactJsonl } from "../core/redact.mjs"; +import { redactJsonl, redact } from "../core/redact.mjs"; import { parseSessionText } from "../core/parse.mjs"; const big = (n) => "x ".repeat(n); // 含空格 → 不是 base64 大块,走字段截断 @@ -153,3 +153,26 @@ test("redactJsonl: 字符串型密钥仍照常脱敏,且不破坏 JSON", () => assert.match(out, /REDACTED_SECRET/, "字母数字混合的密钥值仍要抹"); assert.doesNotThrow(() => JSON.parse(out)); }); + +test("slim: session_history *.md 文档整篇保留,不被当 tool 输出截成 3KB", () => { + const body = "# Agent Task\n\n" + "正常方案文档内容。".repeat(400); // 远超 TOOL_HEAD+TAIL(3KB) + const raw = [ + JSON.stringify({ type: "session_history_meta", timestamp: "2026-06-01T00:00:00Z", source_file: "docs/current/agent-task-x.md" }), + JSON.stringify({ type: "session_history_markdown", timestamp: "2026-06-01T00:00:00Z", content: body }), + ].join("\n") + "\n"; + const out = slimRaw(raw); + assert.doesNotMatch(out, /\[略/, "文档正文不该被截断"); + const md = out.split("\n").map((l) => { try { return JSON.parse(l); } catch { return null; } }) + .find((o) => o && o.type === "session_history_markdown"); + assert.equal(md.content, body, "正文逐字保留"); +}); + +test("redact: 占位/示例值放行,真密钥仍抹(GENERIC_RULE 文档正文路径)", () => { + // 占位符/标识符 → 不脱敏(文档里 design token / YOUR_KEY 这类不该被误伤) + for (const s of ["token: example-token-here", "api_key: YOUR_API_KEY_HERE", "token: ", "password: changeme-now"]) { + assert.doesNotMatch(redact(s), /REDACTED/, `占位值应放行: ${s}`); + } + // 真值 → 照常抹 + assert.match(redact("password: hunter2longsecret"), /REDACTED_SECRET/, "真密钥仍要抹"); + assert.match(redact("api_key: abcdEFGH1234secretval"), /REDACTED_SECRET/, "随机真值仍要抹"); // 复用 .gitleaks.toml 已放行的测试假密钥 +});