From dd8d1123ca3327d0172c1b91acba35fdf10b5e4d Mon Sep 17 00:00:00 2001 From: fakehank Date: Thu, 25 Jun 2026 18:36:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(cli):=20brain=20status=E3=80=8C?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E5=90=8C=E6=AD=A5=E3=80=8D=E6=94=B9=E8=AF=BB?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E4=B8=80=E6=9D=A1=20tick=EF=BC=88logfmt?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8C=B9=E9=85=8D=E8=80=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E7=9A=84=E3=80=8C=C2=B7=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现版 tick 是 logfmt(无「·」),旧过滤匹配不到会显示过期时间戳。改成取最近一条 tick 行、只显示时间 + 计数体。 Co-Authored-By: Claude Opus 4.8 (1M context) --- cli/brain.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/brain.mjs b/cli/brain.mjs index ebcde27..e12fea6 100644 --- a/cli/brain.mjs +++ b/cli/brain.mjs @@ -467,8 +467,14 @@ function status() { console.log(`常驻: ${running ? c.ok(a) : c.warn(a || "未装")}`); } if (existsSync(LOG)) { - const last = readFileSync(LOG, "utf8").trimEnd().split("\n").filter((l) => l.includes("·")).slice(-1)[0]; - if (last) console.log(`最近同步: ${c.dim(last)}`); + // 取最近一条 tick(logfmt:` INFO tick cc_up=… …`),只显示时间 + 计数体,别再匹配老格式的「·」 + const lines = readFileSync(LOG, "utf8").trimEnd().split("\n"); + const last = [...lines].reverse().find((l) => / tick /.test(l)); + if (last) { + const ts = (last.match(/^(\S+)/) || [])[1] || ""; + const body = last.split(" tick ")[1] || last; + console.log(`最近同步: ${c.dim((ts.slice(11, 19) + " " + body).trim())}`); + } } try { const vi = JSON.parse(readFileSync(join(ROOT, ".brain-viewer.json"), "utf8")); if (vi.url) console.log(`查看器: ${c.ok(vi.url)} ${c.dim("(brain viewer 打开)")}`); } catch {} if (!running && existsSync(CFG)) console.log(c.dim("\n起常驻:brain service install")); From 21aa7e9839b28557f185c86fa44ff06609a64c7e Mon Sep 17 00:00:00 2001 From: fakehank Date: Thu, 25 Jun 2026 18:49:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(viewer):=20=E6=9C=AC=E6=9C=BA=20viewer?= =?UTF-8?q?=20=E5=8E=BB=20token=20=E7=99=BB=E5=BD=95=20+=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E8=AE=BE=E5=A4=87=20token=EF=BC=9B=E7=BA=BF=E4=B8=8A?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E6=9C=AC=E6=9C=BA=E6=8E=A7=E5=88=B6=E5=8F=B0?= =?UTF-8?q?=E3=80=8D=E6=8C=89=E9=92=AE=20(v0.1.20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 本机 viewer:本机 loopback 不该要 token 登录 —— 去掉 token 闸,改用 Host/Origin 守卫挡跨站 + DNS-rebind(同源请求照常放行)。`brain viewer` 直接开 http://127.0.0.1:7878。 采集配置页新增「连接」区,展示服务器/身份/设备 token(可显示·复制),方便粘到网站登录。 #4 线上共享库网站:顶栏加「本机控制台」按钮,新标签打开本机 viewer(双语)。 bump v0.1.20,让 #1(brain status 修复)+ #2 随自动更新下发;#4 随服务端部署即生效。 Co-Authored-By: Claude Opus 4.8 (1M context) --- client/viewer.mjs | 28 +++++++++++++--------------- package.json | 2 +- web/app.css | 7 +++++++ web/app.js | 4 ++++ web/index.html | 1 + web/viewer.html | 2 ++ web/viewer.js | 26 +++++++++++++++++++++----- 7 files changed, 49 insertions(+), 21 deletions(-) diff --git a/client/viewer.mjs b/client/viewer.mjs index 7459e03..75f6634 100644 --- a/client/viewer.mjs +++ b/client/viewer.mjs @@ -4,7 +4,6 @@ import http from "node:http"; import { readFileSync, existsSync, statSync, writeFileSync, openSync, readSync, closeSync, readdirSync } from "node:fs"; import { join, extname, basename } from "node:path"; -import { randomBytes } from "node:crypto"; import { parseSession } from "../core/parse.mjs"; import { slimRaw, slimRawFile } from "../core/slim.mjs"; import { projectSession } from "../core/project.mjs"; @@ -23,14 +22,13 @@ const FLAG_SCAN_CAP = 40; // overview 自检:一次最多现扫 40 条未 export function startViewer({ ROOT, cfg, paths }) { const WEB = join(ROOT, "web"); const INFO = join(ROOT, ".brain-viewer.json"); - const token = ensureToken(INFO); - const ctx = { ROOT, WEB, cfg, paths, token }; + const ctx = { ROOT, WEB, cfg, paths }; const server = http.createServer((req, res) => { handle(req, res, ctx).catch((e) => json(res, 500, { error: String(e?.message || e) })); }); server.on("error", (e) => log.warn("viewer 监听异常", { err: e.message })); listen(server, Number(cfg.viewer_port) || 7878, 0, (port) => { - writeFileSync(INFO, JSON.stringify({ port, token, url: `http://127.0.0.1:${port}/?t=${token}`, pid: process.pid })); + writeFileSync(INFO, JSON.stringify({ port, url: `http://127.0.0.1:${port}/`, pid: process.pid })); log.info("本机查看器已起", { url: `http://127.0.0.1:${port}/` }); }); return server; @@ -45,29 +43,28 @@ function listen(server, port, tries, onUp) { server.listen(port, "127.0.0.1", () => onUp(port)); } -function ensureToken(infoPath) { - try { const j = JSON.parse(readFileSync(infoPath, "utf8")); if (j.token) return j.token; } catch {} - return randomBytes(18).toString("base64url"); -} - const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); }; async function handle(req, res, ctx) { const u = new URL(req.url, "http://127.0.0.1"); const p = u.pathname; - // 静态:页面 + 资源(loopback only,页面本身不校验 token;token 在 /api 上把关) - if (p === "/" ) return serveFile(res, join(ctx.WEB, "viewer.html")); + // 不再用「本地 token 登录」(本机自己看自己的页面要 token 是多余摩擦)。 + // 本机 loopback 已是边界,再加两道守卫:① Host 必须是回环(挡 DNS rebinding); + // ② 带 Origin 的请求其 host 必须是回环(挡别的网站跨站打 127.0.0.1)。同源页面自身的请求都放行。 + const hostName = (req.headers.host || "").split(":")[0]; + if (hostName && hostName !== "127.0.0.1" && hostName !== "localhost") return json(res, 403, { error: "forbidden host" }); + const origin = req.headers.origin; + if (origin) { let oh = ""; try { oh = new URL(origin).hostname; } catch {} if (oh !== "127.0.0.1" && oh !== "localhost") return json(res, 403, { error: "cross-site blocked" }); } + + // 静态:页面 + 资源 + if (p === "/") return serveFile(res, join(ctx.WEB, "viewer.html")); if (!p.startsWith("/api/")) { const f = join(ctx.WEB, p.replace(/^\/+/, "")); if (f.startsWith(ctx.WEB) && existsSync(f) && statSync(f).isFile()) return serveFile(res, f); return json(res, 404, { error: "not found" }); } - // API:本地 token 校验(query t 或 header) - const tok = u.searchParams.get("t") || req.headers["x-brain-token"]; - if (tok !== ctx.token) return json(res, 401, { error: "bad local token" }); - // GET if (req.method === "GET") { if (p === "/api/overview") return json(res, 200, overview(ctx)); @@ -131,6 +128,7 @@ function overview(ctx) { const pending = scanPending(ctx); return { me: ctx.cfg.me || {}, version: CLIENT_VERSION, server_url: ctx.cfg.server_url || "", + device_token: ctx.cfg.token || "", // 展示给本人,方便复制去网站登录(loopback only,仅本人可见) lastSync: lastTick(ctx.ROOT), counts: { uploaded: counts.uploaded, pending: pending.length, skipped: counts.skipped, localTotal: all.length + pending.length }, config: pickConfig(ctx.cfg), diff --git a/package.json b/package.json index 115f7dc..4aa1bac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "klear-team-brain", - "version": "0.1.19", + "version": "0.1.20", "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/web/app.css b/web/app.css index b7fc80a..80f8ed0 100644 --- a/web/app.css +++ b/web/app.css @@ -71,6 +71,13 @@ code, pre { font-family: var(--font-mono); } font-family: var(--font-mono); font-size: var(--fz-meta); cursor: pointer; } .lang-toggle:hover { border-color: var(--line-soft); color: var(--ink); } +.local-btn { + display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 var(--sp-3); flex-shrink: 0; + background: var(--surface); border: var(--bw) solid var(--line); border-radius: var(--r-md); + color: var(--muted); font-family: var(--font-mono); font-size: var(--fz-meta); cursor: pointer; text-decoration: none; +} +.local-btn:hover { border-color: var(--line-soft); color: var(--ink); } +.local-btn .dot { width: 6px; height: 6px; border-radius: var(--r-full); background: var(--green); } .token-chip { display: flex; align-items: center; gap: var(--sp-2); height: 30px; padding: 0 var(--sp-4); flex-shrink: 0; diff --git a/web/app.js b/web/app.js index d6598a9..06753ab 100644 --- a/web/app.js +++ b/web/app.js @@ -19,6 +19,8 @@ const I18N = { "doc.desc": "The truth layer of the team project brain — browse the whole team's sessions, team docs, code state and activity.", "nav.menu": "Menu", "brand.tag": "truth layer", + "local.open": "My console", + "local.title": "Open this machine's local footprint console (needs the collector running on this machine)", "search.ph": "grep the team truth repo… ( / to focus)", "search.aria": "Search the truth repo", "token.chipTitle": "Connect / switch token", @@ -224,6 +226,8 @@ const I18N = { "doc.desc": "团队项目大脑的真相层 —— 浏览全队 session、团队文档、代码状态与活动流。", "nav.menu": "菜单", "brand.tag": "真相层", + "local.open": "本机控制台", + "local.title": "打开本机足迹控制台(需本机采集常驻在跑)", "search.ph": "grep 全队真相库… ( / 聚焦)", "search.aria": "搜索真相库", "token.chipTitle": "连接 / 切换 token", diff --git a/web/index.html b/web/index.html index af37a8d..cfcae0e 100644 --- a/web/index.html +++ b/web/index.html @@ -23,6 +23,7 @@ + My console + +
${ic("i-folder")}${t("cfg.scope")}
${ic("i-folder")}${t("cfg.scopeWl")}
${t("cfg.scopeWlDesc")}
${ic("i-layers")}${t("cfg.scopeAll")}
${t("cfg.scopeAllDesc")}
@@ -200,6 +214,8 @@ $("config").addEventListener("click", async (e) => { else if (act === "rmfolder") { WCFG.upload_folders.splice(+el.dataset.i, 1); renderConfig(); } else if (act === "addexcl") { const p = prompt(t("prompt.addExclude")); if (p) { (WCFG.exclude ||= []).push(p.trim()); renderConfig(); } } else if (act === "rmexcl") { WCFG.exclude.splice(+el.dataset.i, 1); renderConfig(); } + else if (act === "revealtok") { TOK_SHOWN = !TOK_SHOWN; renderConfig(); } + else if (act === "copytok") { try { await navigator.clipboard.writeText(CONN.device_token); toast(t("conn.copied")); } catch { toast(CONN.device_token || "-"); } } else if (act === "save") saveConfig(); else if (act === "discard") { WCFG = clone(CFG); renderConfig(); } else if (act === "addterm") { const p = prompt(t("prompt.addTerm")); if (p) { const v = p.trim(); const type = /^\/.*\/[gimsuy]*$/.test(v) ? "regex" : "text"; await apiPost("/api/redact-add", { pattern: v, type }); await refreshRedact(); renderConfig(); toast(t("toast.termAdded")); } }