b223a69884
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。
病根(不是資料掉了,是讀寫兩端各拿一個來源):
寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
leo = `bfezv28v`)
讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。
與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。
租戶字串該從哪裡來(本票的核心判斷):
**從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
#105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。
修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
- `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
偽裝成「你沒有資料」的元凶。
- `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
- 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
留下的閘(規則存在但沒機制驗證=會再犯第三次):
· 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
· 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
· 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。
沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。
驗證:
cypher vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
tsc 5 個既有錯誤,零新增
cli node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
閘 壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
80 lines
3.3 KiB
JavaScript
80 lines
3.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 「靜態租戶字串不得用於資料面過濾」機械閘的**執行殼**(Arcrun#108)。
|
||
*
|
||
* 規則本體(純函式、零 node 相依)在 `tenant-source-rules.mjs`——拆開的理由是
|
||
* **這道閘自己要能被測試**:Workers runtime 的 vitest 沒有 node:fs,規則若和走檔案系統的
|
||
* 程式碼綁在一起就 import 不動,測試也就寫不出來(當晚有一道閘連讀自己的原始碼都擋,
|
||
* 結果沒人驗得了它會不會誤攔)。現在 tests/tenant-gate.test.ts 直接餵字串驗規則。
|
||
*
|
||
* 用法:
|
||
* node scripts/check-tenant-source.mjs [projectRoot] # 掃 src/,有違規 → exit 1
|
||
* node scripts/check-tenant-source.mjs --stdin <相對路徑> # 從 stdin 讀「即將寫入的內容」
|
||
* npm run check:tenant
|
||
*
|
||
* `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`(規則 8.1)用的:在檔案**還沒寫下去之前**
|
||
* 就擋,這樣違規根本進不了工作區。Edit 只給片段也沒關係——規則是逐行的,正好只看新寫的那幾行。
|
||
*/
|
||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||
import { join, relative, sep } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { scanSource } from './tenant-source-rules.mjs';
|
||
|
||
/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */
|
||
function listTsFiles(root, dir = root, out = []) {
|
||
for (const name of readdirSync(dir)) {
|
||
const full = join(dir, name);
|
||
if (statSync(full).isDirectory()) listTsFiles(root, full, out);
|
||
else if (name.endsWith('.ts')) out.push(relative(root, full));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 掃整個 cypher-executor/src。回傳違規清單。 */
|
||
export function scanProject(projectRoot) {
|
||
const srcRoot = join(projectRoot, 'src');
|
||
const all = [];
|
||
for (const rel of listTsFiles(projectRoot, srcRoot)) {
|
||
const relPosix = rel.split(sep).join('/');
|
||
all.push(
|
||
...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({
|
||
...v,
|
||
file: relPosix,
|
||
})),
|
||
);
|
||
}
|
||
return all;
|
||
}
|
||
|
||
/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */
|
||
async function runStdin(relPath) {
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||
const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8'));
|
||
if (violations.length === 0) return 0;
|
||
for (const v of violations) {
|
||
console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`);
|
||
console.error(` → ${v.message}`);
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||
if (process.argv[2] === '--stdin') {
|
||
process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts'));
|
||
}
|
||
const projectRoot = process.argv[2] ?? process.cwd();
|
||
const violations = scanProject(projectRoot);
|
||
if (violations.length === 0) {
|
||
console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts');
|
||
process.exit(0);
|
||
}
|
||
console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n');
|
||
for (const v of violations) {
|
||
console.error(` [${v.rule}] ${v.file}:${v.line}`);
|
||
console.error(` ${v.text}`);
|
||
console.error(` → ${v.message}\n`);
|
||
}
|
||
process.exit(1);
|
||
}
|