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>
171 lines
7.6 KiB
TypeScript
171 lines
7.6 KiB
TypeScript
/**
|
||
* Arcrun#108 迴歸守衛 —— 「雲端讀資料用的命名空間,要跟你寫資料用的那個一致」
|
||
*
|
||
* 2026-08-12 實害:leo 的藏書地圖回 0 個庫,實際有 1854 條三元組。
|
||
* 根因:你 push 工作流、小幫手上傳知識、MCP 查詢都用 `~/.arcrun/config.yaml` 的 `api_key`
|
||
* (leo = `bfezv28v`),但 cypher 讀取時的 owner_id 來自 worker 環境變數
|
||
* ——而那個變數是 repo toml 帶的**官方 prod 值** `CONSOLE_TENANT = "leo"`。
|
||
* 寫在 A、讀在 B,全被過濾掉。
|
||
*
|
||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||
* · 驗得到知識 → **寫** `ARCRUN_NAMESPACE`,讓讀寫兩端對齊
|
||
* · 驗不到 / 問不到 → **一個字都不動**,既有值原封保留
|
||
* (無條件覆蓋會把一台「知識本來就寫在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
|
||
* ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟)
|
||
*
|
||
* 全部離線跑:真的 wrangler.toml + 真的 render 程式碼,fetch 用假的,不碰任何實例。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
renderWranglerToml,
|
||
preservedVars,
|
||
namespaceHasKnowledge,
|
||
VERSION_STAMP_WORKER,
|
||
type DeployContext,
|
||
} from '../src/lib/deploy.ts';
|
||
|
||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||
|
||
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
|
||
const LEO_NS = 'bfezv28v';
|
||
|
||
const CTX: DeployContext = {
|
||
accountId: 'acc-user-123',
|
||
apiToken: 'token',
|
||
workerSubdomain: 'user-sub',
|
||
selfHosted: true,
|
||
kbdbEmbed: true,
|
||
};
|
||
|
||
function readVars(toml: string): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
let inVars = false;
|
||
for (const line of toml.split('\n')) {
|
||
if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; }
|
||
if (/^\s*\[/.test(line)) { inVars = false; continue; }
|
||
if (!inVars) continue;
|
||
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||
if (m) out[m[1]] = m[2];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */
|
||
function deployedVars(ctx: DeployContext, live: Record<string, string>): Record<string, string> {
|
||
const keep = preservedVars(live, CYPHER_TOML);
|
||
const extra: Record<string, string> = { ...keep };
|
||
if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||
return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra));
|
||
}
|
||
|
||
// ── ① 驗得到知識 → 寫進去 ────────────────────────────────────────────────────
|
||
|
||
test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => {
|
||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {});
|
||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||
// CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去
|
||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||
});
|
||
|
||
test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => {
|
||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' });
|
||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||
});
|
||
|
||
// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)──────────────
|
||
|
||
test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => {
|
||
const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' });
|
||
assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns');
|
||
});
|
||
|
||
test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => {
|
||
const vars = deployedVars(CTX, {});
|
||
assert.equal(vars.ARCRUN_NAMESPACE, undefined);
|
||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||
});
|
||
|
||
test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' +
|
||
'列進去會讓驗不到的那趟把既有值一起洗掉', async () => {
|
||
const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts');
|
||
assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false);
|
||
});
|
||
|
||
test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => {
|
||
assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor');
|
||
});
|
||
|
||
// ── ③ 「先驗再寫」那支探針的三態 ───────────────────────────────────────────────
|
||
|
||
test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => {
|
||
const calls: string[] = [];
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||
calls.push(String(url));
|
||
assert.equal((init?.headers as Record<string, string>)['X-Arcrun-API-Key'], LEO_NS);
|
||
return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 });
|
||
}) as typeof fetch;
|
||
try {
|
||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true);
|
||
assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|
||
|
||
test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => {
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = (async () =>
|
||
new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch;
|
||
try {
|
||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|
||
|
||
test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => {
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch;
|
||
try {
|
||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
|
||
try {
|
||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|
||
|
||
test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => {
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = (async () =>
|
||
new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
|
||
try {
|
||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|
||
|
||
test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => {
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch;
|
||
try {
|
||
assert.equal(await namespaceHasKnowledge('', LEO_NS), null);
|
||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null);
|
||
} finally {
|
||
globalThis.fetch = orig;
|
||
}
|
||
});
|