/** * 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 { const out: Record = {}; 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): Record { const keep = preservedVars(live, CYPHER_TOML); const extra: Record = { ...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)['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; } });