Compare commits

..

10 Commits

Author SHA1 Message Date
uncle6me-web 10d150ac2b fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。

病根(不是金鑰沒同步,是身分沒接住):
  oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
  namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
  只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
  而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
  所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。

修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
   display_name/role/libraries 存進 authorization code → access token。
   /portal/login 補回 session_expires_in,access_token TTL 夾成
   min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
   cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
   session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
   kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
   藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
   分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
   服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。

新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
  GET  /portal/data/map、/portal/data/map/:library
  GET  /portal/data/templates、POST /portal/data/templates
  GET  /portal/data/records/by-template/:t、GET /portal/data/records/:id
  POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。

KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。

沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。

驗證:
  mcp        tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
  cypher     vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
  kbdb       vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
  端到端     ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:33:12 +08:00
uncle6me-web a24f2912eb chore(builds): 重編執行檔——把 wait 的修法真的放進引擎成品(Arcrun#93 的閘抓到的)
#101 併進 main 後,.worker-builds/arcrun-cypher-executor 還記著 a5e4caf,
比源碼 HEAD 少了 f1370e2(wait 搬回引擎那筆)。

⇒ 這正是 #93 那道閘存在的理由:**沒有它,這次出貨會送出一個
「引擎裡沒有 wait 修法」的成品**——leo 更新完照樣燒 CPU,而出貨線全綠。
閘寫出來的隔天就抓到一次,抓到的還是我。

arcrun-cypher-executor  source a5e4caff1370e22  sha256=8411ed59b7ad

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:30:26 +08:00
Leo 1791ffa497 Merge pull request '#101 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西' (#103) from fix/wait-not-in-wasm-101 into main 2026-08-12 07:26:00 +00:00
Leo 296fb01247 Merge pull request 'portal 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)' (#102) from fix/portal-onboarding-drop-gemini-key-81 into main 2026-08-12 07:24:47 +00:00
uncle6me-web f1370e2275 fix(engine): 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西(Arcrun#101)
leo 在 youlin stage 實測(只有 input >> wait 兩個節點):
  ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
四個值同一種死法、與 ms 無關 ⇒ 病不是「等待很貴」,是「等待從來沒成功過」。

修法:wait 移進 BUILTIN_COMPONENTS,由引擎 await 一個 timer。
只花 wall-clock、不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改。

🔴 誠實標明:原本註解斷言「Workers 時鐘在同步執行期間凍結,所以自旋永不結束」。
寫測試去證,反而被打臉——workerd 裡自旋 2553 圈後 Date.now() 就前進了。
那條假斷言已刪除(不是改鬆),完整機制降級為推測。修法不依賴它:
純 WASI 沙箱本來就沒有睡覺這個手段,會等的只有宿主。

實測:
  npx vitest run tests/wait-builtin.test.ts  → 12 passed (12)
  npx vitest run(全套)                      → 386 passed / 14 failed
                                              (14 = 動工前的既有紅燈數,未新增)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:15:08 +08:00
uncle6me-web d58a6e152d fix(portal): 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)
leo 2026-08-12:「已經改用 workers AI,刪掉。」

那一項長在**安裝完成清單裡而且是勾選項** ⇒ 使用者會以為不做這步就沒裝完,
而它要人離開流程、去第三方網站申請帳號、把金鑰貼進表單
——**這是整條安裝路徑上最重的一個動作,而它現在是白做的**。

連帶:步數不再寫死「還差 2 步」,改成由剩下的項目算出來
(少一項卻還寫 2,是另一種說謊)。
2026-08-12 14:44:38 +08:00
uncle6me-web 793a94ecb5 chore(worker-builds): 重編——#100 的修法要進執行檔才會送到用戶那台
cypher-executor source 525faaf → a5e4caf(sha 49d59597 → d1765930)。
今天第三次在同一件事上被提醒:改完源碼沒重編,出貨與安裝送出去的還是舊的。
2026-08-12 13:36:39 +08:00
uncle6me-web cbeddf7535 merge: 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
總管審過並自己跑了驗證:
  cypher-executor 374 pass(原 357,+17 條),14 個失敗與 main 基準線完全相同
  反向驗證:拿掉帶認證那一行 ⇒ 14 → 17 failed;還原 ⇒ 回到 14

它挖到兩件我沒交辦、而且比原題更嚴重的:
  · /triplets/stats 的 total 是**分頁長度不是總數**(預設 limit=100)
    ⇒ 只修 401 的話畫面會從「0」變成「100」——一樣是假的
  · 數 entries 原本不帶 owner 過濾 ⇒ **會混到別的租戶**(實測 459,137 vs leo 的 458,732)
2026-08-12 13:36:17 +08:00
uncle6me-web d7c6bd0680 merge: 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
總管審過並自己跑了驗證(那條線被權限閘擋住,沒能執行):
  新測試 20 條全過(含四種「說不準就停手」情境)|CLI 全套 38 pass / 0 fail
  反向驗證:把「照名字 ensure」原語加回去 ⇒ 紅線測試當場變紅(0 pass / 1 fail)
  重編 dist:舊的 ensureKvNamespace/ensureD1Database 在產物裡 0 個

它做的比交辦的多:加了兩條紅線測試(cf-api 不得再提供「找不到同名就順手建一顆」的原語、
只有 resource-resolver 能決定要不要建),以及一條我沒想到的——
**部署出去的 toml 不得殘留官方帳號的資源 id**(自架寫進官方庫=跨租戶外洩)。

誠實標記:驗證是本機模擬(假 CF API),**沒有在真機上跑過**。
2026-08-12 13:36:17 +08:00
uncle6me-web 45a546a686 fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS`
⇒ 找不到 ⇒ 新建一顆空的並綁上去。

2026-08-12 實撞(leo21c):一次例行更新後
  KV 9 顆 → 18 顆、D1 1 顆 → 2 顆,worker 全綁到新建的空的
  ⇒ 工作流一支都看不到、portal 登出、總圖空的、80 把 recipe 解不出來
  leo 原話:「leo21c 是掛掉的」。資料沒掉,但從他的角度就是東西全不見了。

修法方向:**已經部署上去的 worker 綁著什麼,那就是事實**——
名字是使用者那側的事,不是更新指令可以決定的。

📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
2026-08-12 13:30:17 +08:00
35 changed files with 3471 additions and 389 deletions
+140 -16
View File
@@ -2650,7 +2650,7 @@ var init_recipes = __esm({
});
// cypher-executor/src/lib/constants.ts
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
var init_constants3 = __esm({
"cypher-executor/src/lib/constants.ts"() {
"use strict";
@@ -2698,6 +2698,7 @@ var init_constants3 = __esm({
"CLICK": "ON_CLICK",
"SUBFLOW": "CALLS_SUBFLOW"
};
WAIT_MAX_MS = 3e4;
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
["comp_passthrough", (ctx) => ctx],
["comp_uppercase", (ctx) => {
@@ -2707,6 +2708,54 @@ var init_constants3 = __esm({
["comp_counter", (ctx) => {
const c = ctx;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
["wait", async (ctx) => {
const c = ctx && typeof ctx === "object" ? ctx : {};
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
await new Promise((resolve) => setTimeout(resolve, ms));
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}]
]);
}
@@ -3060,7 +3109,12 @@ var init_component_loader = __esm({
filter: "SVC_FILTER",
merge: "SVC_MERGE",
try_catch: "SVC_TRY_CATCH",
wait: "SVC_WAIT",
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: "SVC_SET",
array_ops: "SVC_ARRAY_OPS",
string_ops: "SVC_STRING_OPS",
@@ -3145,6 +3199,11 @@ function graphBase(env) {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
function graphHeaders(env) {
const headers = {};
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
var kbdbProxyRouter, NEED_KEY;
var init_kbdb_proxy = __esm({
"cypher-executor/src/routes/kbdb-proxy.ts"() {
@@ -3278,8 +3337,7 @@ var init_kbdb_proxy = __esm({
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers });
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
@@ -14537,6 +14595,20 @@ async function fetchJson(url, headers) {
return null;
}
}
async function fetchTripletTotal(env, tenant2) {
const { base, headers } = kbdbBase(env);
const data = await fetchJson(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant2)}`,
headers
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
}
async function fetchEntryTotal(env, filters) {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ ...filters, limit: "1" });
@@ -14644,6 +14716,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal
@@ -14655,7 +14728,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env)
),
fetchTripletTotal(c.env, tenant2),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant2 }),
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
@@ -14763,13 +14842,14 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
system: {
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null
triplets_total: tripletTotal
},
generated_at: new Date(now2).toISOString()
});
@@ -14777,22 +14857,22 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now2 = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson(
`${base}/entries?${new URLSearchParams({ owner_id: tenant2, entry_type: "wiki_card", limit: "1" }).toString()}`,
headers
),
fetchJson(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant2),
fetchJson(`${base}/embed/backfill/status`, headers)
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
return c.json({
wiki_card_total: typeof wikiCards?.total === "number" ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now2, latestMs),
triplets_total: typeof graphStats?.total === "number" ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now2).toISOString()
@@ -14944,6 +15024,27 @@ function findBestNodeMatch(searchTerm, nodeNames) {
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
async function tripletCount(env, owner) {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
async function tripletCensus(env, tenant2) {
const owned = await tripletCount(env, tenant2);
if (owned !== 0) return { owned, any: null };
return { owned, any: await tripletCount(env, "") };
}
async function fuzzyFindNode(env, tenant2, searchTerm) {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
@@ -15045,8 +15146,7 @@ portalDataRouter.get(
return c.json(mapGraphWorkflowOutput(result.data));
}
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -15081,12 +15181,19 @@ portalDataRouter.get(
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
tripletCensus(c.env, tenant2)
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
const records = body && Array.isArray(body.records) ? body.records : [];
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = /* @__PURE__ */ new Set();
const edges = [];
@@ -15112,7 +15219,24 @@ portalDataRouter.get(
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
let emptyReason = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = "unreadable";
else if (census.owned > 0) emptyReason = "scope_mismatch";
else if (census.any === null) emptyReason = "unreadable";
else emptyReason = census.any > 0 ? "scope_mismatch" : "confirmed_empty";
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === "confirmed_empty",
empty_reason: emptyReason
});
})
);
portalDataRouter.get(
+5 -5
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T04:21:48.581Z",
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
"generated_at": "2026-08-12T07:29:58.626Z",
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 570290,
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
"js_bytes": 577374,
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
+23 -40
View File
@@ -10,7 +10,6 @@ import chalk from 'chalk';
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
@@ -135,7 +134,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
* 驗 token → 查 subdomain → 下載部署物 → 解析資源(沿用既有/必要才新建)→ 部署 Worker
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
*/
@@ -185,41 +184,13 @@ async function initSelfHosted(
process.exit(1);
}
// 2. KV namespace(冪等)
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
// 不建 R2R2 是 dead storageregistry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
process.stdout.write(chalk.gray(` → KV ${title}...`));
const id = await cf.ensureKvNamespace(title, existing);
kvNamespaceIds[title] = id;
console.log(chalk.green(' ✓'));
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
// (kbdb-base SDD Q4). idempotent: reuse if exists.
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
if (/auth/i.test(em)) {
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
} else {
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
}
}
// 3. 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用)
let workerSubdomain = '';
@@ -245,8 +216,20 @@ async function initSelfHosted(
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
// selfHosted: true → deploy 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 MCP 401)。
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx);
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
if (deploy.blocked) {
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
console.log(' ' + deploy.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// self-hosted 自己的 MCP worker URLmcp-account-source §3.mcp.json 指自己,不 fallback 官方)。
@@ -290,8 +273,8 @@ async function initSelfHosted(
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
const verify = await verifyInstall({
cf,
requiredKv: REQUIRED_KV_NAMESPACES,
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
cypherUrl,
});
printPreflight('安裝驗收(裝完檢查)', verify.items);
@@ -301,7 +284,7 @@ async function initSelfHosted(
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
+17 -36
View File
@@ -14,11 +14,9 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../lib/deploy.js';
@@ -44,43 +42,15 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
// D1KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
let d1DatabaseId = '';
try {
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
console.log(chalk.green(' ✓'));
} catch (e) {
const em = e instanceof Error ? e.message : String(e);
console.log(chalk.yellow(`${em}`));
if (/auth/i.test(em)) {
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
}
}
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
const ctx: DeployContext = {
accountId: config.cloudflare_account_id,
apiToken: config.cf_api_token,
workerSubdomain: extractSubdomain(config.cypher_executor_url),
kvNamespaceIds,
d1DatabaseId: d1DatabaseId || undefined,
// self-hosted → 注入 MULTI_TENANT="false"mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
@@ -93,7 +63,18 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
kbdbEmbed: config.kbdb_embed !== false,
};
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
//Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
if (result.blocked) {
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
console.log(' ' + result.message.split('\n').join('\n '));
console.log('');
process.exit(1);
}
if (result.implemented) {
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
+103 -14
View File
@@ -3,6 +3,8 @@
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
*/
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
export interface CfKvClientOptions {
@@ -83,7 +85,7 @@ export class CfKvClient {
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient {
export class CfAccountClient implements ResourceApi {
private accountBase: string;
private headers: Record<string, string>;
@@ -96,6 +98,16 @@ export class CfAccountClient {
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T;
}
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
private async cfRaw<T>(
path: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
const res = await fetch(`${this.accountBase}${path}`, {
...init,
headers: { ...this.headers, ...(init?.headers ?? {}) },
@@ -104,10 +116,13 @@ export class CfAccountClient {
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
throw new Error(`CF API ${path} 失敗:${msg}`);
return {
ok: false,
status: res.status,
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
};
}
return data.result;
return { ok: true, status: res.status, result: data.result };
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
@@ -126,12 +141,16 @@ export class CfAccountClient {
return map;
}
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listKvNamespaces());
const found = known.get(title);
if (found) return found;
/**
* 無條件新建一顆 KV namespace。
*
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
* (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
* 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在
* 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。
*/
async createKvNamespace(title: string): Promise<string> {
const result = await this.cf<{ id: string; title: string }>(
'/storage/kv/namespaces',
{ method: 'POST', body: JSON.stringify({ title }) },
@@ -139,6 +158,24 @@ export class CfAccountClient {
return result.id;
}
/**
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
* CF`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。
*
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
* 把查不到當成不存在,就是 #97 的根因。
*/
async getScriptBindings(script: string): Promise<ScriptBindings> {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [] };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
@@ -153,14 +190,66 @@ export class CfAccountClient {
return map;
}
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
const known = existing ?? (await this.listD1Databases());
const found = known.get(name);
if (found) return found;
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
async createD1Database(name: string): Promise<string> {
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
}
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本:
* 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
*/
async createVectorizeIndex(name: string): Promise<string> {
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
}
}
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
interface RawWorkerBinding {
type?: string;
name?: string;
namespace_id?: string;
id?: string;
database_id?: string;
index_name?: string;
}
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
const out: LiveBinding[] = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
+235 -69
View File
@@ -20,6 +20,19 @@ import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
import { CfAccountClient } from './cf-api.js';
import {
applyResourcePlan,
bindingKey,
parseWranglerRequirements,
planResources,
ResourcePlanBlocked,
TABLE_KIND,
type BindingRequirement,
type ResourceApi,
type ResourceKind,
type ResolvedResource,
} from './resource-resolver.js';
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-1222/23 成功後重跑仍全部
@@ -107,7 +120,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
}
/**
* init 要建立的 KV namespacetitle
* arcrun 各 worker 會用到的 KV **binding 名**清單
*
* 🔴 Arcrun#97 之後,這份清單**不再是「要去 CF 上建的資源標題」**——
* 真正要哪些綁定,是部署當下從每份 wrangler.toml 讀出來的(parseWranglerRequirements),
* 要不要建則由 resource-resolver 依「已部署的 worker 綁著什麼」決定。
* 這裡保留成一份**文件與離線測試用的期望清單**(測試會比對 toml 沒有漏綁),
* 不再被任何執行路徑拿去「照名字 ensure」。
*
* 原始出處保留如下:
* 前 7 個權威來源:.claude/rules/01-tech-stack.md 資料儲存表(cypher-executor 用)。
* SUBMISSIONS_KVregistry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
@@ -151,8 +172,11 @@ export interface DeployContext {
accountId: string;
apiToken: string;
workerSubdomain: string;
kvNamespaceIds: Record<string, string>; // title → id
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
/** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入,呼叫端不要自己給**
* Arcrun#97:呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路)。*/
kvNamespaceIds?: Record<string, string>;
/** KBDB Base D1 id;同上,由資源解析填入。*/
d1DatabaseId?: string;
// self-hosted 單租戶旗標。trueself-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars]
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
@@ -190,6 +214,11 @@ export interface DeployResult {
cypherExecutorUrl?: string;
mcpUrl?: string; // self-hosted 自己的 MCP worker URLmcp-account-source §3
message: string;
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
blocked?: boolean;
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
resources?: Map<string, ResolvedResource>;
}
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
@@ -219,8 +248,10 @@ export function wranglerAvailable(): boolean {
export async function downloadAndDeploy(
ctx: DeployContext,
ref = 'main',
opts: { force?: boolean } = {},
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
): Promise<DeployResult> {
const mode = opts.mode ?? 'update';
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
// 1. 下載 + 解壓 Gitea archive tarball
let root: string;
try {
@@ -262,28 +293,123 @@ export async function downloadAndDeploy(
}
const failures: string[] = [];
const allDirs = [...tier1, ...tier2];
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
// 失敗不致命(收進 failuresbase 仍可部署、維持 keyword
if (ctx.kbdbEmbed) {
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
//
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
//
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
// → 整趟停手,**在動任何東西之前**。
//
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
const requirements: BindingRequirement[] = [];
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
for (const dir of allDirs) {
const tomlPath = join(dir, 'wrangler.toml');
if (!existsSync(tomlPath)) continue;
const raw = readFileSync(tomlPath, 'utf8');
tomlPreviews.set(dir, raw);
const preview = renderWranglerToml(raw, ctx, new Map());
const parsed = parseWranglerRequirements(preview);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
if (requirements.length > 0) {
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
let plan;
try {
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
await ensureVectorizeIndex(ctx);
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
await ensureVectorizeMetadataIndexes(ctx);
plan = await planResources(api, requirements, mode);
} catch (e) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
if (plan.blockers.length > 0) {
console.log(chalk.yellow(' ✗'));
return {
implemented: true,
blocked: true,
message:
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
plan.blockers.map((b) => `${b}`).join('\n') +
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
console.log(chalk.yellow(' ✗'));
const raw = e instanceof Error ? e.message : String(e);
const detail = e instanceof ResourcePlanBlocked
? e.blockers.map((b) => `${b}`).join('\n')
: `${raw}`;
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
const hint = /d1/i.test(raw) && /auth/i.test(raw)
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
: '';
return {
implemented: true,
blocked: true,
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
console.log(chalk.green(' ✓'));
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
const created = [...resolved.values()].filter((r) => r.origin === 'created');
if (adopted.length > 0) {
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
for (const r of adopted) console.log(chalk.gray(` = ${r.binding}${r.value}(讀自 ${r.from}`));
}
if (created.length > 0) {
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
for (const r of created) console.log(chalk.yellow(` + ${r.binding}${r.value}`));
}
}
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
ctx.kvNamespaceIds = Object.fromEntries(
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
);
ctx.d1DatabaseId =
resolved.get(bindingKey('d1', 'DB'))?.value
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
if (vectorizeIndex) {
try {
process.stdout.write(chalk.gray(` → 語義查詢 metadata index${vectorizeIndex}...`));
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(' ⚠'));
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
}
}
// 3. 對每個 worker:注入 KV id+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
const allDirs = [...tier1, ...tier2];
let deployed = 0;
let skipped = 0;
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
@@ -296,7 +422,7 @@ export async function downloadAndDeploy(
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx);
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
@@ -434,35 +560,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
}
}
/**
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
* REST `POST /accounts/{id}/vectorize/v2/indexes`dimensions=1024 / metric=cosine,對齊 bge-m3)。
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
*/
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: KBDB_VECTORIZE_INDEX,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
signal: AbortSignal.timeout(60_000),
});
if (res.ok) return;
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
const json = (await res.json().catch(() => null)) as
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
| null;
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
throw new Error(msg);
}
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
@@ -471,9 +568,12 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
* Vectorize v2:要對某 metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶過濾的語意查詢一律回 0。
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`indexType=string)。
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
*
* 🔴 index 名由呼叫端傳入(= 資源解析沿用到的那顆),**不是**寫死 KBDB_VECTORIZE_INDEX
* 使用者實例上那顆 index 叫什麼是他那側的事實,我們把 metadata index 建到「他真的在用的那顆」上。
*/
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
const res = await fetch(url, {
method: 'POST',
@@ -596,21 +696,31 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
* - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL,自架可達。
* - R2`[[r2_buckets]]`)是 dead storageregistry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
*/
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
function injectWranglerConfig(
tomlPath: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
original?: string,
): void {
if (!existsSync(tomlPath)) return;
let toml = readFileSync(tomlPath, 'utf8');
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
if (!id) continue;
const re = new RegExp(
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
'g',
);
toml = toml.replace(re, `$1${id}$2`);
}
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
}
/**
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
*
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
toml = toml.replace(
@@ -629,14 +739,6 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
);
}
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
toml = toml.replace(
/(database_id\s*=\s*")[^"]*(")/,
`$1${ctx.d1DatabaseId}$2`,
);
}
// self-hosted:注入 MULTI_TENANT="false" 到 [vars]mcp-account-source §5.5)。
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
// 只對有 [vars] 的 workermcp / cypher-executor)生效;其餘無 [vars] 的不動。
@@ -668,7 +770,71 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
writeFileSync(tomlPath, toml, 'utf8');
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
}
/**
* 把解析好的資源 id 寫進對應的 binding 區塊。
*
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
* KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher`CREDENTIALS_DB`)與
* kbdb(`DB`)各有自己的 D1 綁定,盲換會把兩邊當成同一個東西——而使用者的實例
* 完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實,我們只是原樣搬過去。
*/
export function applyResolvedBindings(
toml: string,
resolved: Map<string, ResolvedResource>,
): string {
if (resolved.size === 0) return toml;
const VALUE_KEY: Record<ResourceKind, string> = {
kv_namespace: 'id',
d1: 'database_id',
vectorize: 'index_name',
};
const out: string[] = [];
let block: string[] = [];
let kind: ResourceKind | null = null;
const flush = (): void => {
if (kind) {
const binding = block
.map((l) => l.trim())
.filter((l) => !l.startsWith('#'))
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
.find((b): b is string => !!b);
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
if (hit) {
const key = VALUE_KEY[kind];
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
if (at >= 0) {
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
} else {
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
block.push(`${key} = "${hit.value}"`);
}
}
}
out.push(...block);
block = [];
};
for (const line of toml.split('\n')) {
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
kind = TABLE_KIND[table[1]] ?? null;
}
block.push(line);
}
flush();
return out.join('\n');
}
/**
+38 -20
View File
@@ -6,9 +6,11 @@
* 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。
* - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項
* + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。
* - **冪等**:重跑檢查後「什麼也沒動」ensureKvNamespace / ensureD1Database 本就冪等)
* - **冪等**:重跑檢查後「什麼也沒動」。
*
* 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。
* 本檔只做「偵測 + 報告」,不自己建資源(要不要建由 resource-resolver 判斷,deploy.ts 編排)。
* 🔴 Arcrun#97:報告裡的 fix 指令也算「產品的一部分」——一句「acr update(冪等重建)」
* 接在誤報的「缺 KV」後面,就是把使用者直接推去執行那個把實例洗空的動作。
*/
import { execFileSync } from 'node:child_process';
@@ -77,42 +79,58 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
*/
export async function verifyInstall(opts: {
cf: CfAccountClient;
requiredKv: readonly string[];
expectD1Name?: string;
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
kvNamespaceIds: Record<string, string>;
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
d1DatabaseId?: string;
cypherUrl?: string;
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
const items: PreflightItem[] = [];
// KV實查 CF 上現有 namespace,比對必需清單
// KV核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
const kvBindings = Object.entries(opts.kvNamespaceIds);
try {
const existing = await opts.cf.listKvNamespaces();
const have = new Set(existing.keys());
const missing = opts.requiredKv.filter((t) => !have.has(t));
const ids = new Set((await opts.cf.listKvNamespaces()).values());
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
items.push(
missing.length === 0
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
: { name: 'KV namespaces', ok: false, detail: `${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
: {
name: 'KV namespaces',
ok: false,
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
);
} catch (e) {
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
}
// D1實查 CF 上是否有該庫
if (opts.expectD1Name) {
// D1同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
if (opts.d1DatabaseId) {
try {
const dbs = await opts.cf.listD1Databases();
const ids = new Set((await opts.cf.listD1Databases()).values());
items.push(
dbs.has(opts.expectD1Name)
? { name: `D1 ${opts.expectD1Name}`, ok: true }
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
ids.has(opts.d1DatabaseId)
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
: {
name: `D1 ${opts.d1DatabaseId}`,
ok: false,
detail: '這顆 D1 在帳號上找不到',
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
},
);
} catch (e) {
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
const m = msg(e);
const fix = /auth/i.test(m)
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
: 'acr update(冪等重試)';
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
: '檢查 CF token 的 D1 讀取權限';
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
}
}
+408
View File
@@ -0,0 +1,408 @@
/**
* resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」
*
* 🔴 Arcrun#972026-08-12 實害,leo 的實例中了):
* 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**`WEBHOOKS`)當成 Cloudflare 上的
* **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**。
* 安裝器建的資源不叫那個名字(它叫 `arcrun-rag-<instance>-kv-webhooks`)⇒ 一次例行更新
* 新建了 9 顆 KV、1 顆 D1,使用者的工作流/登入狀態/子庫**在畫面上全部消失**。
* 資料沒有被刪,但 worker 被綁去空的那幾顆——從使用者的角度,他的東西就是不見了。
*
* 根因不是「KV 那段寫錯」,是**「用名字猜使用者的資源」這個做法本身**:
* 名字是**使用者那側的事實**(安裝器要怎麼取名由它決定,而且它有權改),
* 我們不能拿自己的命名慣例去對號入座,更不能在對不上的時候自作主張生一顆新的。
* ——所以修法不是「多比對幾種名字」,是**不再用名字當識別**。
*
* ── 新規則(三句話)────────────────────────────────────────────────
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
*
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
*/
/** 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 */
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */
export interface LiveBinding {
kind: ResourceKind;
binding: string;
value: string;
}
export interface ScriptBindings {
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
deployed: boolean;
bindings: LiveBinding[];
}
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
export interface ResourceApi {
getScriptBindings(script: string): Promise<ScriptBindings>;
/** title → id */
listKvNamespaces(): Promise<Map<string, string>>;
/** name → uuid */
listD1Databases(): Promise<Map<string, string>>;
listVectorizeIndexes(): Promise<string[]>;
createKvNamespace(title: string): Promise<string>;
createD1Database(name: string): Promise<string>;
createVectorizeIndex(name: string): Promise<string>;
}
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
export interface BindingRequirement {
kind: ResourceKind;
binding: string;
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
worker: string;
createName: string;
}
export interface PlannedAdopt {
kind: ResourceKind;
binding: string;
value: string;
/** 從哪顆已部署的 worker 上讀到的 */
from: string;
}
export interface PlannedCreate {
kind: ResourceKind;
binding: string;
createName: string;
wantedBy: string[];
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
alsoBind: string[];
}
export interface ResourcePlan {
adopt: PlannedAdopt[];
create: PlannedCreate[];
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
blockers: string[];
}
export interface ResolvedResource {
kind: ResourceKind;
binding: string;
value: string;
origin: 'adopted' | 'created';
from?: string;
}
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
constructor(readonly blockers: string[]) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
}
}
export function bindingKey(kind: ResourceKind, binding: string): string {
return `${kind}:${binding}`;
}
const KIND_LABEL: Record<ResourceKind, string> = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
*
* @param mode 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
*/
export async function planResources(
api: ResourceApi,
requirements: readonly BindingRequirement[],
mode: 'update' | 'init',
): Promise<ResourcePlan> {
const blockers: string[] = [];
const adopt: PlannedAdopt[] = [];
const create: PlannedCreate[] = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
const live = new Map<string, LiveBinding[]>();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) live.set(script, res.bindings);
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
const byKey = new Map<string, BindingRequirement[]>();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
const existingCache = new Map<ResourceKind, Set<string>>();
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
const hit = existingCache.get(kind);
if (hit) return hit;
let set: Set<string>;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
const found: Array<{ value: string; script: string }> = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
let existing: Set<string>;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
}
/**
* 收斂「不同 binding 其實是同一顆資源」的情況。
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`),不是使用者那側的資源名——
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
*
* 沒有這一步會出兩種錯:
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
*/
function shareSameResource(
adopt: PlannedAdopt[],
create: PlannedCreate[],
byKey: Map<string, BindingRequirement[]>,
): PlannedCreate[] {
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
const out: PlannedCreate[] = [];
const groups = new Map<string, PlannedCreate>();
for (const c of create) {
const groupKey = `${c.kind}${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**。
*/
export async function applyResourcePlan(
api: ResourceApi,
plan: ResourcePlan,
): Promise<Map<string, ResolvedResource>> {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
const out = new Map<string, ResolvedResource>();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
const madeSoFar: string[] = [];
for (const c of plan.create) {
let value: string;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
export interface WranglerRequirements {
/** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */
script: string;
bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>;
}
/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */
export const TABLE_KIND: Record<string, ResourceKind> = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
*
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
* kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
*/
export function parseWranglerRequirements(toml: string): WranglerRequirements {
let script = '';
let seenTable = false;
const bindings: WranglerRequirements['bindings'] = [];
let kind: ResourceKind | null = null;
let binding = '';
let createName = '';
const flush = (): void => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
+532
View File
@@ -0,0 +1,532 @@
/**
* Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」
*
* 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、
* 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。
*
* 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找,
* 安裝器建的資源叫 `arcrun-rag-<instance>-kv-webhooks`,永遠對不上 ⇒ 每次更新都新建一顆綁上去。
*
* 這份測試用一台**照安裝器命名慣例**的假實例(不是 leo 的實例,不碰 leo21c)跑真正的
* 解析程式碼(planResources / applyResourcePlan / renderWranglerToml),對照更新前後:
* ① 工作流數、登入狀態、子庫數 ② 帳號上的資源顆數 ③ 找不到既有資源時要停手
*/
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 {
planResources,
applyResourcePlan,
parseWranglerRequirements,
bindingKey,
ResourcePlanBlocked,
type BindingRequirement,
type ResourceApi,
type ScriptBindings,
type LiveBinding,
type ResolvedResource,
} from '../src/lib/resource-resolver.ts';
import {
renderWranglerToml,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../src/lib/deploy.ts';
import { CfAccountClient } from '../src/lib/cf-api.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */
const WORKER_TOMLS = [
'cypher-executor/wrangler.toml',
'registry/wrangler.toml',
'mcp/wrangler.toml',
'kbdb/wrangler.toml',
];
const CTX: DeployContext = {
accountId: 'acct-test',
apiToken: 'tok-test',
workerSubdomain: 'yuga3bse',
selfHosted: true,
kbdbEmbed: true,
};
// ─────────────────────────────────────────────────────────────────────────────
// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名
// ─────────────────────────────────────────────────────────────────────────────
const INSTANCE = 'yuga3bse';
interface FakeOpts {
/** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */
failBindingsFor?: string;
/** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */
deleteKvTitle?: string;
/** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */
nothingDeployed?: boolean;
}
class FakeCloudflare implements ResourceApi {
/** title → id */
kv = new Map<string, string>();
/** name → uuid */
d1 = new Map<string, string>();
vectorize: string[] = [];
/** script → bindings */
scripts = new Map<string, LiveBinding[]>();
/** 使用者的東西:kvId → (key → value) */
kvData = new Map<string, Map<string, string>>();
/** d1Id → 子庫名單 */
d1Libraries = new Map<string, string[]>();
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
createdKv: string[] = [];
createdD1: string[] = [];
createdVectorize: string[] = [];
constructor(private opts: FakeOpts = {}) {
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
for (const binding of REQUIRED_KV_NAMESPACES) {
const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
const id = `kvid-${binding.toLowerCase()}`;
this.kv.set(title, id);
this.kvData.set(id, new Map());
}
this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);
// 使用者的東西
this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);
if (!opts.nothingDeployed) {
const kvB = (b: string): LiveBinding =>
({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
this.scripts.set('arcrun-cypher-executor', [
kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
]);
this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
this.scripts.set('arcrun-kbdb', [
{ kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
]);
}
if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
}
async getScriptBindings(script: string): Promise<ScriptBindings> {
if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
const b = this.scripts.get(script);
return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
}
async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
this.createdKv.push(title);
return id;
}
async createD1Database(name: string): Promise<string> {
const id = `NEW-d1id-${this.createdD1.length}`;
this.d1.set(name, id);
this.d1Libraries.set(id, []);
this.createdD1.push(name);
return id;
}
async createVectorizeIndex(name: string): Promise<string> {
this.vectorize.push(name);
this.createdVectorize.push(name);
return name;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
// ─────────────────────────────────────────────────────────────────────────────
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
const requirements: BindingRequirement[] = [];
const tomls = new Map<string, string>();
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
tomls.set(rel, raw);
const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
return { requirements, tomls };
}
/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
function deployAndReadBindings(
tomls: Map<string, string>,
resolved: Map<string, ResolvedResource>,
): Map<string, Map<string, string>> {
const out = new Map<string, Map<string, string>>();
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
const script = parseWranglerRequirements(rendered).script;
const bound = new Map<string, string>();
let kind: string | null = null;
let binding = '';
for (const line of rendered.split('\n')) {
const t = line.trim();
if (t.startsWith('#')) continue;
const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) { kind = table[1]; binding = ''; continue; }
const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!m) continue;
if (m[1] === 'binding') binding = m[2];
else if (binding && (
(kind === 'kv_namespaces' && m[1] === 'id')
|| (kind === 'd1_databases' && m[1] === 'database_id')
|| (kind === 'vectorize' && m[1] === 'index_name')
)) bound.set(binding, m[2]);
}
out.set(script, bound);
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => {
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const before = {
workflows: cf.kvData.get('kvid-webhooks')!.size,
sessions: cf.kvData.get('kvid-sessions_kv')!.size,
libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
};
assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
// 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要)
const cypher = bound.get('arcrun-cypher-executor')!;
assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);
const after = {
workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
};
assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
});
// ═════════════════════════════════════════════════════════════════════════════
// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆)
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ②:更新不會在帳號上多生任何資源', async () => {
const cf = new FakeCloudflare();
const kvBefore = cf.kv.size;
const d1Before = cf.d1.size;
const vecBefore = cf.vectorize.length;
assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
});
test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
// 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
// cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。
// 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。
const cf = new FakeCloudflare();
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建
}
const d1s = await cf.listD1Databases();
if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');
assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)');
assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆');
assert.equal(cf.kv.size, 18, '9 → 18');
assert.equal(cf.d1.size, 2, '1 → 2');
// 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
});
// ═════════════════════════════════════════════════════════════════════════════
// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去
// ═════════════════════════════════════════════════════════════════════════════
test('#97 ③-aworker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => {
const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.ok(plan.blockers.length > 0, '要有 blocker');
const said = plan.blockers.join('\n');
assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
assert.match(said, /找不到/, '要說清楚發生什麼事');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
assert.deepEqual(cf.createdD1, []);
});
test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-cupdate 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, []);
});
test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => {
const cf = new FakeCloudflare();
// registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊)
cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
cf.kv.set('some-other-kv', 'kvid-other');
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
assert.deepEqual(cf.createdKv, []);
});
// ═════════════════════════════════════════════════════════════════════════════
// 合法的新建:只有「確定沒人綁過」時才准
// ═════════════════════════════════════════════════════════════════════════════
test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => {
const cf = new FakeCloudflare();
cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的)
cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
});
test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
const { requirements } = collectRequirements();
const plan = await planResources(cf, requirements, 'init');
assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
assert.equal(cf.createdVectorize.length, 1);
});
test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => {
const cf = new FakeCloudflare();
cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb
const { requirements, tomls } = collectRequirements();
const plan = await planResources(cf, requirements, 'update');
assert.deepEqual(plan.blockers, []);
assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');
const resolved = await applyResourcePlan(cf, plan);
const bound = deployAndReadBindings(tomls, resolved);
assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
'credential 目錄要跟 KBDB 在同一顆庫');
assert.deepEqual(cf.createdD1, []);
});
test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => {
// repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入,
// 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。
const cf = new FakeCloudflare();
const { requirements, tomls } = collectRequirements();
const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));
const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 做法本身的看守:不准再出現「照名字 ensure」這種原語
// ═════════════════════════════════════════════════════════════════════════════
test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。'
+ '要建資源一律先過 resource-resolver 的 planResources。');
});
test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => {
for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
const src = readFileSync(join(REPO, rel), 'utf8');
assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
`${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
}
});
// ═════════════════════════════════════════════════════════════════════════════
// 底層零件
// ═════════════════════════════════════════════════════════════════════════════
test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => {
const toml = [
'name = "arcrun-kbdb" # 註解不影響',
'',
'[[d1_databases]]',
'binding = "DB"',
'database_name = "arcrun-kbdb"',
'database_id = "placeholder"',
'',
'[vars]',
'ENVIRONMENT = "production"',
'',
'# [[vectorize]]',
'# binding = "VECTORIZE"',
'# index_name = "arcrun-kbdb-embed-m3"',
].join('\n');
const r = parseWranglerRequirements(toml);
assert.equal(r.script, 'arcrun-kbdb');
assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
});
test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
});
test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => {
const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
const resolved = new Map<string, ResolvedResource>([
[bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
[bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
]);
const out = renderWranglerToml(cypher, CTX, resolved);
const bound = parseWranglerRequirements(out);
assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
// 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved
assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
});
test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => {
for (const rel of WORKER_TOMLS) {
const raw = readFileSync(join(REPO, rel), 'utf8');
const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
const resolved = new Map<string, ResolvedResource>(
preview.bindings.map((b) => [
bindingKey(b.kind, b.binding),
{ kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
]),
);
const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
assert.deepEqual(
after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
`${rel}: 預覽與實際注入看到的綁定必須一致`,
);
}
});
test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => {
const { requirements } = collectRequirements();
const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
) as typeof fetch;
const cf = new CfAccountClient('a', 't');
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
) as typeof fetch;
await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
} finally {
globalThis.fetch = orig;
}
});
test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
const orig = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(JSON.stringify({
success: true,
result: {
bindings: [
{ type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
{ type: 'd1', name: 'DB', id: 'db1' },
{ type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
{ type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
{ type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
],
},
}), { status: 200 })) as typeof fetch;
const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
assert.equal(res.deployed, true);
assert.deepEqual(res.bindings, [
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
{ kind: 'd1', binding: 'DB', value: 'db1' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
]);
} finally {
globalThis.fetch = orig;
}
});
+23 -26
View File
@@ -1108,7 +1108,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
})();
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
// ── t53 完成安裝清單(進站必見,做完才消失)───────────────────────────────
// 件數演進:t53 三件 → t54 兩件(設定檔改由小幫手憑帳密自取)
// → arcrun-rag#81 一件(AI 問答改走 Workers AI,不再要用戶自備金鑰)。
function setupSteps() {
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
}
@@ -1123,7 +1125,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var p = S.profile || {};
if (p.role !== 'admin') return;
var s = setupSteps();
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
// arcrun-rag#81leo 08-12:「已經改用 workers AI,刪掉」):只剩「下載小幫手」一件。
// 舊的 s.key(貼 Google AI 金鑰)已整條移除,見下方 innerHTML 處的說明。
// 相容:舊瀏覽器 localStorage 裡殘留的 s.key 不再被讀 —— 沒設過 key 的人也不會被卡住。
if (s.daemon) return; // t54 起設定改由小幫手輸入帳密自取,不再下載檔案
var cfg = window.ARCRUN_CONFIG || {};
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
@@ -1134,7 +1139,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var el = document.createElement('div');
el.id = 'setup-checklist';
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
// 🔴 arcrun-rag#81leo 08-12):第二項「啟用 AI 問答(貼 Google AI 金鑰)」整條刪除。
// 為什麼不是「一個沒用的欄位」而已:它長在**安裝完成清單裡而且是勾選項**
// ⇒ 用戶會以為不做這步就沒裝完,而它要人離開流程、去第三方網站申請帳號、
// 把金鑰貼進表單——整條安裝路徑上最重的一個動作,而且是白做的。
// 真相:雲端問答走 t18108-04)改好的 Workers AI`env.AI` binding,免金鑰)——
// /portal/data/chat → tenant 的 rag_chat workflow → `workers_ai_chat` recipe
// api-recipe-seeds.ts:150endpoint `@cf/meta/llama-4-scout-17b-16e-instruct`)。
// 這裡貼的金鑰是打 POST /portal/admin/ai 存一筆雲端 gemini_api_key credential
// **現行問答鏈路一個地方都沒有讀它**。
// 後端 route 本身不動(同 08-09 拿掉檢修孔按鈕的處置:端點無害、已無任何 UI 呼叫,
// 純粹清路標);已經存過金鑰的人那筆 credential 也原封不動,不做刪除。
// ⚠️ 別把這個跟設定頁「AI 設定」面板講的地端萃取金鑰搞混——那把是填在
// **同步小幫手**托盤裡的,從來不經過這個清單。
el.innerHTML = '<b>還差 1 步,安裝就真的完成了</b>'
+ row(s.daemon, 'daemon',
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
+ (daemonUrl
@@ -1156,34 +1174,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
+ row(s.key, 'key',
'<b>啟用 AI 問答</b><a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
document.body.appendChild(el);
var dl = document.getElementById('sc-daemon');
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
// t54config.json 下載鈕已移除(設定由小幫手憑帳密自取)
var kb = document.getElementById('sc-key-save');
if (kb) kb.addEventListener('click', function () {
var k = (document.getElementById('sc-key').value || '').trim();
var m = document.getElementById('sc-key-msg');
if (!k) { m.textContent = '請先貼上金鑰'; return; }
kb.disabled = true; m.textContent = '啟用中…';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ gemini_api_key: k })
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
markStep('key');
})
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
});
// arcrun-rag#81:金鑰輸入框的送出邏輯(POST /portal/admin/ai)一併移除——
// 只藏畫面留著那條路,等於這個要求還在,只是變得更難發現。
var later = document.getElementById('sc-later');
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
}
+6 -1
View File
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
filter: 'SVC_FILTER',
merge: 'SVC_MERGE',
try_catch: 'SVC_TRY_CATCH',
wait: 'SVC_WAIT',
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: 'SVC_SET',
array_ops: 'SVC_ARRAY_OPS',
string_ops: 'SVC_STRING_OPS',
+62
View File
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
'SUBFLOW': 'CALLS_SUBFLOW',
};
/**
* wait registry/components/wait/component.contract.yaml
* **調**Arcrun#101
*
*/
export const WAIT_MAX_MS = 30000;
/**
*
* WASM = Workercypher-executor HTTP URL R2
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
const c = ctx as Record<string, unknown>;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
['wait', async (ctx) => {
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: 'ms 必須大於 0' };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
await new Promise<void>((resolve) => setTimeout(resolve, ms));
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
? c.context as Record<string, unknown>
: {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}],
]);
export const SCORE_THRESHOLD = 0.5;
+209
View File
@@ -653,6 +653,215 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
}),
);
// ═══════════════════════════════════════════════════════════════════════════
// 授權的 AIarcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
// ═══════════════════════════════════════════════════════════════════════════
//
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
//
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
// 於是查詢時只好去找一把**服務內部金鑰**KBDB_INTERNAL_TOKEN)直打 KBDB——
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
//
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
// ① 一律 requirePortalUsersession → 回讀 user record → 停用即時生效),
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
// 呼叫端自己帶租戶字串=繞過庫過濾),
// ③ 越權與不存在同回 404(不洩存在性)。
//
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
/**
* record entry ** `library` slot record **
*
* entry 'general' fallbackentry
* general record contact / workflow_metadata / triplet
* triplet library slot general fallback
* ["kb"] contact
* owner_id server /
*/
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
const lib = values?.library;
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
}
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: string,
libraries: string[],
): boolean {
if ((rec.owner_id ?? '') !== tenant) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
portalDataRouter.get('/portal/data/map', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ success: true, libraries: [], count: 0, note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
}),
);
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/map/:library', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param('library');
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/templates — template 清單。
// template=虛擬表定義(schema),**全域共享不分租戶**kbdb-proxy 同一裁定,leo 2026-06-14):
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
portalDataRouter.get('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, '/templates');
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// POST /portal/data/templates — 建 templatename + slots)。
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
// created_by 記租戶(溯源),template 本身全域可見可用。
portalDataRouter.post('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as
| { name?: unknown; slots?: unknown; description?: unknown }
| null;
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: 'name 與 slots[] 必填' }, 400);
}
const res = await kbdbFetch(c.env, '/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: portalTenant(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant = portalTenant(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
| null;
if (!body || !Array.isArray(body.records)) {
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
return c.json({ success: true, records, count: records.length });
}),
);
// GET /portal/data/records/:recordId — 單筆 record。
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
// POST /portal/data/records — 依 template 填一筆 record。
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
portalDataRouter.post('/portal/data/records', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
}
const body = (await c.req.json().catch(() => null)) as
| { template?: unknown; values?: unknown }
| null;
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
return c.json({ error: 'template 與 values 必填' }, 400);
}
const values = body.values as Record<string, unknown>;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
}
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
//
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
+5
View File
@@ -677,6 +677,11 @@ portalRouter.post('/portal/login', (c) =>
display_name: rec.values.display_name ?? '',
role: rec.values.role ?? 'user',
libraries: parseLibraries(rec.values.libraries),
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
session_expires_in: sessionTtl(c.env),
// 絕不回租戶字串(design §3.3portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*
});
}),
+208
View File
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
});
});
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
//
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
describe('藏書地圖 /portal/data/mapMCP 走的那條)', () => {
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
await seedSession('tok-m1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
expect(data.count).toBe(1);
});
it('["*"] 全庫 → 全部庫都回', async () => {
await seedSession('tok-m2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
const data = (await res.json()) as { libraries: { library: string }[] };
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
});
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
await seedSession('tok-m3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { count: number; note?: string };
expect(data.count).toBe(0);
expect(data.note).toContain('尚未被授權');
});
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
await seedSession('tok-m4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
await seedSession('tok-m5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
expect(res.status).toBe(200);
});
it('未登入 → 401', async () => {
expect((await get('/portal/data/map')).status).toBe(401);
});
});
describe('結構化資料 /portal/data/records、/portal/data/templatesMCP 走的那條)', () => {
it('by-templateserver 注入 owner_idcaller 自帶的被靜默覆蓋(繞不過)', async () => {
await seedSession('tok-r1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
let captured = '';
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/records/by-template/contact')) return false;
captured = p;
return true;
},
method: 'GET',
})
.reply(200, { success: true, records: [], count: 0 });
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
Authorization: 'Bearer tok-r1',
});
expect(res.status).toBe(200);
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
});
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
await seedSession('tok-r2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
success: true,
records: [
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
],
count: 3,
});
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
const data = (await res.json()) as { records: { record_id: string }[] };
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
});
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
await seedSession('tok-r3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_other', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
await seedSession('tok-r4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_hr', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
await seedSession('tok-r5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_fin', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
});
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
await seedSession('tok-r6', 'rec_6');
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
let body: Record<string, unknown> = {};
fetchMock
.get(KBDB)
.intercept({
path: '/records',
method: 'POST',
body: (b: string) => {
body = JSON.parse(b) as Record<string, unknown>;
return true;
},
})
.reply(200, { success: true, record: { record_id: 'r_new' } });
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
});
expect(res.status).toBe(200);
expect(body.owner_id).toBe(TENANT);
});
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
await seedSession('tok-r7', 'rec_7');
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
});
expect(res.status).toBe(403);
});
it('templates 全域共享(schema 非內容):登入即可列', async () => {
await seedSession('tok-t1', 'rec_t1');
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/templates', method: 'GET' })
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
expect(res.status).toBe(200);
expect(((await res.json()) as { count: number }).count).toBe(1);
});
it('未登入 → 401records / templates 都是)', async () => {
expect((await get('/portal/data/templates')).status).toBe(401);
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
expect((await get('/portal/data/records/r1')).status).toBe(401);
});
});
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe('GET /portal/data/graph/neighbors/:nameD-4 粗閘)', () => {
+165
View File
@@ -0,0 +1,165 @@
/**
* waitArcrun#101
*
* leo 2026-08-12 youlin stage input >> wait
* ms=3000 38.9s 503(1102) / ms=20000 34.0s / ms=30000 34.9s / 3000 34.8s
* ms N N CPUms=3000 3
*
*
* wait TinyGo WASMtime.Sleep WASI poll_oneoffcomponent worker
* WASI shim poll_oneoff ENOSYS TinyGo 退 clock_time_get
* Workers I/O
*
*
* A. workerd
* B. wait timer
* C. workflow wait
* D. wait step 1 arcrun-wait worker fetch
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { env } from 'cloudflare:test';
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
import { createComponentLoader } from '../src/lib/component-loader';
import type { Bindings, ComponentRunner } from '../src/types';
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
afterEach(() => {
vi.unstubAllGlobals();
});
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
//
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
//
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
//
// 保留下來的是**查證得動的那一半**WASI shim 把 poll_oneoff 實作成 ENOSYS(76)
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
//
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
// 等待都該搬回引擎。
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
const t0 = Date.now();
await new Promise<void>((r) => setTimeout(r, 20));
expect(Date.now()).toBeGreaterThan(t0);
});
});
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
const started = Date.now();
const results = await Promise.all(
Array.from({ length: 5 }, () => wait({ ms: 300 })),
);
const elapsed = Date.now() - started;
for (const r of results) {
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
}
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
expect(elapsed).toBeLessThan(900);
expect(elapsed).toBeGreaterThanOrEqual(300);
});
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
const order: string[] = [];
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
await Promise.all([waited, ticked]);
expect(order).toEqual(['tick-50', 'wait-400']);
});
});
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
//
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
});
it('contract gherkinms 為 0 時失敗(不是靜靜跳過)', async () => {
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
});
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
}
});
it('contract gherkinms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
expect(asked).toEqual([WAIT_MAX_MS]);
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
});
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
expect(asked).toEqual([30000]);
});
it('context 照契約透傳,並補上 waited_ms', async () => {
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
expect(r).toEqual({
success: true,
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
});
});
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
});
});
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
describe('D. component-loader 把 wait 解到內建 runnerstep 1),不發任何 fetch', () => {
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
const runner = await createComponentLoader(fakeEnv)('wait');
const r = await runner({ ms: 10 });
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
// 修法前這裡會打 arcrun-wait.test-sub.workers.devSVC_WAIT 未綁時的 fallback),
// 那顆 worker 就是會燒到 1102 的那顆。
expect(fetchSpy).not.toHaveBeenCalled();
});
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
});
});
+17 -7
View File
@@ -65,6 +65,13 @@ export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
/**
* record slot entries owner_idcreateRecord
* 2026-08-12 `GET /records/:id` ****
* id cypher portal /AI
* 404 null
*/
owner_id: string | null;
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
@@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
.run();
}
return { record_id: recordId, template_id: tpl.id, values: input.values };
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
}
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
@@ -147,17 +154,19 @@ export async function updateRecord(
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
const res = await db
.prepare(
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`,
)
.bind(recordId)
.all<{ slot: string; content: string; template_id: string }>();
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values: Record<string, string> = {};
for (const r of rows) values[r.slot] = r.content;
return { record_id: recordId, template_id: rows[0].template_id, values };
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
@@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
const placeholders = chunk.map(() => '?').join(',');
const evRes = await db
.prepare(
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id IN (${placeholders})`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
for (const r of evRes.results ?? []) {
let rec = byId.get(r.record_id);
if (!rec) {
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
byId.set(r.record_id, rec);
}
rec.values[r.slot] = r.content;
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
}
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
+16 -3
View File
@@ -1,13 +1,25 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { Env } from "./types.js";
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
import { handleMcpRequest } from "./mcp-handler.js";
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
import type { PortalIdentity } from "./oauth/store.js";
import { inspectorHtml } from "./pages/inspector.js";
import { kbdbFetch } from "./lib/kbdb-client.js";
import { registerOAuthRoutes } from "./oauth/routes.js";
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
const _app = new Hono<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>();
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
@@ -261,7 +273,8 @@ app.options("/mcp", (c) => {
app.post("/", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
const partnerToken = c.get("partner_token");
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
});
// 輸出根 app_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
+34 -6
View File
@@ -19,6 +19,7 @@
import type { Env } from "../types.js";
import { kbdbFetch } from "./kbdb-client.js";
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
export interface LibraryMapRow {
@@ -86,25 +87,45 @@ const MAP_FETCH_TIMEOUT_MS = 1500;
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
const CACHE_TTL_FAIL_MS = 60 * 1000;
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
/**
* 2026-08-12
*
* entity
*
*
*/
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
export function __resetLibraryMapInstructionsCacheForTests(): void {
instructionsCache = null;
instructionsCache.clear();
}
/**
* MCP server instructions design §4 / §6session instructions
* push /HTTP // JSON nullcaller
*
* identity.kind === 'portal' cypher `/portal/data/map`
* KBDB `/map` tokenstale
*/
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
export async function buildLibraryMapInstructions(
env: Env,
identity: KnowledgeIdentity,
): Promise<string | null> {
if (identity.kind === "stale") return null;
// 快取 keyportal 用 session(=這個人這次登入),service 用固定字串。
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
const now = Date.now();
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
const hit = instructionsCache.get(cacheKey);
if (hit && hit.expiresAt > now) return hit.text;
let text: string | null = null;
try {
const res = await Promise.race([
kbdbFetch(env, "/map"),
identity.kind === "portal"
? portalFetch(env, identity.portal.session, "/portal/data/map")
: kbdbFetch(env, "/map"),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
),
@@ -124,6 +145,13 @@ export async function buildLibraryMapInstructions(env: Env): Promise<string | nu
text = null;
}
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
instructionsCache.set(cacheKey, {
text,
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
});
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
if (instructionsCache.size > 64) {
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
}
return text;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Portal client AI西
*
* leo 2026-08-12 Portal 西
* AI MCP AI西
* MCP ****
*
* **portal session token** cypher
* portal
* cypher `/portal/data/*` server
* rule 07 API
*
* CYPHER_EXECUTOR service binding binding
*/
import type { Env } from "../types.js";
import type { PortalIdentity } from "../oauth/store.js";
import { errorResponse } from "./cypher-client.js";
export interface PortalCallOpts {
method?: string;
body?: unknown;
query?: Record<string, string | number | undefined>;
}
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
export async function portalFetch(
env: Env,
session: string,
path: string,
opts: PortalCallOpts = {},
): Promise<Response> {
if (!env.CYPHER_EXECUTOR) {
throw new Error("CYPHER_EXECUTOR service binding not configured");
}
const url = new URL(`https://cypher${path}`);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
}
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
method: opts.method ?? "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session}`,
},
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
}
/**
*
*
* leo
* - portal portal
* - service static token / partner key KBDB
* - stale OAuth token token ****
* 退
*/
export type KnowledgeIdentity =
| { kind: "portal"; portal: PortalIdentity }
| { kind: "service" }
| { kind: "stale" };
export function resolveKnowledgeIdentity(
authPath: "oauth" | "service",
portal: PortalIdentity | undefined,
): KnowledgeIdentity {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
export function staleIdentityError() {
return errorResponse(
"identity_missing",
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
"不需要另外找任何 credential 或金鑰。",
[
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
],
);
}
/**
* portal AI
* 401/403 ****
* AI 使
*/
export async function portalError(res: Response, what: string) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
[
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
"重連後權限與你在 portal 網頁上看到的一致",
],
detail,
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
detail,
);
}
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status}`, ["稍後重試"], detail);
}
+8 -2
View File
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { registerAllTools } from "./tools/registry.js";
import { buildLibraryMapInstructions } from "./lib/library-map.js";
import type { KnowledgeIdentity } from "./lib/portal-client.js";
import { Env } from "./types.js";
export async function handleMcpRequest(
@@ -9,11 +10,16 @@ export async function handleMcpRequest(
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<Response> {
// library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
const mapInstructions = await buildLibraryMapInstructions(env);
//
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
const mapInstructions = await buildLibraryMapInstructions(env, identity);
// 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
@@ -60,7 +66,7 @@ export async function handleMcpRequest(
{ instructions },
);
registerAllTools(server, env, orgNamespace, partnerToken);
registerAllTools(server, env, orgNamespace, partnerToken, identity);
await server.connect(transport);
return transport.handleRequest(request);
+28 -2
View File
@@ -1,9 +1,19 @@
import { Context, Next } from "hono";
import { Env } from "../types.js";
import { getAccessToken } from "../oauth/store.js";
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
import { constantTimeEqual } from "../oauth/crypto.js";
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
/**
* ****kbdb_*
* - "oauth" Portal portal session portal
* portal
* - "service"static token / partner key ****
* KBDB
* token
*/
export type AuthPath = "oauth" | "service";
/**
* MCP / GUI
*
@@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.
* ALLOW_PLAINTEXT_NAMESPACE="true"
*/
export async function partnerAuthMiddleware(
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
c: Context<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>,
next: Next
) {
const origin = originOf(c.req.url);
@@ -50,6 +68,11 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", at.namespace);
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
c.set("portal", at.portal);
c.set("auth_path", "oauth");
await next();
return;
}
@@ -60,6 +83,7 @@ export async function partnerAuthMiddleware(
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
c.set("org_namespace", ns);
c.set("partner_token", ns);
c.set("auth_path", "service");
await next();
return;
}
@@ -79,6 +103,7 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", info.org_namespace);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
@@ -89,6 +114,7 @@ export async function partnerAuthMiddleware(
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
c.set("org_namespace", token);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
+56 -6
View File
@@ -14,6 +14,7 @@ import {
consumeAuthCode,
putAccessToken,
AUTH_CODE_TTL_SECONDS,
type PortalIdentity,
} from "./store.js";
import {
originOf,
@@ -34,10 +35,25 @@ const CORS_JSON = {
"Cache-Control": "no-store",
} as const;
function ownerNamespace(env: Env): string {
/**
* ****arcrun_* kbdb_*
* portal session store.ts PortalIdentity
*
* cypher workflow API opaque key
* X-Arcrun-API-Key portal session cypher
* portal session workflow
* arcrun_* ****
*
* "leo" ****
* namespace KBDB owner_id API key
*/
function workflowTenant(env: Env): string {
return env.MCP_OWNER_NAMESPACE || "leo";
}
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
function tokenTtl(env: Env): number {
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
@@ -248,7 +264,13 @@ export function registerOAuthRoutes<
}
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
// 走 service bindingMCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
let loginOk = false;
//
// 🔴 2026-08-12leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
let portal: PortalIdentity | null = null;
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
try {
const res = await c.env.CYPHER_EXECUTOR.fetch(
new Request("https://cypher/portal/login", {
@@ -257,11 +279,34 @@ export function registerOAuthRoutes<
body: JSON.stringify({ email, password }),
}),
);
loginOk = res.ok;
if (res.ok) {
const body = (await res.json().catch(() => null)) as {
session_token?: unknown;
display_name?: unknown;
role?: unknown;
libraries?: unknown;
session_expires_in?: unknown;
} | null;
const session = typeof body?.session_token === "string" ? body.session_token : "";
if (session) {
portal = {
session,
display_name: typeof body?.display_name === "string" ? body.display_name : "",
role: typeof body?.role === "string" ? body.role : "user",
libraries: Array.isArray(body?.libraries)
? body.libraries.filter((x): x is string => typeof x === "string")
: [],
};
const ttl = Number(body?.session_expires_in);
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
}
}
} catch {
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
}
if (!loginOk) {
if (!portal) {
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
}
if (!c.env.OAUTH_KV) {
@@ -275,7 +320,9 @@ export function registerOAuthRoutes<
code_challenge_method: "S256",
scope: consent.scope,
resource: consent.resource,
namespace: ownerNamespace(c.env),
namespace: workflowTenant(c.env),
portal,
portal_session_expires_in: portalTtl,
});
const location = redirectWith(redirectUri, {
code,
@@ -318,7 +365,9 @@ export function registerOAuthRoutes<
return err("invalid_grant", "PKCE verification failed");
}
const ttl = tokenTtl(c.env);
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
const accessToken = randomToken(32);
await putAccessToken(
c.env.OAUTH_KV,
@@ -327,6 +376,7 @@ export function registerOAuthRoutes<
namespace: data.namespace,
client_id: data.client_id,
scope: data.scope,
portal: data.portal,
// RFC 8707aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
aud: resourceUri(originOf(c.req.url)),
+31
View File
@@ -4,6 +4,28 @@
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
import { sha256Hex } from "./crypto.js";
/**
* authorize token
*
* leo 2026-08-12 MCP
* ****
*
* `session` cypher `/portal/login` portal session token portal
* KV TTL
* access_token TTL routes.ts
* MCP session
*
* display_name / role / libraries ****arcrun_whoami
* cypher user record
* token
*/
export interface PortalIdentity {
session: string;
display_name: string;
role: string;
libraries: string[];
}
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
export interface AuthCodeData {
client_id: string;
@@ -15,6 +37,10 @@ export interface AuthCodeData {
resource: string;
/** 換發後 token 綁定的資料分區(owner namespace)。 */
namespace: string;
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
portal: PortalIdentity;
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
portal_session_expires_in: number;
}
/** access token 綁定的資料。 */
@@ -26,6 +52,11 @@ export interface AccessTokenData {
aud: string;
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
exp: number;
/**
* token ** token** undefined
* 退fail-closed
*/
portal?: PortalIdentity;
}
const CODE_PREFIX = "oauth:code:";
+57 -13
View File
@@ -5,31 +5,75 @@
* AI CLI acr whoamiMCP rule 07 §5
* AI MCP curl
*
* MCP orgNamespace+ cypher binding
* 2026-08-12 ****display_name / role /
* account_namespace 西
* /kbdb/*portal-data.ts
* arcrun_* API key server
*
* MCP
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { toolName } from "../brand.js";
import { Env } from "../types.js";
import type { KnowledgeIdentity } from "../lib/portal-client.js";
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
export function registerWhoami(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
server.tool(
toolName("whoami"),
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
{},
async () => {
// 薄殼:MCP 透過 service bindingCYPHER_EXECUTOR)連 cypherbinding 本身決定連哪台;
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
const identity = {
account_namespace: orgNamespace || "(未設)",
const base = {
cypher: "service-binding:CYPHER_EXECUTOR",
kbdb: "service-binding:KBDB",
note:
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
};
return {
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
};
if (identity.kind === "portal") {
const { display_name, role, libraries } = identity.portal;
return json({
...base,
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
logged_in_as: display_name || "(未設顯示名稱)",
role,
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
knowledge_scope:
libraries.includes("*")
? "全部知識庫(此帳號有全庫權限)"
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
note:
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
});
}
if (identity.kind === "stale") {
return json({
...base,
auth: "舊版 token(沒有登入者身分)",
knowledge_scope: "查不到任何知識內容",
note:
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
});
}
return json({
...base,
auth: "service tokenstatic token / partner key,代表整個實例或租戶,不是某個人)",
account_namespace: orgNamespace || "(未設)",
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
});
},
);
}
function json(obj: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
}
+148 -59
View File
@@ -1,23 +1,27 @@
/**
* KBDB MCP kbdb-base Phase 9.1HANDOFF §2
*
* rule 07 §5 APIMCP +
* kbdbFetchKBDB service binding HTTP APIkbdb/src/routes/*
* rule 07 §5 APIMCP +
*
* 2026-08-12
* leo Portal 西AI
* MCP AI西
* MCP
*
* MCP ****
* KBDB_INTERNAL_TOKEN KBDB
*
* identity.kind === 'portal' portal session cypher
* `/portal/data/*` server portal
* ****MCP
*
* static token / partner keyidentity.kind === 'service' KBDB
*
*
* KBDB leo 2026-06-14 DECISION-kbdb-v3-baseplane.md
* - ** / SQL tool**
* - AI templatename+slots+ recordslotcontent
* Supabase schema template/slot CREATE TABLE
* - 調 HTTP API D1 SQL
*
* API kbdb/src/routes
* POST /templates { name, slots[], description?, created_by? } { template }
* GET /templates { templates[], count }
* GET /templates/:idOrName { template }
* POST /records { template, values:{slot:content}, owner_id? } { record }
* GET /records/by-template/:t ?owner_id= { records[], count }
* GET /records/:recordId { record }
* GET /entries/search ?q=&owner_id= { entries[], count, mode:'keyword' }
* - AI templatename+slots+ recordslotcontent
* - 調 HTTP API D1 SQL
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -25,22 +29,32 @@ import { z } from "zod";
import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */
const OWNER_IGNORED_HINT =
"owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)";
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
registerCreateTemplate(server, env);
registerListTemplates(server, env);
registerCreateRecord(server, env);
registerGetRecord(server, env);
registerQuery(server, env);
registerSearch(server, env);
export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerCreateTemplate(server, env, identity);
registerListTemplates(server, env, identity);
registerCreateRecord(server, env, identity);
registerGetRecord(server, env, identity);
registerQuery(server, env, identity);
registerSearch(server, env, identity);
}
/**
* kbdb_create_template template= /
* AI API template + slots
*/
export function registerCreateTemplate(server: McpServer, env: Env) {
export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_create_template",
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
@@ -50,16 +64,24 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
created_by: z.string().optional().describe("建立者標記(選填)"),
created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值"),
},
async ({ name, slots, description, created_by }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slots, description, created_by }),
});
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/templates", {
method: "POST",
body: { name, slots, description },
})
: await kbdbFetch(env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slots, description, created_by }),
});
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `建 template「${name}`);
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
}
const data = await res.json();
@@ -74,17 +96,28 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
}
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
export function registerListTemplates(server: McpServer, env: Env) {
export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_list_templates",
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
{},
async () => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/templates");
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/templates")
: await kbdbFetch(env, "/templates");
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "列 template");
return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
return successResponse(data, [
"每個 template 的 slots_json 是它的欄位清單",
"填資料用 kbdb_create_record",
"template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -93,7 +126,7 @@ export function registerListTemplates(server: McpServer, env: Env) {
}
/** kbdb_create_record — 依某 template 填一筆 recordslot → 內容)。 */
export function registerCreateRecord(server: McpServer, env: Env) {
export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_create_record",
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容}slot 名要對得上 template 的 slots。" +
@@ -101,23 +134,34 @@ export function registerCreateRecord(server: McpServer, env: Env) {
{
template: z.string().min(1).describe("template 的 name 或 id"),
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id"),
owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值"),
},
async ({ template, values, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id }),
});
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/records", {
method: "POST",
body: { template, values },
})
: await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id }),
});
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `填 recordtemplate「${template}」)`);
return errorResponse("create_record_failed", `填 record 失敗`, [
`確認 template「${template}」存在(kbdb_list_templates`,
"values 的 slot 名要對得上 template 的 slots",
], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
return successResponse(data, [
`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`,
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -126,7 +170,7 @@ export function registerCreateRecord(server: McpServer, env: Env) {
}
/** kbdb_get_record — 用 record_id 取單筆 record。 */
export function registerGetRecord(server: McpServer, env: Env) {
export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_get_record",
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
@@ -134,10 +178,23 @@ export function registerGetRecord(server: McpServer, env: Env) {
record_id: z.string().min(1).describe("record 的 idrec_xxx"),
},
async ({ record_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`)
: await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
if (res.status === 404) {
// 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。
return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [
"確認 record_id 正確",
"用 kbdb_query 列出某 template 的 record 取 id",
]);
}
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "取 record");
return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data);
} catch (e) {
@@ -148,21 +205,39 @@ export function registerGetRecord(server: McpServer, env: Env) {
}
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
export function registerQuery(server: McpServer, env: Env) {
export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_query",
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
{
template: z.string().min(1).describe("template 的 name 或 id"),
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定"),
},
async ({ template, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
const res = await kbdbFetch(env, path);
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(
env,
identity.portal.session,
`/portal/data/records/by-template/${encodeURIComponent(template)}`,
)
: await kbdbFetch(
env,
`/records/by-template/${encodeURIComponent(template)}` +
(owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""),
);
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`);
return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
return successResponse(data, [
"用 kbdb_get_record(record_id) 取單筆全文",
"按關鍵字找內容改用 kbdb_search",
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -175,26 +250,38 @@ export function registerQuery(server: McpServer, env: Env) {
* / KBDB MCP RAGissue #7 / D17
* mode=semantic vectorize base keyword + capability_hint CC
*/
export function registerSearch(server: McpServer, env: Env) {
export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_search",
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'AI 向量語義搜尋," +
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
{
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定"),
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize"),
},
async ({ q, owner_id, source, mode }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const qs = new URLSearchParams({ q });
if (owner_id) qs.set("owner_id", owner_id);
if (source) qs.set("source", source);
if (mode) qs.set("mode", mode);
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
const data = (await res.json()) as { mode?: string; capability_hint?: string };
let res: Response;
if (identity.kind === "portal") {
// /portal/data/search 只吃在權限範圍內「再收窄」的 filterowner_id/library 由 server 定死。
res = await portalFetch(env, identity.portal.session, "/portal/data/search", {
query: { q, mode },
});
} else {
const qs = new URLSearchParams({ q });
if (owner_id) qs.set("owner_id", owner_id);
if (source) qs.set("source", source);
if (mode) qs.set("mode", mode);
res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
}
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "搜尋");
return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string };
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
const hints =
data.capability_hint
@@ -202,6 +289,8 @@ export function registerSearch(server: McpServer, env: Env) {
: data.mode === "semantic"
? ["mode:semantic = AI 向量語義搜尋"]
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize"];
if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT);
if (data.note) hints.push(data.note);
return successResponse(data, hints);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
+60 -7
View File
@@ -23,6 +23,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
@@ -35,8 +41,13 @@ const INSTALL_HINTS = [
];
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) {
registerGraphNeighbors(server, env, orgNamespace);
export function registerAllKbdbGraphTools(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
registerGraphNeighbors(server, env, orgNamespace, identity);
// graph_traverserepo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
}
@@ -45,7 +56,12 @@ export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamesp
* kbdb_graph_neighbors knowledge graph 1-hop/N-hop
* 調 GET /q/{ns}/graph_neighbors MCP client
*/
export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) {
export function registerGraphNeighbors(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
server.tool(
"kbdb_graph_neighbors",
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
@@ -60,10 +76,10 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
depth: z.number().int().min(1).max(10).optional().describe(
"最大跳數(N-hop),預設 1(只看直接鄰居)",
),
kbdb_base: z.string().min(1).describe(
"你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " +
"或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" +
"帶錯(或照抄別人的值)=查詢打進別人的庫",
kbdb_base: z.string().min(1).optional().describe(
"【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL" +
"以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" +
"(指定了也不會採用)。只有服務級 tokenstatic token / partner key)連線時才需要填。",
),
template: z.string().optional().describe(
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
@@ -74,6 +90,43 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
),
},
async ({ subject, depth, kbdb_base, template, directed }) => {
if (identity.kind === "stale") return staleIdentityError();
// ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)──
// 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好
// 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。
// ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。
if (identity.kind === "portal") {
try {
const res = await portalFetch(
env,
identity.portal.session,
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
{ query: { depth: depth ?? 1 } },
);
if (!res.ok) return portalError(res, `查「${subject}」的鄰居`);
const out = (await res.json().catch(() => null)) as
| { neighbors?: unknown[]; edges?: unknown[]; count?: number }
| null;
return successResponse(out, [
`${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1}`,
"count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄",
"找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record",
"查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
}
// ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ──
if (!kbdb_base) {
return errorResponse(
"kbdb_base_required",
"以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL",
["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"],
);
}
if (!orgNamespace) {
return errorResponse(
"no_namespace",
+36 -11
View File
@@ -22,6 +22,12 @@ import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/**
* /404
@@ -39,8 +45,8 @@ const RECOMPUTE_HINTS = [
];
/** 註冊全部藏書地圖工具(library-map M4)。 */
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
registerGetMap(server, env);
export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerGetMap(server, env, identity);
}
/** 單庫詳圖回傳形狀(GET /map/:library 的 mapslot 陣列已 parse 成物件)。 */
@@ -62,7 +68,7 @@ interface LibraryMapDetail {
* kbdb_get_map library
* design §6 retrieval get_map(library) graph/search
*/
export function registerGetMap(server: McpServer, env: Env) {
export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_get_map",
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities" +
@@ -73,15 +79,26 @@ export function registerGetMap(server: McpServer, env: Env) {
library: z.string().min(1).optional().describe(
"庫名(如 'kb''notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
),
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
owner_id: z.string().optional().describe(
"限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)",
),
},
async ({ library, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
// 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫
//KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。
const isPortal = identity.kind === "portal";
const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
const mapFetch = (path: string) =>
identity.kind === "portal"
? portalFetch(env, identity.portal.session, `/portal/data${path}`)
: kbdbFetch(env, path);
if (!library) {
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count)。
const res = await kbdbFetch(env, `/map${qs}`);
const res = await mapFetch(`/map${qs}`);
if (!res.ok && isPortal) return portalError(res, "取全館地圖");
if (!res.ok) {
return errorResponse(
"map_fetch_failed",
@@ -90,7 +107,7 @@ export function registerGetMap(server: McpServer, env: Env) {
await res.text().catch(() => ""),
);
}
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number; note?: string };
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
...l,
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
@@ -101,8 +118,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
// 不再是「沒人跑過 recompute」那種曖昧狀態。
// 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」
// 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。
return successResponse({ libraries: [], count: 0 }, [
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
isPortal
? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫"
: "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
...(data.note ? [data.note] : []),
...RECOMPUTE_HINTS,
]);
}
@@ -113,7 +135,7 @@ export function registerGetMap(server: McpServer, env: Env) {
}
// 單庫詳圖:完整 slotsslot 陣列 parse 成物件再回)。
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
if (res.status === 404) {
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
@@ -121,10 +143,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
return errorResponse(
"map_not_found",
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
isPortal
? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)`
: `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS],
);
}
if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`);
if (!res.ok) {
return errorResponse(
"map_fetch_failed",
+14 -5
View File
@@ -20,8 +20,15 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js";
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
import { registerAllKbdbMapTools } from "./kbdb_map.js";
import { registerWhoami } from "./arcrun_whoami.js";
import type { KnowledgeIdentity } from "../lib/portal-client.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
export function registerAllTools(
server: McpServer,
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
) {
registerSearchComponents(server, env, orgNamespace);
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
// 零件貢獻**只有一條路=PR 人審**leo 2026-08-01:「已經沒有 publish 了,
@@ -53,13 +60,15 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
registerAllRecipeTools(server, env);
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2
// 鐵律:不提供建表/SQL toolAI 只有 template+slot 可用(類 Supabase 萬用表)
registerAllKbdbDataTools(server, env);
// 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面
// (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。
registerAllKbdbDataTools(server, env, identity);
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
registerAllKbdbGraphTools(server, env, orgNamespace);
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
// library-map SDD M4Arcrun#39: 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map/map/:library
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6
registerAllKbdbMapTools(server, env);
registerAllKbdbMapTools(server, env, identity);
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
registerWhoami(server, env, orgNamespace);
registerWhoami(server, env, orgNamespace, identity);
}
+18 -4
View File
@@ -2,6 +2,15 @@ export interface Env {
COMPONENT_REGISTRY: Fetcher;
CYPHER_EXECUTOR: Fetcher;
KBDB: Fetcher;
/**
* KBDB
*
* 2026-08-12 **kbdb_*** cypher
* `/portal/data/*` portal session
* SaaS partner-key middleware/partner-auth.ts 3
* tokenstatic token KBDB
* binding MCP
*/
KBDB_INTERNAL_TOKEN: string;
API_KEY?: string;
// Platform telemetry / feedback aggregation key (optional)
@@ -20,11 +29,16 @@ export interface Env {
// 短效認證儲存:authorization codeTTL ~600s+ access tokenTTL = MCP_TOKEN_TTL)。
// 只放「取得的暫時性認證」,key 用 SHA-256 hashKV list 不外洩可用 token)。長效機密不進 KV。
OAUTH_KV?: KVNamespace;
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)
// 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」——
// 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回),
// 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉
MCP_OWNER_SECRET?: string;
// OAuth 換發出的 access_token 綁定的 namespaceowner 的資料分區)。預設 "leo"。
// **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。
//
// ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session
// 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入
// 都看到同一格、而且是全部——那個用法已經消滅。
// 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。
MCP_OWNER_NAMESPACE?: string;
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 259200030 天)。
// 過期後 claude.ai 重走 OAuthowner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
+202 -12
View File
@@ -59,14 +59,55 @@ async function pkcePair() {
return { verifier, challenge };
}
/**
* cypher `/portal/login` 2026-08-12 MCP 使
* Portal owner secret session_token 401
*/
const GOOD_EMAIL = "leo@example.com";
const GOOD_PASSWORD = "correct horse";
function cypherMock(
over: {
/** null 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
sessionToken?: string | null;
displayName?: string;
role?: string;
libraries?: string[];
sessionExpiresIn?: number;
} = {},
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
const calls: Array<{ email: string; password: string }> = [];
const fetcher = {
async fetch(req: Request) {
const body = (await req.json()) as { email: string; password: string };
calls.push(body);
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
}
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
return new Response(
JSON.stringify({
success: true,
...(sessionToken ? { session_token: sessionToken } : {}),
display_name: over.displayName ?? "Leo",
role: over.role ?? "admin",
libraries: over.libraries ?? ["*"],
session_expires_in: over.sessionExpiresIn ?? 604800,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
} as unknown as Fetcher;
return { fetcher, calls };
}
function baseEnv(over: Partial<Env> = {}): Env {
return {
COMPONENT_REGISTRY: {} as Fetcher,
CYPHER_EXECUTOR: {} as Fetcher,
CYPHER_EXECUTOR: cypherMock().fetcher,
KBDB: {} as Fetcher,
KBDB_INTERNAL_TOKEN: "internal",
OAUTH_KV: makeKV(),
MCP_OWNER_SECRET: "s3cr3t-owner",
MCP_OWNER_NAMESPACE: "leo",
...over,
} as Env;
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
scope: "mcp",
resource: "https://mcp/mcp",
namespace: "leo",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
portal_session_expires_in: 604800,
});
const first = await consumeAuthCode(kv, "code-1");
expect(first?.namespace).toBe("leo");
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
);
expect(ok.status).toBe(200);
expect(await ok.text()).toContain("Owner 祕密");
const consentHtml = await ok.text();
// 同意頁問的是 Portal 帳密(不是另一把 owner secret
expect(consentHtml).toContain("Portal");
expect(consentHtml).toContain('name="email"');
expect(consentHtml).toContain('name="password"');
// 缺 PKCE → 400
const bad = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
expect(bad.status).toBe(400);
});
it("GET /authorizeMCP_OWNER_SECRET 未設 → 503(不留不安全預設", async () => {
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
it("GET /authorize不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁", async () => {
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}&code_challenge=${challenge}&code_challenge_method=S256`,
);
expect(r.status).toBe(503);
expect(r.status).toBe(200);
});
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
code_challenge_method: "S256",
scope: "mcp",
resource: "https://mcp.arcrun.dev/mcp",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
});
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const authRes = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
const tokRes = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.portal?.session).toBe("sess-abc");
expect(at?.portal?.display_name).toBe("小明");
expect(at?.portal?.role).toBe("user");
expect(at?.portal?.libraries).toEqual(["kb"]);
});
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
async function tokenFor(env: Env) {
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
}
const a = await tokenFor(envA);
const b = await tokenFor(envB);
expect(a?.portal?.session).not.toBe(b?.portal?.session);
expect(a?.portal?.libraries).toEqual(["*"]);
expect(b?.portal?.libraries).toEqual(["kb"]);
});
it("access_token 活不過它底下的 portal sessionTTL 取兩者較小)", async () => {
const env = baseEnv({
MCP_TOKEN_TTL: "2592000", // 30 天
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
});
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
expect((await t.json()).expires_in).toBe(3600);
});
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
const { challenge } = await pkcePair();
const r = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
expect(r.status).toBe(401);
expect(r.headers.get("location")).toBeNull();
});
});
it("錯誤 owner 祕密 → 401、不發 code", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "WRONG",
email: GOOD_EMAIL,
password: "WRONG",
}).toString(),
redirect: "manual",
});
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -445,7 +633,8 @@ describe("oauth resourceRFC 8707)簽發端把關", () => {
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -570,7 +759,8 @@ describe("oauth store drift guardOAUTH_KV 的 put 一律帶 TTL", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -0,0 +1,208 @@
/**
* kbdb_* ****2026-08-12
*
* leo Portal 西AI
* MCP AI西
* MCP
*
*
* ** portal session** cypher `/portal/data/*`
* KBDB KBDB
* owner_id ****
* token**fail-closed**退
* static token / partner key KBDB
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
const server = {
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
tools.set(name, { description, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
cypherCalls.push({ url, init });
return respond("cypher", url, init);
},
},
KBDB: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
kbdbCalls.push({ url, init });
return respond("kbdb", url, init);
},
},
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
} as unknown as Env;
return { env, cypherCalls, kbdbCalls };
}
function parseResult(r: { content: { text: string }[] }) {
return JSON.parse(r.content[0].text) as Record<string, unknown>;
}
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
const SERVICE: KnowledgeIdentity = { kind: "service" };
const STALE: KnowledgeIdentity = { kind: "stale" };
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
const { server, tools } = makeServer();
const e = makeEnv(respond);
registerAllKbdbDataTools(server, e.env, identity);
return { tools, ...e };
}
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
];
for (const c of cases) {
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
const res = await t.get(c.tool)!.handler(c.args);
expect(res.isError).toBeUndefined();
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
expect(cypherCalls).toHaveLength(1);
expect(cypherCalls[0].url.pathname).toBe(c.path);
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
// 帶的是「那個人的 session」,不是任何服務金鑰
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
expect(auth).toBe("Bearer sess-abc");
expect(auth).not.toContain("service-key");
});
}
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
for (const call of cypherCalls) {
expect(call.url.searchParams.get("owner_id")).toBeNull();
}
});
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_create_record")!.handler({
template: "contact",
values: { name: "Leo" },
owner_id: "someone-else",
});
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
expect(body).not.toHaveProperty("owner_id");
});
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
);
const res = await t.get("kbdb_create_record")!.handler({
template: "note",
values: { library: "secret", body: "x" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
);
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("not_found");
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
});
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
const res = await t.get("kbdb_search")!.handler({ q: "x" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
});
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
for (const name of [
"kbdb_search",
"kbdb_query",
"kbdb_get_record",
"kbdb_list_templates",
"kbdb_create_template",
"kbdb_create_record",
]) {
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
const res = await t.get(name)!.handler({
q: "x",
template: "t",
record_id: "r",
name: "n",
slots: ["a"],
values: { a: "b" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(0);
});
}
});
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
expect(res.isError).toBeUndefined();
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(1);
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
await t.get("kbdb_query")!.handler({ template: "triplet" });
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
"/records/by-template/triplet",
"/records/rec_1",
]);
});
});
+92 -7
View File
@@ -5,6 +5,17 @@ import {
registerGraphNeighbors,
GRAPH_NEIGHBORS_WORKFLOW,
} from "../../../src/tools/kbdb_graph.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => {
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
});
@@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "Arcrun",
depth: 2,
@@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
status: 200,
}),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "");
registerGraphNeighbors(server, env, "", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
expect(calls).toHaveLength(0);
});
});
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() =>
new Response(
JSON.stringify({
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
edges: [],
count: 1,
}),
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
expect(calls[0].url.searchParams.get("depth")).toBe("2");
const headers = new Headers(calls[0].init!.headers as HeadersInit);
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
expect(res.isError).toBeUndefined();
expect((parseResult(res).data as { count: number }).count).toBe(1);
});
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
});
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo", STALE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed
});
});
+135 -17
View File
@@ -7,6 +7,32 @@ import {
renderLibraryMapLines,
__resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 CYPHER_EXECUTOR bindingportal 資料面用)。 */
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
} as unknown as Env;
return { env, calls };
}
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
expect(tools.has("kbdb_get_map")).toBe(true);
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(calls).toHaveLength(1);
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const data = parseResult(res).data as {
libraries: { top_entities: string[]; triplet_count: number }[];
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
expect(body.ok).toBe(true);
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const hintsText = JSON.stringify(body.hints);
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(calls[0].url.pathname).toBe("/map/kb");
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
triplet_count: "111",
};
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
},
},
} as unknown as Env;
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("internal_error");
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
}),
),
);
const text = await buildLibraryMapInstructions(env);
const text = await buildLibraryMapInstructions(env, SERVICE);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("binding throws → null(靜默略過)", async () => {
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
},
},
} as unknown as Env;
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env);
const second = await buildLibraryMapInstructions(env);
const first = await buildLibraryMapInstructions(env, SERVICE);
const second = await buildLibraryMapInstructions(env, SERVICE);
expect(second).toBe(first);
expect(calls).toHaveLength(1);
});
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
// 一個只有部分權限的帳號。
describe("藏書地圖:登入身分(portal 資料面)", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/map");
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
});
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
});
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(String(body.human_message)).toContain("不在你被授權");
});
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(() => new Response("{}"));
registerGetMap(server, env, STALE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0);
});
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const text = await buildLibraryMapInstructions(env, PORTAL);
expect(text).toContain("kb");
expect(calls[0].url.pathname).toBe("/portal/data/map");
});
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const other: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
};
await buildLibraryMapInstructions(env, PORTAL);
await buildLibraryMapInstructions(env, other);
expect(calls).toHaveLength(2); // 兩次真的各打一次
await buildLibraryMapInstructions(env, PORTAL);
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
});
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
const { env, calls } = makePortalEnv(() => new Response("{}"));
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
expect(calls).toHaveLength(0);
});
});
+19 -1
View File
@@ -1,5 +1,23 @@
// wait — 等待指定毫秒數後繼續(最多 30 秒)
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
//
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#1012026-08-12)。
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait']
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
//
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
//
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
//
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
// rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
package main
import (