Compare commits

..

7 Commits

Author SHA1 Message Date
uncle6me-web c2897ba68b fix(mcp): AI 一連上就知道「這裡有主人的知識庫」+「讀不到」不再講成「不存在」
leo 2026-08-13:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,
從你對 Arcrun 的認知就知道你的內建 Memory 是沒用的。」

## 病灶一:指路的話被綁在一個會無聲消失的段落上

instructions 裡唯一提到「這裡有知識可查」的,是 buildLibraryMapInstructions
拼上去的【藏書地圖】——而那段是**選配的**(1500ms 逾時、catch 回 null、失敗無聲)。
必定出現的那段靜態文字(startHere)從頭到尾只講工作流、零件、recipe、邊的語法,
`kbdb_search`/`kbdb_get_map` 一個字都沒有。

實害:同一天,一條 portal 連線的 session 為了回答「Arcrun 是什麼」去讀了 682 行
原始碼——而 `kbdb_search(q="Arcrun 是什麼")` 當下回得出 33 筆真答案。

修法:
- 新增 `KNOWLEDGE_FIRST` 靜態段(與 startHere 同級,KBDB 掛了照樣出現),
  排在最前面:這條連線後面有主人的知識庫、第一個動作是 kbdb_search、
  工具怎麼呼叫、以及「沒查到/沒查/地圖沒取到」三件事不可以講成同一句。
- 地圖失敗**不再靜默**:拿不到就印【藏書地圖:這次沒取到】,
  stale token 另印身分版(要使用者重新連線)——「沒取到」與「這裡沒有知識」
  不可以長得一樣(Arcrun#109 同族)。鐵律不變:失敗仍然不擋連線。
- 抽出 `buildServerInstructions(env, identity)` 供測試印出完整字串。

## 病灶二:一個 401 被逐字翻譯成「不存在」

`arcrun_get_skill('write_intent_workflow')` 回「skill ... 不存在」,
但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
(entry_type: agent-skill、source: installer-seed)——**與本工具查的鍵逐字相符**。
真兇是 `kbdbGetByPageName` 的 `if (!resp.ok) return null;`。
而 401 的來源是該實例的 arcrun-mcp 沒有 KBDB_INTERNAL_TOKEN
(kbdb-client 沒 token 就匿名送出)=部署面的事,但這支 code 把它講成了假話。

修法:
- 非 2xx 一律拋 KbdbAccessError(帶真 status)→ kbdb_unauthorized/kbdb_unreachable,
  訊息明說「這是讀不到,不是不存在」,並交出還走得通的那條路(kbdb_search)。
- `not_found` 只留給「KBDB 正常回應但真的沒這張卡」,並說明是「這台實例沒 seed」。
- 五支工具吃 identity:stale → identity_missing;portal 撞 401 時額外說明
  「是這批工具還走服務憑據,不是你的帳號讀不到」。
- instructions 步驟 4 不再指名 `arcrun_get_skill('INDEX')`(leo21c 實測根本沒 seed
  這支)——改成先 `arcrun_list_skills()` 看這台實例真的有哪幾支。

## 驗

- `mcp` 測試 130 passed(新增 mcp-instructions 7 項+skills-examples-honesty 10 項),tsc exit 0。
- 三種情境(地圖抓得到/抓不到/舊 token)各印一份完整 instructions 實測,
  三份的開場都指向 kbdb_search。

⚠️ 未部署(部署是 leo 的手動閘)。上線後才會到用戶手上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:09:35 +08:00
uncle6me-web 9ff933ba98 chore(builds): 重編成品——把 #108 的租戶修法放進執行檔
cypher source 10d150a → b223a698(#108 併入)。
重編時新的租戶來源閘第一行就報:
  ✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts

⇒ 那道閘現在長在建置線上:以後有人再寫 env.XXX || 'leo',成品編不出來。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:25:46 +08:00
Leo 2fcae722e7 Merge PR #112: 藏書地圖看得到自己的知識——租戶字串改從寫入端來(Arcrun#108)
總管複驗(不聽自述,自己重跑並與 base 逐條比對):
  cypher  14 failed / 441 passed(base 14 / 400)⇒ 多 41 條新測試全過
          失敗清單 diff 無輸出=逐字元相同、零回歸
  閘實測  照 #105/#108 的原句形狀種一個違規(env.CONSOLE_TENANT || leo)
          → 掃描器當場抓到並指名道姓;移除後恢復綠 ⇒ 不是假綠

這道閘的價值超過本票:它擋的是「身分來自環境變數」整族,
#105 那句話今天再寫一次也會被擋。符合 leo 08-12 立的
「做平台要減少 hotfix」——修掉 bug 不算完成,要留下下次再犯會被擋住的機制。

agent 誠實標的殘項(不擋併):
  · kbdb_get_map 回 1851、Portal 總圖畫面、部分授權帳號實測——都要部署 leo21c,紅線沒碰
  · 想把閘也接進 .claude/hooks/pre-write-guard.sh(寫入前就擋),該檔受保護改不動;
    檢查器已備妥 --stdin 模式,總管代接
2026-08-12 16:24:45 +00:00
uncle6me-web cac874601f chore(builds): 重編 tier2 成品——把 #108 放進執行檔(出貨路徑 A 的第 0 步)
原始碼修好不等於使用者拿得到:`.worker-builds/` 是安裝器與 `acr update`
實際部署的那份執行檔。本次一併重編,repo_dirty=false、repo_head=b223a698。

  arcrun-cypher-executor  sha256=e0026a23792f  source=b223a698 ← #108
  arcrun-kbdb             sha256=9c6d41895d78  source=f87d0e92(未變)
  arcrun-http-request     sha256=9a9dcb71879a  source=1e85dfb4(未變)
  arcrun-code             sha256=285a7406ec69  source=621cb8d9(未變)
  arcrun-mcp              sha256=3ebc0d441bc0  source=10d150ac(未變)

其餘四顆的 js 位元組有變是 esbuild 版本/相依重裝造成的重編,source commit 未動。
本次起這條路徑多一道閘:編成品前先跑租戶來源檢查,違規直接「建置中止」
(見同 PR 的 scripts/build-worker-artifacts.mjs)——實跑輸出貼在 PR。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:16:55 +08:00
uncle6me-web b223a69884 fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:58 +08:00
uncle6me-web fad5da0e17 rules/07 §3.6:自舉例外——能力只實作一次,但不一定要是 HTTP API
代補 PR #111 的 agent 想加但改不動的段落(該檔受保護)。

立這條的原因不是理論:#97 的修法一開始寫在 cli/src/lib/resource-resolver.ts
(能力住在介面層,違反 §0)⇒ 安裝器拿不到它 ⇒ 同一個 bug 只修了一半,
走 acr 的人有保護、走 install.arcrun.dev 的人沒有——而所有真實用戶走後者。

leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

記三件,都是為了不讓下一個人「修正」回去:
① 為什麼不放 cypher API(自舉/輸入是使用者自己的帳號狀態/它是純函式)
② §0 的正確讀法是「只准有一份、不准住在單一介面裡」,放 API 只是常見手段
③ npm 打包例外:cli/ 下的逐位元組副本由 sync --check 機械擋漂移,不是第二份實作

📍 repo:matrix/arcrun(shared/resource-rule/、cli/、.claude/rules/07)
   + products/arcrun-rag(安裝器待接上,已派工)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:50:53 +08:00
Leo 13155a1d7b Merge PR #111: 把「該用哪些資源」搬出 CLI——一份實作,兩條路吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

總管複驗(不聽自述,自己重跑):
  兩份 rule.mjs 逐位元組相同;--check 綠
  故意加一行製造漂移 → --check 當場擋下;還原後恢復綠 ⇒ 閘是真的,不是假綠
  cli 測試 58 pass / 0 fail(main 上是 49 ⇒ 多 9 條全過)
  邊界乾淨:沒動 cypher/kbdb/mcp;resource-resolver.ts 減 461 行變薄殼

兩條新測試比我寫的收工標準更嚴:
  two-paths-agree 斷言「一邊停手、一邊照做 = 最危險的分歧」與「停手的理由也要一樣」
  single-implementation 帶 assert.ok(files.length > 0) 防「檢查 0 個卻通過」
  ——後者正是總管當晚剛撞過的假綠形狀

殘項(agent 誠實標的,不擋併):
  · youlin 上走安裝器那條路的實測沒跑成——安裝器在 arcrun-rag,不在它的工作副本裡,
    且沙箱無 CF 憑證。它依紅線「不確定時寧可中止」沒有猜著跑。
  · 安裝器那一行 import 尚未接上(規格在 shared/resource-rule/README.md §4)
  · rules/07 想補「自舉例外」正例,該檔受保護,內容留在 README §2 待總管代補
2026-08-12 15:44:50 +00:00
27 changed files with 2005 additions and 156 deletions
+31
View File
@@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/`
---
---
## 第六類:租戶字串來源(Arcrun#108#105 同族)
### 6.1 靜態租戶字串不得用於資料面過濾
**知識資料面的 `owner_id`(三元組/entriesrecords/藏書地圖/工作流 KV)必須與寫入端同源。**
寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml``api_key`(=實例 namespace
CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。
實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫**
#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。
**規則**
1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。
`CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。
2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經
`ownerQuery()` / `ownerField()`——它們只吃 `TenantId``tsc` 就擋掉「隨手一個 string」。
3. 帳號層用 `accountTenant(env)`(回 `string`**刻意不是 TenantId**):帳號子 namespace
`{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。
4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`
誠實回「讀不到」(不是「你沒有」,#100 同一條)。
**機械強制**(規則存在但沒機制驗證=它會再犯第三次):
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**
- 本機自查:`cd cypher-executor && npm run check:tenant``npm test` 也會先跑它)。
- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式);
閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。
> 尚未接上 PreToolUse hook`.claude/hooks/` 為受保護檔案,需人類加入)。
> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。
## Hook Block 訊息格式
當 hook 擋住一個操作時,訊息格式統一為:
+32
View File
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
---
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API2026-08-12 立)
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
| 為什麼不放 API | 說明 |
|---|---|
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
「放 API」是達成它的**常見手段**,不是唯一手段。
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
📌 **打包例外**`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build``test` 都會先跑它
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 publish 擋下。**
---
## 4. 統一帳號來源(薄殼共用同一身份)
所有薄殼讀**同一份**身份設定:
+137 -46
View File
@@ -12557,6 +12557,45 @@ init_kbdb_proxy();
// cypher-executor/src/routes/console-auth.ts
init_dist();
// cypher-executor/src/lib/tenant.ts
var TenantUnresolvedError = class extends Error {
constructor(message) {
super(message);
this.name = "TenantUnresolvedError";
}
};
function knowledgeOwner(env) {
const injected = (env.ARCRUN_NAMESPACE ?? "").trim();
if (injected) return injected;
const legacy = (env.CONSOLE_TENANT ?? "").trim();
if (legacy) return legacy;
throw new TenantUnresolvedError(
"\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002"
);
}
function tenantFromApiKey(apiKey) {
const key = (apiKey ?? "").trim();
if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D");
return key;
}
function accountTenant(env) {
return env.CONSOLE_TENANT || "leo";
}
function ownerQuery(tenant2) {
return `owner_id=${encodeURIComponent(tenant2)}`;
}
function ownerField(tenant2) {
return tenant2;
}
function censusQueryAllTenants() {
return "owner_id=";
}
function isOwnedBy(value, tenant2) {
return typeof value === "string" && value === tenant2;
}
// cypher-executor/src/routes/console-auth.ts
var consoleAuthRouter = new Hono2();
var CREDS_KEY = "console:credentials";
var SESSION_PREFIX = "console_sess:";
@@ -12583,7 +12622,7 @@ async function hashPassword(password, salt) {
return h;
}
function tenantOf(c) {
return c.env.CONSOLE_TENANT || "leo";
return knowledgeOwner(c.env);
}
async function loadCredentials(env) {
let fromStore = readAuthStore(env).console;
@@ -12854,10 +12893,10 @@ var DEFAULT_SESSION_TTL = 604800;
var USER_TEMPLATE = "portal_user";
var LIBRARY_TEMPLATE = "portal_library";
function portalTenant(env) {
return env.CONSOLE_TENANT || "leo";
return accountTenant(env);
}
function portalNamespace(env) {
return `${portalTenant(env)}::portal`;
return `${accountTenant(env)}::portal`;
}
function sessionTtl(env) {
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
@@ -12886,6 +12925,9 @@ async function run(c, fn) {
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502);
}
if (e instanceof TenantUnresolvedError) {
return c.json({ error: e.message, code: "tenant_unresolved" }, 500);
}
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
throw e;
}
@@ -13768,10 +13810,9 @@ portalRouter.post(
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
await clearLoginFail(c.env, email);
const tenant2 = portalTenant(c.env);
const daemonCfg = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant2,
namespace: knowledgeOwner(c.env),
library: "kb",
email,
instance_name: String(rec.values.display_name ?? "")
@@ -13787,7 +13828,7 @@ portalRouter.post(
const body = await c.req.json().catch(() => null);
const key = String(body?.key ?? "").trim();
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const kvKey2 = `${tenant2}:wf:rag_chat`;
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
@@ -13839,8 +13880,8 @@ portalRouter.get(
});
const known = new Set(out.map((l) => l.name));
try {
const tenant2 = portalTenant(c.env);
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
const tenant2 = knowledgeOwner(c.env);
const ownerParam = ownerQuery(tenant2);
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
@@ -13989,8 +14030,8 @@ portalRouter.get(
(c) => run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
const data = await res.json();
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
@@ -14006,10 +14047,10 @@ portalRouter.put(
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 400);
}
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, "/execution-log/retention", {
method: "PUT",
body: JSON.stringify({ owner_id: ownerId, retention_days: days === void 0 ? null : days })
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days })
});
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
const data = await res.json();
@@ -14026,10 +14067,10 @@ portalRouter.delete(
const confirm = String(body?.confirm ?? "").trim();
if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400);
if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 400);
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", {
method: "PATCH",
body: JSON.stringify({ owner_id: ownerId, library: name })
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name })
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`);
const data = await res.json();
@@ -14085,8 +14126,8 @@ async function buildDiagnostics(env, tenant2) {
let embedding = { checked: false };
try {
const [statusRes, selftestRes] = await Promise.all([
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
]);
const statusBody = await statusRes.json().catch(() => null);
const selftestBody = await selftestRes.json().catch(() => null);
@@ -14108,7 +14149,7 @@ async function buildDiagnostics(env, tenant2) {
}
let library_count = 0;
let triplet_count = 0;
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
const ownerParam = ownerQuery(tenant2);
try {
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
@@ -14132,7 +14173,7 @@ async function buildDiagnostics(env, tenant2) {
let library_scope_check = { ran: false };
if (library_count === 0 && triplet_count === 0) {
try {
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`);
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`);
const probeBody = await probeRes.json().catch(() => null);
const total = probeBody?.total ?? 0;
library_scope_check = {
@@ -14155,7 +14196,7 @@ portalRouter.get(
(c) => run(c, async () => {
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
const core = await buildDiagnostics(c.env, apiKey);
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
return c.json({
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
instance_url: new URL(c.req.url).origin,
@@ -14710,7 +14751,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
return { ...fresh, cache: "miss" };
}
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const now2 = Date.now();
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
@@ -14862,7 +14903,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
});
});
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const { base, headers } = kbdbBase(c.env);
const now2 = Date.now();
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
@@ -14897,7 +14938,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
consoleDashboardRouter.get("/console/triage-data", async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant2, "todo", 500),
fetchEntries(c.env, tenant2, "inbox", 200)
@@ -14912,7 +14953,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
const action = body?.action === "restore" ? "restore" : "check";
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const tenant2 = knowledgeOwner(c.env);
const { base, headers } = kbdbBase(c.env);
const got = await fetchJson(
`${base}/entries/${encodeURIComponent(entryId)}`,
@@ -14940,7 +14981,7 @@ init_kbdb_proxy();
init_webhook_handlers();
var portalDataRouter = new Hono2();
async function getTenantWorkflowGraph(env, name) {
const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text");
const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text");
if (!raw2) return null;
try {
const rec = JSON.parse(raw2);
@@ -15033,7 +15074,7 @@ function findBestNodeMatch(searchTerm, nodeNames) {
}
async function tripletCount(env, owner) {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.stats)) return null;
@@ -15050,11 +15091,11 @@ async function tripletCount(env, owner) {
async function tripletCensus(env, tenant2) {
const owned = await tripletCount(env, tenant2);
if (owned !== 0) return { owned, any: null };
return { owned, any: await tripletCount(env, "") };
return { owned, any: await tripletCount(env, null) };
}
async function fuzzyFindNode(env, tenant2, searchTerm) {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.records)) return null;
@@ -15082,7 +15123,7 @@ portalDataRouter.get(
if (libraries.length === 0) {
return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
if (!libraries.includes("*")) params.set("library", libraries.join(","));
if (c.req.query("mode") === "semantic") {
params.set("mode", "semantic");
@@ -15118,7 +15159,7 @@ portalDataRouter.get(
const body = await res.json();
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? "") !== portalTenant(c.env)) return notFound(c);
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
})
@@ -15133,7 +15174,7 @@ portalDataRouter.get(
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const nodeName = normalizeCjkQuery(c.req.param("name"));
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
if (wfGraph) {
const depthRaw = c.req.query("depth") ?? "";
@@ -15187,9 +15228,9 @@ portalDataRouter.get(
if (!await hasGraphAccess(c.env, libraries)) {
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant2)}&limit=500`),
tripletCensus(c.env, tenant2)
]);
const tripletsTotal = census.owned;
@@ -15260,7 +15301,7 @@ portalDataRouter.get(
wfGraph,
{ question },
"rag_chat",
portalTenant(c.env),
knowledgeOwner(c.env),
c.executionCtx
);
if (!result.success) {
@@ -15336,7 +15377,7 @@ portalDataRouter.get(
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const prefix = `${tenant2}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
@@ -15358,7 +15399,7 @@ portalDataRouter.get(
let last_execution = null;
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}`
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}`
);
const execBody = await execRes.json().catch(() => null);
if (execRes.ok && execBody?.success && execBody.execution) {
@@ -15375,7 +15416,7 @@ function recordLibrary(values) {
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
}
function canReadRecord(rec, tenant2, libraries) {
if ((rec.owner_id ?? "") !== tenant2) return false;
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
@@ -15386,9 +15427,17 @@ portalDataRouter.get(
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: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: true,
empty_reason: "no_library_grant",
note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002"
});
}
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
const tenant2 = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
@@ -15399,7 +15448,49 @@ portalDataRouter.get(
const allowed = body.libraries.filter(
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
if (allowed.length > 0) {
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
}
if (body.libraries.length > 0) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: true,
empty_reason: "filtered_out",
note: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002"
});
}
const census = await tripletCensus(c.env, tenant2);
if (census.owned === null || census.owned === 0 && census.any === null) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: false,
empty_reason: "unreadable",
note: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002"
});
}
if (census.owned === 0 && (census.any ?? 0) > 0) {
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: false,
empty_reason: "scope_mismatch",
instance_triplet_count: census.any,
note: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002`
});
}
return c.json({
success: true,
libraries: [],
count: 0,
empty_confirmed: true,
empty_reason: "confirmed_empty",
note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002"
});
})
);
portalDataRouter.get(
@@ -15412,7 +15503,7 @@ portalDataRouter.get(
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
@@ -15445,7 +15536,7 @@ portalDataRouter.post(
name: body.name,
slots: body.slots,
description: typeof body.description === "string" ? body.description : void 0,
created_by: portalTenant(c.env)
created_by: knowledgeOwner(c.env)
})
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
@@ -15458,10 +15549,10 @@ portalDataRouter.get(
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 tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?owner_id=${encodeURIComponent(tenant2)}`
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}`
);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
const body = await res.json().catch(() => null);
@@ -15485,7 +15576,7 @@ portalDataRouter.get(
const body = await res.json().catch(() => null);
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
})
);
@@ -15510,7 +15601,7 @@ portalDataRouter.post(
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) })
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) })
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
})
@@ -15520,7 +15611,7 @@ portalDataRouter.get(
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant2 = portalTenant(c.env);
const tenant2 = knowledgeOwner(c.env);
const core = await buildDiagnostics(c.env, tenant2);
return c.json({
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
+5 -5
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T14:22:56.880Z",
"repo_head": "21293568d550ab7ef50cec2020165d8bf4376104",
"generated_at": "2026-08-12T16:25:19.210Z",
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "53b05c6d3d4a5a02661880dd5565fa9feb743bb6",
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 584721,
"content_sha256": "b7c810d7654c05d197abe9a37acce2ed5f77e1af09595d5b4316b69166edf11a",
"js_bytes": 588587,
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
+27
View File
@@ -17,6 +17,7 @@ import { loadConfig } from '../lib/config.js';
import {
wranglerAvailable,
downloadAndDeploy,
namespaceHasKnowledge,
type DeployContext,
} from '../lib/deploy.js';
@@ -63,6 +64,32 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
kbdbEmbed: config.kbdb_embed !== false,
};
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
//
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
// repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
//
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
if (config.api_key && config.cypher_executor_url) {
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
if (hasKnowledge === true) {
ctx.knowledgeNamespace = config.api_key;
console.log(chalk.green(' ✓'));
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
} else if (hasKnowledge === false) {
console.log(chalk.yellow(' ⚠'));
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
} else {
console.log(chalk.yellow(' ⚠'));
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
}
}
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
//Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
+53
View File
@@ -298,6 +298,44 @@ export interface DeployContext {
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
// base 維持 LIKE keywordfree-tier 友善)。
kbdbEmbed?: boolean;
/**
* Arcrun#108`~/.arcrun/config.yaml` `api_key`
* cypher worker `ARCRUN_NAMESPACE` var owner_id
*
* ** namespace ** `resolveKnowledgeNamespace`
* worker preservedVars
*/
knowledgeNamespace?: string;
}
/**
* namespace Arcrun#108
*
* `GET /kbdb/map?owner_id=<ns>`cypher CLI
* + `X-Arcrun-API-Key`
* true namespace ARCRUN_NAMESPACE
* false
* null / /
*
*
*/
export async function namespaceHasKnowledge(
cypherUrl: string,
namespace: string,
): Promise<boolean | null> {
if (!cypherUrl || !namespace) return null;
try {
const res = await fetch(
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
{ headers: { 'X-Arcrun-API-Key': namespace } },
);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
if (!body || !Array.isArray(body.libraries)) return null;
return body.libraries.length > 0;
} catch {
return null;
}
}
/**
@@ -561,6 +599,21 @@ export async function downloadAndDeploy(
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
}
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
//
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
//
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
}
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
}
if (preservedTotal.length > 0) {
+170
View File
@@ -0,0 +1,170 @@
/**
* Arcrun#108
*
* 2026-08-12 leo 0 1854
* push MCP `~/.arcrun/config.yaml` `api_key`
* leo `bfezv28v` cypher owner_id worker
* repo toml ** prod ** `CONSOLE_TENANT = "leo"`
* A B
*
*
* · **** `ARCRUN_NAMESPACE`
* · / ****
* CONSOLE_TENANT
* #97/#106 西 bug
*
* wrangler.toml + render fetch
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
renderWranglerToml,
preservedVars,
namespaceHasKnowledge,
VERSION_STAMP_WORKER,
type DeployContext,
} from '../src/lib/deploy.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
const LEO_NS = 'bfezv28v';
const CTX: DeployContext = {
accountId: 'acc-user-123',
apiToken: 'token',
workerSubdomain: 'user-sub',
selfHosted: true,
kbdbEmbed: true,
};
function readVars(toml: string): Record<string, string> {
const out: Record<string, string> = {};
let inVars = false;
for (const line of toml.split('\n')) {
if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; }
if (/^\s*\[/.test(line)) { inVars = false; continue; }
if (!inVars) continue;
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (m) out[m[1]] = m[2];
}
return out;
}
/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */
function deployedVars(ctx: DeployContext, live: Record<string, string>): Record<string, string> {
const keep = preservedVars(live, CYPHER_TOML);
const extra: Record<string, string> = { ...keep };
if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra));
}
// ── ① 驗得到知識 → 寫進去 ────────────────────────────────────────────────────
test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => {
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {});
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
// CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去
assert.equal(vars.CONSOLE_TENANT, 'leo');
});
test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => {
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' });
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
});
// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)──────────────
test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => {
const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' });
assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns');
});
test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => {
const vars = deployedVars(CTX, {});
assert.equal(vars.ARCRUN_NAMESPACE, undefined);
assert.equal(vars.CONSOLE_TENANT, 'leo');
});
test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' +
'列進去會讓驗不到的那趟把既有值一起洗掉', async () => {
const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts');
assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false);
});
test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => {
assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor');
});
// ── ③ 「先驗再寫」那支探針的三態 ───────────────────────────────────────────────
test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => {
const calls: string[] = [];
const orig = globalThis.fetch;
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
calls.push(String(url));
assert.equal((init?.headers as Record<string, string>)['X-Arcrun-API-Key'], LEO_NS);
return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 });
}) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true);
assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
} finally {
globalThis.fetch = orig;
}
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
} finally {
globalThis.fetch = orig;
}
});
test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => {
const orig = globalThis.fetch;
globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch;
try {
assert.equal(await namespaceHasKnowledge('', LEO_NS), null);
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null);
} finally {
globalThis.fetch = orig;
}
});
+2 -1
View File
@@ -5,7 +5,8 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
"check:tenant": "node scripts/check-tenant-source.mjs",
"test": "node scripts/check-tenant-source.mjs && vitest run"
},
"dependencies": {
"@hono/zod-openapi": "^1.2.4",
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* 靜態租戶字串不得用於資料面過濾機械閘的**執行殼**Arcrun#108
*
* 規則本體純函式 node 相依 `tenant-source-rules.mjs`拆開的理由是
* **這道閘自己要能被測試**Workers runtime vitest 沒有 node:fs規則若和走檔案系統的
* 程式碼綁在一起就 import 不動測試也就寫不出來當晚有一道閘連讀自己的原始碼都擋
* 結果沒人驗得了它會不會誤攔現在 tests/tenant-gate.test.ts 直接餵字串驗規則
*
* 用法
* node scripts/check-tenant-source.mjs [projectRoot] # src/有違規 exit 1
* node scripts/check-tenant-source.mjs --stdin <相對路徑> # stdin 即將寫入的內容
* npm run check:tenant
*
* `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`規則 8.1用的在檔案**還沒寫下去之前**
* 就擋這樣違規根本進不了工作區Edit 只給片段也沒關係規則是逐行的正好只看新寫的那幾行
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scanSource } from './tenant-source-rules.mjs';
/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */
function listTsFiles(root, dir = root, out = []) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) listTsFiles(root, full, out);
else if (name.endsWith('.ts')) out.push(relative(root, full));
}
return out;
}
/** 掃整個 cypher-executor/src。回傳違規清單。 */
export function scanProject(projectRoot) {
const srcRoot = join(projectRoot, 'src');
const all = [];
for (const rel of listTsFiles(projectRoot, srcRoot)) {
const relPosix = rel.split(sep).join('/');
all.push(
...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({
...v,
file: relPosix,
})),
);
}
return all;
}
/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */
async function runStdin(relPath) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8'));
if (violations.length === 0) return 0;
for (const v of violations) {
console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`);
console.error(`${v.message}`);
}
return 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
if (process.argv[2] === '--stdin') {
process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts'));
}
const projectRoot = process.argv[2] ?? process.cwd();
const violations = scanProject(projectRoot);
if (violations.length === 0) {
console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts');
process.exit(0);
}
console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n');
for (const v of violations) {
console.error(` [${v.rule}] ${v.file}:${v.line}`);
console.error(` ${v.text}`);
console.error(`${v.message}\n`);
}
process.exit(1);
}
@@ -0,0 +1,142 @@
/**
* 靜態租戶字串不得用於資料面過濾 機械閘Arcrun#108
*
*
* 為什麼要有這道閘
*
* 同一句話已經寫錯兩次
* #105 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
* #108 `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
* 兩次都是拿一個部署環境變數的字面預設值當成使用者資料的歸屬規則早就在rule 07
* 薄殼design §3.3 租戶不下發**沒有任何機制會擋**所以它每隔幾週就長回來一次
* leo 2026-08-12做一個平台要減少 hotfix 修掉 bug 不算完成要留下會擋的東西
*
*
* 判準有沒有在做那件事不是看有沒有出現那個詞
*
* 誤攔比漏攔更容易殺死一道閘被擋煩了就有人把它關掉所以三條規則全部盯**行為**
*
* T1 租戶環境變數只有一個產地
* `env.CONSOLE_TENANT` / `env.ARCRUN_NAMESPACE` 只能在 src/lib/tenant.ts 被讀取
* 盯的是你在把部署設定讀成身分這個動作本身註解裡寫這兩個字不算只看 `env.X` 取值
*
* T2 資料面租戶識別不得憑空捏造
* `as TenantId` 只能出現在 src/lib/tenant.ts且不得套在字面字串上
* 盯的是繞過唯一產地自己造一個租戶
*
* T3 帳號層字串不得流進知識資料面
* 同一行同時在組 owner_id值來自 portalTenant()/accountTenant()
* 這正是 #108 那一行的形狀`owner_id=${encodeURIComponent(portalTenant(c.env))}`
* `owner_id: ns`帳號子 namespace合法不命中`x.owner_id` 這種讀取也不命中
*
*
* 這道閘自己要能被測試
*
* 核心是純函式 `scanSource(relPath, text)`不碰檔案系統測試餵好例子/壞例子驗它會不會叫
* tests/tenant-gate.test.ts當晚有一道閘連讀自己的原始碼都擋導致沒人驗得了它
* 本檔只掃 `src/`測試與 fixture 都不在掃描範圍內所以**不會擋到自己**
*
* 本檔是**純規則** node 相依所以 Workers runtime vitest import 得動
* 走檔案系統的那半在 check-tenant-source.mjs
*/
/** 唯一允許產出租戶識別的檔案(相對 cypher-executor/)。 */
export const TENANT_SOURCE_FILE = 'src/lib/tenant.ts';
/** 只宣告型別、不取值的檔案(`CONSOLE_TENANT?: string` 這種)。 */
const TYPE_DECL_FILES = new Set(['src/types.ts']);
/** 被視為「租戶來源」的環境變數——讀它們=在決定使用者資料的歸屬。 */
const TENANT_ENV_VARS = ['CONSOLE_TENANT', 'ARCRUN_NAMESPACE'];
/** 帳號層租戶字串的取得方式(回的是 string 不是 TenantId,不得用於知識資料面)。 */
const ACCOUNT_TENANT_CALLS = ['portalTenant(', 'accountTenant('];
const ENV_READ = new RegExp(String.raw`\benv\s*\.\s*(${TENANT_ENV_VARS.join('|')})\b`);
const AS_TENANT_ID = /\bas\s+TenantId\b/;
const LITERAL_AS_TENANT_ID = /(['"`][^'"`]*['"`])\s*as\s+TenantId\b/;
/**
* 這一行在組 owner_id **構造**才算**讀取**不算
* `owner_id=` 出現在字串/樣板裡`owner_id:` 當成物件屬性在賦值
* 不算`x.owner_id``owner_id?:`型別宣告`owner_id` 單獨出現在註解句子裡
*/
function buildsOwnerFilter(line) {
const code = stripComment(line);
if (!code.includes('owner_id')) return false;
if (/owner_id\s*=/.test(code) && !/[.\w]owner_id\s*=/.test(code)) return true; // `?owner_id=` / `owner_id=${...}`
if (/(^|[^.\w])owner_id\s*:/.test(code) && !/owner_id\s*\?\s*:/.test(code)) return true; // `owner_id: X`
return false;
}
/** 去掉行末 `//` 註解(不處理跨行 /* *\/——那種行本來就不含可執行的取值)。 */
function stripComment(line) {
const i = line.indexOf('//');
return i === -1 ? line : line.slice(0, i);
}
/** 整行是註解?(`//` 開頭或位於 JSDoc 區塊的 ` *` 行) */
function isCommentLine(line) {
const t = line.trim();
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
}
/**
* 掃一份原始碼回傳違規清單純函式測試直接餵字串
* @param {string} relPath 相對 cypher-executor/ 的路徑例如 'src/routes/portal-data.ts'
* @param {string} text 檔案內容
* @returns {{rule: string, line: number, text: string, message: string}[]}
*/
export function scanSource(relPath, text) {
const rel = relPath.split('\\').join('/');
const violations = [];
const lines = text.split('\n');
lines.forEach((line, idx) => {
const n = idx + 1;
const push = (rule, message) =>
violations.push({ rule, line: n, text: line.trim(), message });
if (isCommentLine(line)) return;
const code = stripComment(line);
// T1:租戶環境變數只有一個產地
if (rel !== TENANT_SOURCE_FILE && !TYPE_DECL_FILES.has(rel) && ENV_READ.test(code)) {
push(
'T1',
`租戶環境變數只能在 ${TENANT_SOURCE_FILE} 讀取。` +
'在別處讀它=又一次「身分來自環境變數」(#105/#108 同形),' +
'請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。',
);
}
// T2:資料面租戶識別不得憑空捏造
if (AS_TENANT_ID.test(code)) {
if (rel !== TENANT_SOURCE_FILE) {
push(
'T2',
`TenantId 只能由 ${TENANT_SOURCE_FILE} 產生。自己 cast 一個等於繞過唯一產地——` +
'請用 knowledgeOwner(env) 或 tenantFromApiKey(header)。',
);
} else if (LITERAL_AS_TENANT_ID.test(code)) {
push(
'T2',
'不得把**字面字串**當成租戶識別(那就是 `|| "leo"` 那個預設值的原形)。' +
'解析不到請丟 TenantUnresolvedError,誠實說讀不到。',
);
}
}
// T3:帳號層字串不得流進知識資料面
if (buildsOwnerFilter(line) && ACCOUNT_TENANT_CALLS.some((fn) => code.includes(fn))) {
push(
'T3',
'這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——' +
'正是 #108 那一行(1854 條三元組被過濾成 0)。' +
'知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()。',
);
}
});
return violations;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* ****Arcrun#108
*
*
*
*
* #105`ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
* #108`portalTenant(env) = env.CONSOLE_TENANT || "leo"`
*
* ****
* leo `owner_id=bfezv28v`08-11 `~/.arcrun/config.yaml`
* `api_key` `X-Arcrun-API-Key` cypher repo `"leo"`
* 1854 0
*
*
*
*
* ****
*
* 使 `~/.arcrun/config.yaml` `api_key` namespace
* CLI push workflow`{ns}:wf:*``owner_id=ns`
* MCP Bearer****
* `acr init/update` `ARCRUN_NAMESPACE`cli/src/lib/deploy.ts
* CF_ACCOUNT_ID / WORKER_SUBDOMAIN / MULTI_TENANT CLI
* 使****
*
* #105 ****
* portal design D-2 `{tenant}::portal`
* namespace `libraries` owner_id owner_id
*
* #105
* ****
*
*
*
* `TenantId`
*
* `TenantId` branded string****`knowledgeOwner` / `tenantFromApiKey`
* owner_id `ownerQuery()` / `ownerField()` `TenantId`
* env `tsc`
*
* `scripts/check-tenant-source.mjs` `tests/tenant-gate.test.ts`
* repo
*/
import type { Bindings } from '../types';
/**
*
*
* branded type minter
* namespace `knowledgeOwner``tenantFromApiKey`
*
*/
export type TenantId = string & { readonly __tenantId: unique symbol };
/** 實例 namespace 解析不出來 → 誠實炸掉,不拿預設值當答案(#100「讀不到就說讀不到」同源)。 */
export class TenantUnresolvedError extends Error {
constructor(message: string) {
super(message);
this.name = 'TenantUnresolvedError';
}
}
/**
* ** owner_id** / entries / records / / KV
* CLIMCP
*
* ****
* 1. `ARCRUN_NAMESPACE``acr init/update` `~/.arcrun/config.yaml` `api_key`
*
* 2. `CONSOLE_TENANT` prod`cypher.arcrun.dev` #108
* prod `leo` `acr update`
* self-hosted 1
* 3. ** TenantUnresolvedError** `"leo"`
*
*/
export function knowledgeOwner(env: Bindings): TenantId {
const injected = (env.ARCRUN_NAMESPACE ?? '').trim();
if (injected) return injected as TenantId;
const legacy = (env.CONSOLE_TENANT ?? '').trim();
if (legacy) return legacy as TenantId;
throw new TenantUnresolvedError(
'這個部署沒有知識命名空間(ARCRUN_NAMESPACE / CONSOLE_TENANT 都沒設)——' +
'不知道要去哪一格找資料。請跑 `acr update` 讓它從你的 ~/.arcrun/config.yaml 注入。',
);
}
/**
* `X-Arcrun-API-Key`namespace self-hosted
*
* **** #105/#108
* owner_id TenantId
*
*/
export function tenantFromApiKey(apiKey: string): TenantId {
const key = (apiKey ?? '').trim();
if (!key) throw new TenantUnresolvedError('缺少 X-Arcrun-API-Key,無法決定查詢範圍');
return key as TenantId;
}
/**
* namespace design D-2 `{tenant}::portal`
*
* 🔴 ** `string` `TenantId`** cypher
* ****
* #108
*
* `'leo'`
*/
export function accountTenant(env: Bindings): string {
return env.CONSOLE_TENANT || 'leo';
}
/**
* KBDB query string owner_id ****
* `kbdbFetch(env, `/map?${ownerQuery(tenant)}`)`
*/
export function ownerQuery(tenant: TenantId): string {
return `owner_id=${encodeURIComponent(tenant)}`;
}
/**
* JSON body / URLSearchParams owner_id ****
* `JSON.stringify({ owner_id: ownerField(tenant) })`
*/
export function ownerField(tenant: TenantId): string {
return tenant;
}
/**
* 🔴 ****KBDB `owner_id`
*
* #100 0
* ****
* review
*/
export function censusQueryAllTenants(): string {
return 'owner_id=';
}
/** 逐筆核對歸屬(讀回來的 record/entry 是不是這個租戶的)。缺欄位一律視為不是。 */
export function isOwnedBy(value: unknown, tenant: TenantId): boolean {
return typeof value === 'string' && value === (tenant as string);
}
+12 -1
View File
@@ -35,6 +35,8 @@ import {
readAuthStore,
type AuthConsoleRecord,
} from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
@@ -79,8 +81,17 @@ async function hashPassword(password: string, salt: string): Promise<string> {
return h;
}
/**
* console api_key console portal
* portal console
*
* Arcrun#108**** owner_id `/kbdb/*`
* CLIMCP namespace `env.CONSOLE_TENANT || 'leo'`
* portal `bfezv28v` `leo`console
* `lib/tenant.ts`
*/
function tenantOf(c: { env: Bindings }): string {
return c.env.CONSOLE_TENANT || 'leo';
return knowledgeOwner(c.env);
}
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
@@ -72,6 +72,9 @@ import {
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
import { knowledgeOwner } from '../lib/tenant';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -265,7 +268,7 @@ export async function cachedGiteaSprint(
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const now = Date.now();
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
@@ -451,7 +454,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const { base, headers } = kbdbBase(c.env);
const now = Date.now();
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
@@ -499,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant, 'todo', 500),
fetchEntries(c.env, tenant, 'inbox', 200),
@@ -530,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const { base, headers } = kbdbBase(c.env);
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
+87 -28
View File
@@ -23,7 +23,10 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
@@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
/** 讀 tenant 的 named workflow graph`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
// #108workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`),
// 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。
const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
if (!raw) return null;
try {
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
@@ -190,9 +195,11 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
* KBDB `/records/triplet-stats` SQL COUNTowner '' KBDB
* `?1 = '' OR e.owner_id = ?1`nullcaller 0
*/
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
// owner=null 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
if (!body || !Array.isArray(body.stats)) return null;
@@ -219,16 +226,16 @@ async function tripletCount(env: Bindings, owner: string): Promise<number | null
* owned=0 any>0 owner_id /
* owned=null
*/
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
const owned = await tripletCount(env, tenant);
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
return { owned, any: await tripletCount(env, '') };
return { owned, any: await tripletCount(env, null) };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
@@ -263,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) =>
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
@@ -334,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
}),
@@ -359,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
if (wfGraph) {
const depthRaw = c.req.query('depth') ?? '';
@@ -420,9 +427,9 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
tripletCensus(c.env, tenant),
]);
const tripletsTotal = census.owned;
@@ -505,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
wfGraph,
{ question },
'rag_chat',
portalTenant(c.env),
knowledgeOwner(c.env),
c.executionCtx,
);
if (!result.success) {
@@ -611,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
// 資料源與 /webhooks/named /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const prefix = `${tenant}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
@@ -637,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
let last_execution: { timestamp: string; verdict?: string } | null = null;
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
);
const execBody = await execRes.json().catch(() => null) as {
success?: boolean;
@@ -691,25 +698,42 @@ function recordLibrary(values: Record<string, unknown> | undefined): string | nu
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: string,
tenant: TenantId,
libraries: string[],
): boolean {
if ((rec.owner_id ?? '') !== tenant) return false;
if (!isOwnedBy(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 同一個帳號看得更多。
//
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
//
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
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: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'no_library_grant',
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
});
}
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
@@ -720,7 +744,42 @@ portalDataRouter.get('/portal/data/map', (c) =>
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
if (allowed.length > 0) {
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
}
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
if (body.libraries.length > 0) {
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'filtered_out',
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
});
}
const census = await tripletCensus(c.env, tenant);
if (census.owned === null || (census.owned === 0 && census.any === null)) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'unreadable',
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
});
}
if (census.owned === 0 && (census.any ?? 0) > 0) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'scope_mismatch',
instance_triplet_count: census.any,
note:
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
});
}
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'confirmed_empty',
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
});
}),
);
@@ -734,7 +793,7 @@ portalDataRouter.get('/portal/data/map/:library', (c) =>
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
@@ -775,7 +834,7 @@ portalDataRouter.post('/portal/data/templates', (c) =>
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: portalTenant(c.env),
created_by: knowledgeOwner(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
@@ -790,10 +849,10 @@ portalDataRouter.get('/portal/data/records/by-template/:template', (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 tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
@@ -824,7 +883,7 @@ portalDataRouter.get('/portal/data/records/:recordId', (c) =>
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
@@ -856,7 +915,7 @@ portalDataRouter.post('/portal/data/records', (c) =>
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) }),
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
@@ -884,7 +943,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const core = await buildDiagnostics(c.env, tenant);
return c.json({
generated_at: new Date().toISOString(),
+48 -21
View File
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string),
// 知識資料面用 knowledgeOwnerTenantId)——型別分家,拿錯編不過。
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
@@ -58,14 +61,27 @@ export const LIBRARY_TEMPLATE = 'portal_library';
// ── 基礎 helpers ────────────────────────────────────────────────────────────
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
/**
* **** owner_idArcrun#108
*
* namespace`{tenant}::portal`design D-2 cypher
* extractor_config / credentials cypher
*
* 🔴 **** / entries / records / /
* CLI namespace #108
* leo 1854 `CONSOLE_TENANT="leo"` 0
* `lib/tenant.ts` `knowledgeOwner(env)` `TenantId` `string`
*
*
* ** server 使**
*/
export function portalTenant(env: Bindings): string {
return env.CONSOLE_TENANT || 'leo';
return accountTenant(env);
}
/** 帳號子 namespacedesign D-2)。 */
function portalNamespace(env: Bindings): string {
return `${portalTenant(env)}::portal`;
return `${accountTenant(env)}::portal`;
}
function sessionTtl(env: Bindings): number {
@@ -102,6 +118,11 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
// Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」,
// 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。
if (e instanceof TenantUnresolvedError) {
return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
}
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
throw e;
}
@@ -1409,7 +1430,6 @@ portalRouter.post('/portal/daemon/config', (c) =>
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const tenant = portalTenant(c.env);
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
@@ -1422,9 +1442,12 @@ portalRouter.post('/portal/daemon/config', (c) =>
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLIMCP 用的實例 namespace 是兩個來源
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
const daemonCfg: Record<string, string> = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant,
namespace: knowledgeOwner(c.env),
library: 'kb',
email,
instance_name: String(rec.values.display_name ?? ''),
@@ -1456,7 +1479,7 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
const key = String(body?.key ?? '').trim();
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
@@ -1518,8 +1541,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try {
const tenant = portalTenant(c.env);
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
const tenant = knowledgeOwner(c.env);
const ownerParam = ownerQuery(tenant);
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
@@ -1712,8 +1735,8 @@ portalRouter.get('/portal/admin/execution-log-retention', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
@@ -1732,10 +1755,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) =>
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
}
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }),
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }),
});
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null };
@@ -1756,10 +1779,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
const confirm = String(body?.confirm ?? '').trim();
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
method: 'PATCH',
body: JSON.stringify({ owner_id: ownerId, library: name }),
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
const data = (await res.json()) as { deprecated_count?: number };
@@ -1850,16 +1873,19 @@ export interface DiagnosticsCore {
notes: string[];
}
/** tenantowner_idsession 版傳 portalTenant(env)daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
/**
* tenantowner_idsession `knowledgeOwner(env)`daemon `tenantFromApiKey(header)`
* #108 `TenantId`
*/
export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise<DiagnosticsCore> {
const notes: string[] = [];
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
let embedding: Record<string, unknown> = { checked: false };
try {
const [statusRes, selftestRes] = await Promise.all([
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
]);
const statusBody = (await statusRes.json().catch(() => null)) as
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
@@ -1895,7 +1921,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
// - GET /records/triplet-statsper-library 即時聚合 SQLt142COUNT,非快取)。
let library_count = 0;
let triplet_count = 0;
const ownerParam = new URLSearchParams({ owner_id: tenant }).toString();
const ownerParam = ownerQuery(tenant);
try {
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
@@ -1929,7 +1955,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
let library_scope_check: Record<string, unknown> = { ran: false };
if (library_count === 0 && triplet_count === 0) {
try {
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`);
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
const total = probeBody?.total ?? 0;
library_scope_check = {
@@ -1976,7 +2002,8 @@ portalRouter.get('/portal/daemon/diagnostics', (c) =>
run(c, async () => {
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const core = await buildDiagnostics(c.env, apiKey);
// 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
return c.json({
generated_at: new Date().toISOString(),
instance_url: new URL(c.req.url).origin,
+14
View File
@@ -91,6 +91,20 @@ export type Bindings = {
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
CONSOLE_TENANT?: string;
/**
* ****Arcrun#108使 `~/.arcrun/config.yaml` `api_key`
*
* `acr init/update`cli/src/lib/deploy.ts CLI_MANAGED_VARS**使**
* CLI push workflow`{ns}:wf:*``owner_id=ns`MCP Bearer
*
*
* 沿 `CONSOLE_TENANT` repo toml ** prod **`leo`
* self-hosted #108 1854 0
* `CONSOLE_TENANT` namespace`{tenant}::portal`
* var
* `src/lib/tenant.ts`
*/
ARCRUN_NAMESPACE?: string;
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
// 不影響任何行為。未設 → "Arcrun"console 是引擎共用件,不寫死產品名)。
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
@@ -0,0 +1,261 @@
/**
* Arcrun#108
*
*
* 1. owner_id `knowledgeOwner(env)``ARCRUN_NAMESPACE` `CONSOLE_TENANT` 退
* TenantUnresolvedError** `|| 'leo'` **
* 2. `/portal/data/map` KBDBleo ARCRUN_NAMESPACE=bfezv28v
* `owner_id=bfezv28v` 9 `owner_id=leo` 0
* 3. **** KBDB ["kb"] kb
* 4. #100
* no_library_grant / filtered_out / scope_mismatch / confirmed_empty
* 5. ****design §3.3 /kbdb/*
*/
import { env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
import { portalDataRouter } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
const LEO_NS = 'bfezv28v';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/**
* router env SELF.fetch`cloudflare:test` `env`
* SELF worker ARCRUN_BUNDLE_VERSION /health
* Hono router env
* handler KBDB fetch env
*/
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockGetRecord(recordId: string, libraries: string) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, {
success: true,
record: {
record_id: recordId,
template_id: 'tpl_pu',
values: {
email: 'leo@example.com',
display_name: 'leo',
status: 'active',
role: 'admin',
password_hash: 'pbkdf2-sha256$600000$AA$BB',
libraries,
created_at: '2026-08-12T00:00:00.000Z',
updated_at: '2026-08-12T00:00:00.000Z',
},
},
});
}
/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/map')) return false;
seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
return true;
},
method: 'GET',
})
.reply(200, { success: true, libraries, count: libraries.length });
}
function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) =>
p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
method: 'GET',
})
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
}
async function getMap(token: string, overrides: Partial<Bindings> = {}) {
const res = await portalDataRouter.fetch(
new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
{ ...env, ...overrides } as Bindings,
ctx,
);
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */
const distinct = (xs: string[]): string[] => [...new Set(xs)];
// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────
describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => {
it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
});
it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => {
expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('兩個都沒有 → 丟 TenantUnresolvedError**不回 "leo"**(靜默預設值正是本票的病)', () => {
expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
});
it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => {
expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
expect(accountTenant({} as Bindings)).toBe('leo');
});
it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => {
expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
expect(censusQueryAllTenants()).toBe('owner_id=');
});
});
// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────
describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => {
it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => {
await seedSession('t-map-1', 'rec_leo');
mockGetRecord('rec_leo', '["*"]');
const seen: string[] = [];
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'general', triplet_count: 3 },
],
seen,
);
const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
expect(status).toBe(200);
expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo'
expect(body.count).toBe(2);
expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
.toEqual([1851, 3]);
expect(body.empty_reason).toBeNull();
});
it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => {
await seedSession('t-map-2', 'rec_leo2');
mockGetRecord('rec_leo2', '["*"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
expect(JSON.stringify(body)).not.toContain(LEO_NS);
expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
});
it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => {
await seedSession('t-map-3', 'rec_leo3');
mockGetRecord('rec_leo3', '["*"]');
const seen: string[] = [];
mockMap([{ library: 'kb', triplet_count: 1 }], seen);
await getMap('t-map-3');
expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
});
});
// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)──────────────
describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => {
it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
await seedSession('t-perm-1', 'rec_partial');
mockGetRecord('rec_partial', '["kb"]');
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'finance', triplet_count: 42 },
{ library: 'general', triplet_count: 3 },
],
[],
);
const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
expect(body.count).toBe(1);
});
it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => {
await seedSession('t-perm-2', 'rec_nolib');
mockGetRecord('rec_nolib', '[]');
const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_library_grant');
expect(body.empty_confirmed).toBe(true);
});
it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => {
await seedSession('t-perm-3', 'rec_other');
mockGetRecord('rec_other', '["finance"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
expect(body.empty_reason).toBe('filtered_out');
expect(body.empty_confirmed).toBe(true);
});
});
// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)────────────────
describe('空地圖:分得出「讀不到」與「沒有」', () => {
it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => {
await seedSession('t-empty-1', 'rec_e1');
mockGetRecord('rec_e1', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條
const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
expect(body.empty_reason).toBe('scope_mismatch');
expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
expect(body.instance_triplet_count).toBe(1854);
expect(String(body.note)).toContain('acr update');
expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
});
it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => {
await seedSession('t-empty-2', 'rec_e2');
mockGetRecord('rec_e2', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'leo', 0);
mockTripletStats((o) => o === '', 0);
const { body } = await getMap('t-empty-2');
expect(body.empty_reason).toBe('confirmed_empty');
expect(body.empty_confirmed).toBe(true);
});
it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => {
await seedSession('t-empty-3', 'rec_e3');
mockGetRecord('rec_e3', '["*"]');
mockMap([], []);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(500, { error: 'boom' });
const { body } = await getMap('t-empty-3');
expect(body.empty_reason).toBe('unreadable');
expect(body.empty_confirmed).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* ****Arcrun#108
*
*
* CLI exit 1hook exit 2
*
*
*
* src/ `?raw`
*/
import { describe, it, expect } from 'vitest';
// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點
import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs';
// @ts-expect-error -- vite ?rawbuild-time 讀檔,runtime 是純字串(Workers 沒有 node:fs
import tenantLibSource from '../src/lib/tenant.ts?raw';
// @ts-expect-error -- 同上
import portalDataSource from '../src/routes/portal-data.ts?raw';
// @ts-expect-error -- 同上
import portalSource from '../src/routes/portal.ts?raw';
// @ts-expect-error -- 同上
import consoleAuthSource from '../src/routes/console-auth.ts?raw';
// @ts-expect-error -- 同上
import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw';
type Violation = { rule: string; line: number; text: string; message: string };
const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[];
const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort();
const FILE = 'src/routes/portal-data.ts';
describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => {
it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => {
const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`;
const v = scan('src/routes/portal.ts', bad);
expect(rulesOf(v)).toContain('T1');
expect(v[0].message).toContain('knowledgeOwner');
});
it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => {
const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`);
expect(rulesOf(v)).toEqual(['T1']);
});
it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => {
const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`);
expect(rulesOf(v)).toContain('T2');
});
it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => {
const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`);
expect(rulesOf(v)).toEqual(['T2']);
expect(v[0].message).toContain('TenantUnresolvedError');
});
it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => {
const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);";
const v = scan(FILE, bad);
expect(rulesOf(v)).toContain('T3');
expect(v[0].message).toContain('1854');
});
it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`', () => {
const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`);
expect(rulesOf(v)).toContain('T3');
});
it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => {
const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);");
expect(rulesOf(v)).toContain('T3');
});
});
describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => {
const legit: [string, string, string][] = [
['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`],
['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }`],
['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"],
['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`],
['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`],
['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`],
['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANTportalTenant(c.env) 那條路整個空掉`],
['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`],
['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`],
['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`],
['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`],
];
for (const [name, path, line] of legit) {
it(`零誤攔:${name}`, () => {
expect(scan(path, line)).toEqual([]);
});
}
});
describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => {
const files: [string, string][] = [
[TENANT_SOURCE_FILE, tenantLibSource as string],
['src/routes/portal-data.ts', portalDataSource as string],
['src/routes/portal.ts', portalSource as string],
['src/routes/console-auth.ts', consoleAuthSource as string],
['src/routes/console-dashboard.ts', consoleDashboardSource as string],
];
for (const [path, text] of files) {
it(`${path} 零違規`, () => {
expect(scan(path, text)).toEqual([]);
});
}
it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo"', () => {
const body = (tenantLibSource as string).slice(
(tenantLibSource as string).indexOf('export function knowledgeOwner'),
(tenantLibSource as string).indexOf('export function tenantFromApiKey'),
);
expect(body).toContain('TenantUnresolvedError');
// 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身
expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/);
});
});
+13
View File
@@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
CONSOLE_TENANT = "leo"
# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV)
# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT`
# (leo)底下,未設就沿用它,行為一字不變。
#
# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`
# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace),
# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts
# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。
#
# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace`{tenant}::portal`
# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。
# ARCRUN_NAMESPACE = "your-namespace"
# Portal session TTL 秒數(portal-auth P2#24/#25routes/portal.ts)。預設 7 天(604800)——
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL = "604800"
+94 -8
View File
@@ -5,20 +5,86 @@ import { buildLibraryMapInstructions } from "./lib/library-map.js";
import type { KnowledgeIdentity } from "./lib/portal-client.js";
import { Env } from "./types.js";
export async function handleMcpRequest(
request: Request,
/**
* ****2026-08-13leo
*
* leo ******** Arcrun
* Memory
*
* ****
* `kbdb_search(q="Arcrun 是什麼")` 33
* MCP session 682
* ****instructions
* ****1500ms null portal
* ** AI **
* Arcrun KBDB
*/
const KNOWLEDGE_FIRST = [
"【這條連線上有主人的知識庫——先查它,再查別的】",
"",
"這條 MCP 連線後面接著一個 **KBDB 知識庫**:這台實例的主人長期累積的筆記、決策、",
"踩過的坑、專案現況、skill 與工作流紀錄,都在裡面。**你不是從零開始的**——",
"你對這些專案的內建印象多半是錯的或過時的,庫裡那份才是主人認的版本。",
"",
"🔴 **有人問你「X 是什麼/為什麼這樣做/之前怎麼決定的/現在做到哪」——",
"你的第一個動作是 `kbdb_search`,不是 grep 原始碼、不是上網搜、不是回答「我不知道」。**",
"",
'- `kbdb_search({ q: "Arcrun 是什麼" })` — 關鍵字查(預設 `mode:\'keyword\'`,基本盤永遠可用)。',
" 換幾組講法再放棄;想要語義相似度用 `mode:'semantic'`。**這一支是你的第一站。**",
"- `kbdb_get_map()` — 不知道該進哪個庫時先看藏書地圖(下面若有【藏書地圖】就是它的快照)。",
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` — 查某個東西跟誰有關係(三元組遍歷)。',
"- `kbdb_list_templates` / `kbdb_query` — 按 template 取整批結構化資料。",
"",
"🔴 **這三件事不可以講成同一句**(講成同一句就是在騙人):",
"① 「知識庫裡沒有」 ② 「我沒查」 ③ 「地圖沒取到/某庫顯示 0」。",
"查過真的沒有 → 明說「知識庫裡查不到,以下是我從原始碼/網路推的」,再去讀 code 或上網。",
"**沒查就回答=拿你的猜測冒充主人的知識,那是這條連線上最嚴重的錯。**",
"",
"🔴 **地圖是索引,不是庫存清單**:某庫顯示 `0 triplets`、或下面整段【藏書地圖】沒出現,",
"都**不代表**沒有知識(可能只是還沒重算、或這次沒抓到)。要知道有沒有,只有一個方法:`kbdb_search` 查過。",
"同理,任何工具回 401/連不上/沒權限,那是**讀不到**,不是**不存在**——照它給的 next_actions 修,",
"別把它改口講成「這裡沒有」(`arcrun_get_skill` 曾把 KBDB 的 401 講成「skill 不存在」,就是這個病)。",
].join("\n");
/** 地圖沒拿到時的**明講**(不可靜默):「沒取到」和「這裡沒有知識」不可以長得一樣。 */
const MAP_UNAVAILABLE_NOTE = [
"【藏書地圖:這次沒取到】",
"地圖抓取逾時/回錯/或它回報的清單是空的(也可能只是還沒重算過)。",
"🔴 **這是「地圖沒拿到」,不是「這裡沒有知識」。** 上面那條規則照舊:",
"要知道庫裡有什麼,直接 `kbdb_search`;想再抓一次地圖呼叫 `kbdb_get_map()`(它會回報真正的原因)。",
].join("\n");
/** 舊 tokenstale):拿不到地圖是**身分問題**,同樣要明講,並給可執行的修法。 */
const MAP_STALE_NOTE = [
"【藏書地圖:拿不到,因為這條連線是舊版簽發的 token】",
"這條連線的 token 沒帶登入者身分,`kbdb_*` 會回 `identity_missing`。",
"🔴 **這不代表知識庫是空的**——是這條連線還沒認得你。",
"仍然先呼叫一次 `kbdb_search` 確認錯誤碼;若真的是 `identity_missing`",
"請使用者到 claude.ai → Settings → Connectors 把這個 connector 重新連線一次(重新輸入 Portal 帳密),",
"**不要改口說「查不到資料」或自己去猜答案。**",
].join("\n");
/**
* server instructions`initialize` **AI **
*
* token
* AI
*/
export async function buildServerInstructions(
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<Response> {
): Promise<string> {
// library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 絕不擋 MCP 連線(鐵律)。
//
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
//
// 🔴 2026-08-13:失敗**不再靜默略過**。原本 null → 整段消失,於是「地圖沒取到」與
// 「這裡沒有知識」在 AI 眼裡長得一模一樣(Arcrun#109 同一族:保險拒絕了 vs 程式碼不存在,
// 畫面上都是沉默)。現在改成印一句明話。鐵律沒變——**失敗仍然不擋連線**,只是不再無聲。
const mapInstructions = await buildLibraryMapInstructions(env, identity);
// 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
@@ -39,9 +105,13 @@ export async function handleMcpRequest(
"",
"1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。",
" 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。",
" ⚠️ 這支若回錯(401/連不上/`kbdb_unreachable`),那是**這條連線讀不到 KBDB**",
" **不是 skill 不存在**——同一份內容用 `kbdb_search({ q: 'skill-write_intent_workflow' })` 撈得到。",
"2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。",
"3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。",
"4. 卡住/不知道該查什麼 → `arcrun_get_skill('INDEX')`(全館導航:什麼問題查哪裡+已知的坑)。",
"4. 卡住/不知道該查什麼 → `arcrun_list_skills()` **看這台實例真的有哪幾支**,再挑一支讀。",
" 2026-08-13 實測:不同實例 seed 的 skill 不一樣,有的實例只有兩支、連 `INDEX` 都沒有。",
" **不要照教材直接指名一個 slug** ——先列清單,或 `kbdb_search({ q: 'skill' })` 直接在庫裡找。)",
"5. 缺零件時:缺 API → 寫 recipe`arcrun_recipe_push`);缺能力 → 投稿零件 PR。",
" 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、",
" 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。",
@@ -58,7 +128,23 @@ export async function handleMcpRequest(
"第一個節點固定是 `input`。",
].join("\n");
const instructions = mapInstructions ? `${startHere}\n\n---\n\n${mapInstructions}` : startHere;
// 地圖那段永遠有東西可印:拿到 → 印地圖;沒拿到 → 印「沒拿到」,不是消失。
// stale 與「抓失敗」分開講,因為修法不同(前者要使用者重新連線,後者只是這次沒抓到)。
const mapSection =
mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
// 知識段排在 Arcrun 指路段之前:AI 最常被問的是「X 是什麼」,那題的正解是查庫,不是查零件。
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
}
export async function handleMcpRequest(
request: Request,
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<Response> {
const instructions = await buildServerInstructions(env, identity);
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = new McpServer(
+126 -41
View File
@@ -30,6 +30,79 @@ 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 { staleIdentityError, type KnowledgeIdentity } from "../lib/portal-client.js";
/**
* 🔴 2026-08-13****Arcrun#100#109
*
* leo21cportal-login
* `arcrun_get_skill('write_intent_workflow')` skill "write_intent_workflow" ****
* `kbdb_search` `page_name: "skill-write_intent_workflow"`
* `entry_type: "agent-skill"``source: "installer-seed"`****
* `kbdbGetByPageName` `if (!resp.ok) return null;`
* ** HTTP 401 **
*
* 401 code code `kbdb-client.ts`
* `env.KBDB_INTERNAL_TOKEN` Authorization KBDB 401
* `arcrun-mcp` token secret
* ** token leo code **
*
* instructions AI skill
* skill grep repo**AI **
*
* 2xx `KbdbAccessError` status `kbdbFailure()`
* 401/4035xx****`kbdb_search` AI
* `not_found` KBDB
*/
class KbdbAccessError extends Error {
constructor(
readonly status: number,
readonly what: string,
readonly detail?: string,
) {
super(`KBDB ${what} HTTP ${status}`);
}
}
/**
* KBDB
*
* `searchHint` registry skill/example
* KBDB entries `kbdb_search` **** portal
* 401 2026-08-13
*/
function kbdbFailure(e: unknown, searchHint: string, identity: KnowledgeIdentity) {
const portalNote =
identity.kind === "portal"
? "你這條是帳密登入的連線,但 skill/example 這批工具目前仍走**服務內部憑據**(還沒接上登入身分)——" +
"所以它讀不到,不代表你的帳號讀不到。"
: "";
if (e instanceof KbdbAccessError) {
const unauthorized = e.status === 401 || e.status === 403;
return errorResponse(
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
(unauthorized
? `讀不到 KBDBHTTP ${e.status}:這條連線的憑據被拒或根本沒帶)。`
: `讀不到 KBDBHTTP ${e.status})。`) +
"🔴 **這是「讀不到」,不是「不存在」**——內容還在庫裡,只是這條路被擋住了。" +
(portalNote ? ` ${portalNote}` : ""),
[
searchHint,
"kbdb_get_map() 看這台實例有哪些庫(那條走得通就更確定是這批工具的路壞了,不是庫空了)",
"🔴 不准把這個錯誤回報成「找不到/沒有這個 skill」——請照實說「KBDB 這條路讀不到」",
"持續失敗:告訴 leo 這台實例的 arcrun-mcp 少了 secret KBDB_INTERNAL_TOKEN",
],
e.detail,
);
}
return errorResponse(
"kbdb_unreachable",
`讀不到 KBDB${e instanceof Error ? e.message : String(e)}` +
"🔴 **這是「讀不到」,不是「不存在」**。" +
(portalNote ? ` ${portalNote}` : ""),
[searchHint, "稍後重試", "🔴 不准把它回報成「沒有這個 skillexample」"],
);
}
// 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type
interface KbdbBlock {
@@ -45,14 +118,25 @@ interface KbdbBlock {
async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> {
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
}
const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries ?? [];
}
/**
* page_name
*
* 🔴 `null` **KBDB **
* 4015xx `KbdbAccessError`** return null**
* 2026-08-13
*/
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
if (!resp.ok) return null;
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
}
const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries?.[0] ?? null;
}
@@ -67,7 +151,7 @@ function parseTags(tagsJson?: string): string[] {
}
}
export function registerListSkills(server: McpServer, env: Env) {
export function registerListSkills(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
toolName("list_skills"),
"列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。",
@@ -75,6 +159,7 @@ export function registerListSkills(server: McpServer, env: Env) {
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"),
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "agent-skill", 100);
const skills = blocks
@@ -101,20 +186,19 @@ export function registerListSkills(server: McpServer, env: Env) {
skills.length === 0
? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部"
: "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown",
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
"🔴 只用上面清單裡真的有的 slug;清單沒有=這台實例沒 seed 進去,不要硬猜名字",
],
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試", "若持續失敗,告訴 leo"],
);
return kbdbFailure(e, "改用 kbdb_search({ q: 'skill' }) 直接在知識庫裡找 skill 卡片(那條路走的是另一組憑據)", identity);
}
},
);
}
export function registerGetSkill(server: McpServer, env: Env) {
export function registerGetSkill(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
toolName("get_skill"),
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
@@ -122,15 +206,19 @@ export function registerGetSkill(server: McpServer, env: Env) {
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
// 走到這裡=KBDB **有正常回答**,而它說沒有這張卡(讀不到的情況上面已經拋出去了)。
return errorResponse(
"not_found",
`skill "${slug}" 不存在`,
`KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒有 seed 這支 skill。` +
"(不同實例 seed 的 skill 不一樣,別照舊教材假設某個名字一定在。)",
[
"call arcrun_list_skills() 看可用 slug",
"call arcrun_list_skills() 看**這台實例真的有**哪幾支",
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
"確認拼字正確(不需要 'skill-' prefix",
],
);
@@ -142,17 +230,17 @@ export function registerGetSkill(server: McpServer, env: Env) {
tags: parseTags(block.tags_json),
});
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
return kbdbFailure(
e,
`改用 kbdb_search({ q: 'skill-${slug}' }) 撈同一張卡片(skill 就住在知識庫的 entries 裡,那條路走另一組憑據)`,
identity,
);
}
},
);
}
export function registerListExamples(server: McpServer, env: Env) {
export function registerListExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
toolName("list_examples"),
"列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。",
@@ -160,6 +248,7 @@ export function registerListExamples(server: McpServer, env: Env) {
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"),
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "workflow-example", 200);
const examples = blocks
@@ -183,17 +272,13 @@ export function registerListExamples(server: McpServer, env: Env) {
],
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
return kbdbFailure(e, "改用 kbdb_search({ q: 'example' }) 直接在知識庫裡找 example 卡片(那條路走另一組憑據)", identity);
}
},
);
}
export function registerGetExample(server: McpServer, env: Env) {
export function registerGetExample(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
toolName("get_example"),
"拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。",
@@ -201,16 +286,19 @@ export function registerGetExample(server: McpServer, env: Env) {
slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"),
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
// KBDB 好好回答了,而它說沒有這張卡(讀不到的情況已在 kbdbGetByPageName 拋出)。
return errorResponse(
"not_found",
`example "${slug}" 不存在`,
`KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒 seed 這個 example。`,
[
"call arcrun_list_examples() 看可用 slug",
"或 arcrun_search_examples(use_case) 用自然語言找",
"call arcrun_list_examples() 看**這台實例真的有**哪些 slug",
"或 arcrun_search_examples(use_case) 用關鍵字找",
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
],
);
}
@@ -232,17 +320,17 @@ export function registerGetExample(server: McpServer, env: Env) {
"看 description_md 了解設計意圖 / 改造方向",
]);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
return kbdbFailure(
e,
`改用 kbdb_search({ q: 'example-${slug}' }) 撈同一張卡片(example 就住在知識庫的 entries 裡)`,
identity,
);
}
},
);
}
export function registerSearchExamples(server: McpServer, env: Env) {
export function registerSearchExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
toolName("search_examples"),
"用 use case 關鍵字搜 workflow examples,回最相關 N 個。" +
@@ -253,6 +341,7 @@ export function registerSearchExamples(server: McpServer, env: Env) {
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5"),
},
async ({ query, top_k }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const k = top_k ?? 5;
const q = query.trim();
@@ -309,20 +398,16 @@ export function registerSearchExamples(server: McpServer, env: Env) {
],
);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
return kbdbFailure(e, `改用 kbdb_search({ q: '${query.trim()}' }) 直接查知識庫(那條路走另一組憑據)`, identity);
}
},
);
}
export function registerAllSkillExampleTools(server: McpServer, env: Env) {
registerListSkills(server, env);
registerGetSkill(server, env);
registerListExamples(server, env);
registerGetExample(server, env);
registerSearchExamples(server, env);
export function registerAllSkillExampleTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerListSkills(server, env, identity);
registerGetSkill(server, env, identity);
registerListExamples(server, env, identity);
registerGetExample(server, env, identity);
registerSearchExamples(server, env, identity);
}
+5 -1
View File
@@ -55,7 +55,11 @@ export function registerAllTools(
registerAllWorkflowCrudTools(server, env);
// LI SDD M3.2: skills + examples lookupKBDB-backed
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
registerAllSkillExampleTools(server, env);
// 2026-08-13:吃 identity 只為了「把話講對」——舊 token 誠實回 identity_missing
// 且 401 時能告訴登入者「是這批工具還走服務憑據,不是你的帳號讀不到」。
// ⚠️ 這批**尚未**改走 portal 資料面(portal 沒有 by-entry_type 的 listing 端點;
// 要接得補 API,不是在這層拼裝——rule 07 §3.1)。見該檔檔頭。
registerAllSkillExampleTools(server, env, identity);
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
registerAllRecipeTools(server, env);
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2
+130
View File
@@ -0,0 +1,130 @@
/**
* server instructions`initialize` AI ****
*
* 2026-08-13 leo
* ********
*
* instructions ****
* 1500ms null portal session
* Arcrun 682 `kbdb_search` 33
*
*
* ** instructions **
*/
import { describe, it, expect, beforeEach } from "vitest";
import { buildServerInstructions } from "../../src/mcp-handler.js";
import { __resetLibraryMapInstructionsCacheForTests } from "../../src/lib/library-map.js";
import type { Env } from "../../src/types.js";
import type { KnowledgeIdentity } from "../../src/lib/portal-client.js";
const SERVICE: KnowledgeIdentity = { kind: "service" };
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 KBDB service binding(服務級憑據路徑)。 */
function makeEnv(respond: () => Response | Promise<Response>): Env {
return { KBDB: { fetch: async () => respond() } } as unknown as Env;
}
const MAP_OK = () =>
new Response(
JSON.stringify({
success: true,
libraries: [
{ library: "kb", narrative: "leo 的知識庫主庫", top_entities: ["Arcrun"], triplet_count: 1854 },
],
count: 1,
}),
);
/**
* AI
* instructions
*/
function expectPointsAtKnowledge(text: string) {
// ① 明講這條連線後面有主人的知識庫
expect(text).toContain("知識庫");
// ② 指名工具與呼叫法(AI 不必猜工具名)
expect(text).toContain("kbdb_search");
expect(text).toContain("Arcrun 是什麼");
// ③ 明確排除三條錯路
expect(text).toContain("不是 grep 原始碼");
expect(text).toContain("不是上網搜");
expect(text).toContain("不是回答「我不知道」");
// ④ 「沒查到」與「沒有」不可以混為一談
expect(text).toContain("那是**讀不到**,不是**不存在**");
}
describe("buildServerInstructions — 三種情境都必須指向知識庫", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("① 地圖抓得到:地圖照舊注入,且知識段仍在", async () => {
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
expectPointsAtKnowledge(text);
expect(text).toContain("【藏書地圖】");
expect(text).toContain("kbleo 的知識庫主庫");
// 地圖成功時不該同時出現「沒取到」的話
expect(text).not.toContain("【藏書地圖:這次沒取到】");
});
it("② 地圖抓不到(HTTP 500):明講「沒取到」,不靜默、也不等於沒有知識", async () => {
const text = await buildServerInstructions(makeEnv(() => new Response("boom", { status: 500 })), SERVICE);
expectPointsAtKnowledge(text);
expect(text).toContain("【藏書地圖:這次沒取到】");
expect(text).toContain("這是「地圖沒拿到」,不是「這裡沒有知識」");
});
it("② 地圖逾時/binding 爆炸:同樣明講,不擋連線(不 throw)", async () => {
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
const text = await buildServerInstructions(env, SERVICE);
expectPointsAtKnowledge(text);
expect(text).toContain("【藏書地圖:這次沒取到】");
});
it("② 地圖回空清單:也算沒取到,不可以長得像「這裡沒有知識」", async () => {
const text = await buildServerInstructions(
makeEnv(() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 }))),
SERVICE,
);
expect(text).toContain("【藏書地圖:這次沒取到】");
expect(text).toContain("不是「這裡沒有知識」");
});
it("③ 舊 token(stale):講成身分問題+給可執行的修法,仍指向知識庫", async () => {
const text = await buildServerInstructions(makeEnv(MAP_OK), STALE);
expectPointsAtKnowledge(text);
expect(text).toContain("舊版簽發的 token");
expect(text).toContain("identity_missing");
expect(text).toContain("重新連線");
// 🔴 不可以講成「知識庫是空的」
expect(text).toContain("這不代表知識庫是空的");
});
it("stale 不去打 KBDB(地圖本身就是情報,舊 token 不給)", async () => {
let called = 0;
const env = {
KBDB: {
fetch: async () => {
called += 1;
return MAP_OK();
},
},
} as unknown as Env;
await buildServerInstructions(env, STALE);
expect(called).toBe(0);
});
it("Arcrun 指路段照舊存在(知識段是新增的,不是取代)", async () => {
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
expect(text).toContain("# Arcrun — 你已經配備了這套工具,別上網找");
expect(text).toContain("【先讀這裡】");
// 步驟 4 不再指名一個可能沒被 seed 的 slugleo21c 實測連 INDEX 都沒有)
expect(text).toContain("arcrun_list_skills()");
expect(text).not.toContain("arcrun_get_skill('INDEX')");
});
});
@@ -0,0 +1,186 @@
/**
* skillexample ****2026-08-13Arcrun#100#109
*
* `arcrun_get_skill('write_intent_workflow')` skill ... ****
* `kbdb_search` `page_name: "skill-write_intent_workflow"`
*`entry_type: agent-skill``source: installer-seed`****
* `if (!resp.ok) return null;` HTTP 401
*
* instructions AI skill
* skill grep repo
*
* **401/403/5xx/KBDB **
* `kbdb_search`
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import {
registerGetSkill,
registerListSkills,
registerGetExample,
} from "../../../src/tools/arcrun_skills_examples.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
const SERVICE: KnowledgeIdentity = { kind: "service" };
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
const STALE: KnowledgeIdentity = { kind: "stale" };
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function fakeServer() {
const tools = new Map<string, ToolHandler>();
const server = {
tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => {
tools.set(name, handler);
},
} as unknown as McpServer;
return { server, tools };
}
function makeEnv(respond: (url: string) => Response): Env {
return {
KBDB_INTERNAL_TOKEN: "",
KBDB: { fetch: async (url: string) => respond(url) },
} as unknown as Env;
}
const parse = (res: { content: { text: string }[] }) => JSON.parse(res.content[0].text);
describe("arcrun_get_skill — 401 不准講成「不存在」", () => {
it("KBDB 回 401 → kbdb_unauthorized,訊息明說「讀不到不是不存在」並給 kbdb_search 這條路", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), SERVICE);
const res = await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" });
expect(res.isError).toBe(true);
const body = parse(res);
expect(body.error_code).toBe("kbdb_unauthorized");
// 🔴 這句是本次事故的核心:不可以再出現舊版那句「skill "x" 不存在」的結論
expect(body.human_message).not.toMatch(/skill "[^"]*" 不存在/);
expect(body.human_message).toContain("這是「讀不到」,不是「不存在」");
expect(body.human_message).toContain("讀不到");
expect(body.human_message).toContain("HTTP 401");
// 還走得通的那條路要交到 AI 手上(實測 kbdb_search 撈得到同一張卡)
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'skill-write_intent_workflow' })");
// 並明白禁止它改口
expect(body.next_actions.join("\n")).toContain("不准把這個錯誤回報成");
});
it("以帳密登入的連線收到 401 → additionally 說清楚「不是你的帳號讀不到」", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), PORTAL);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_recipe" }));
expect(body.human_message).toContain("服務內部憑據");
expect(body.human_message).toContain("不代表你的帳號讀不到");
});
it("KBDB 5xx → kbdb_unreachable(連不上,也不是不存在)", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("boom", { status: 503 })), SERVICE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "whatever" }));
expect(body.error_code).toBe("kbdb_unreachable");
expect(body.human_message).toContain("HTTP 503");
});
it("binding 直接爆炸 → 也回 kbdb_unreachable,不吞成 not_found", async () => {
const { server, tools } = fakeServer();
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
registerGetSkill(server, env, SERVICE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
expect(body.error_code).toBe("kbdb_unreachable");
expect(body.human_message).toContain("kbdb down");
});
it("KBDB 正常回應但真的沒這張卡 → not_found,且說明是「這台實例沒 seed」不是全世界沒有", async () => {
const { server, tools } = fakeServer();
registerGetSkill(
server,
makeEnv(() => new Response(JSON.stringify({ entries: [] }))),
SERVICE,
);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "INDEX" }));
expect(body.error_code).toBe("not_found");
expect(body.human_message).toContain("KBDB 正常回應");
expect(body.human_message).toContain("skill-INDEX");
expect(body.next_actions.join("\n")).toContain("arcrun_list_skills()");
});
it("卡片真的在 → 照舊回內容(沒改壞正路)", async () => {
const { server, tools } = fakeServer();
let seen = "";
registerGetSkill(
server,
makeEnv((url) => {
seen = url;
return new Response(
JSON.stringify({
entries: [{ id: "e1", page_name: "skill-write_intent_workflow", content: "# 意圖工作流", tags_json: '["skill:core"]' }],
}),
);
}),
SERVICE,
);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" }));
expect(body.ok).toBe(true);
expect(body.data.content).toBe("# 意圖工作流");
expect(seen).toContain("page_name=skill-write_intent_workflow");
});
it("舊 tokenstale)→ identity_missing(誠實要求重新連線,不謊稱找不到)", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("{}")), STALE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
expect(body.error_code).toBe("identity_missing");
});
});
describe("arcrun_list_skills / arcrun_get_example — 同一條規則", () => {
it("list_skills 撞 401 → kbdb_unauthorized(舊版是 fetch_failed 一句技術話)", async () => {
const { server, tools } = fakeServer();
registerListSkills(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
const body = parse(await tools.get("arcrun_list_skills")!({}));
expect(body.error_code).toBe("kbdb_unauthorized");
expect(body.next_actions.join("\n")).toContain("kbdb_search");
});
it("list_skills 成功時提醒「只用清單裡真的有的 slug」(別再猜 INDEX", async () => {
const { server, tools } = fakeServer();
registerListSkills(
server,
makeEnv(() =>
new Response(
JSON.stringify({
entries: [{ id: "e1", page_name: "skill-write_recipe", content: "x", tags_json: "[]" }],
}),
),
),
SERVICE,
);
const body = parse(await tools.get("arcrun_list_skills")!({}));
expect(body.ok).toBe(true);
expect(body.data.count).toBe(1);
expect(body.hints.join("\n")).toContain("不要硬猜名字");
});
it("get_example 撞 401 → 同樣是讀不到,不是「example 不存在」", async () => {
const { server, tools } = fakeServer();
registerGetExample(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
const body = parse(await tools.get("arcrun_get_example")!({ slug: "rag-search-answer" }));
expect(body.error_code).toBe("kbdb_unauthorized");
expect(body.human_message).not.toMatch(/example "[^"]*" 不存在/);
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'example-rag-search-answer' })");
});
});
+25
View File
@@ -42,6 +42,8 @@ import { join, resolve, basename, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
// Arcrun#108 出貨閘(見 main() 內註解)。規則本體與掃描器住在 cypher-executor/scripts/。
import { scanProject as scanTenantSources } from '../cypher-executor/scripts/check-tenant-source.mjs';
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
@@ -214,6 +216,29 @@ async function main() {
console.log('✔ node_modules 檢查通過:');
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
// ── 出貨閘:靜態租戶字串不得用於資料面過濾(Arcrun#108,#105 同族)─────────────
//
// 為什麼擋在**這裡**:這條路徑是成品的產地(.worker-builds/ → 使用者的機器)。
// 擋在這裡=違規的碼**編不出成品、出不了貨**,而不是「有人記得跑檢查才會發現」。
// leo 2026-08-12:「做一個平台要減少 hotfix。」規則存在但沒機制驗證,就是會再犯第三次。
//
// 規則本體是純函式(cypher-executor/scripts/tenant-source-rules.mjs),
// 由 cypher-executor/tests/tenant-gate.test.ts 逐條驗「壞例子會擋、合法寫法零誤攔」
// ——這道閘自己可測,也擋不到自己(掃描範圍只有 cypher-executor/src/)。
const tenantViolations = scanTenantSources(join(REPO, 'cypher-executor'));
if (tenantViolations.length) {
console.error('\n❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘):\n');
for (const v of tenantViolations) {
console.error(` [${v.rule}] ${v.file}:${v.line} ${v.text}`);
console.error(`${v.message}`);
}
console.error('\n知識資料面請用 knowledgeOwner(env) ownerQuery()/ownerField()');
console.error('cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。');
console.error('本機自查:cd cypher-executor && npm run check:tenant\n');
process.exit(1);
}
console.log('✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts');
if (CHECK_ONLY) {
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
return;
@@ -376,6 +376,23 @@
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。
- [x] **Arcrun#108(任務層修正,2026-08-13):資料面租戶字串收斂到唯一產地**
P3 的 `/portal/data/*` 一律用 `portalTenant(env) = env.CONSOLE_TENANT || 'leo'``owner_id`
那個字串是**部署環境變數**,而知識是 CLI/同步小幫手/MCP 用**實例 namespace**
`~/.arcrun/config.yaml``api_key`)寫進去的——兩個來源會漂。leo 實撞:
藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組(他的在 `owner_id=bfezv28v`)。
`Arcrun#105``env.MCP_OWNER_NAMESPACE || "leo"`)同形,低一層。
**修法**:新增 `cypher-executor/src/lib/tenant.ts` 當唯一產地——
`knowledgeOwner(env)` 回 branded `TenantId``ARCRUN_NAMESPACE``CONSOLE_TENANT`
誠實丟錯,**無字面預設值**),資料面過濾一律經 `ownerQuery()/ownerField()`
帳號子 namespacedesign D-2 的 `{tenant}::portal`)改用 `accountTenant(env)`(回 `string`
型別上不可能流進資料面),**帳號落點一字不動**(動了舊實例登不進去)。
`acr update` 先驗(`GET /kbdb/map?owner_id=<api_key>` 查得到庫)才注入 `ARCRUN_NAMESPACE`
空地圖改回四態(`no_library_grant``filtered_out``scope_mismatch``confirmed_empty`),
沿 Arcrun#100「讀不到就說讀不到」。庫權限過濾一字未動(回歸測試釘住)。
防複發:`scripts/build-worker-artifacts.mjs` 出貨前掃描,違規編不出成品。
規範寫入 `.claude/rules/02-forbidden.md` 第六類、`system-dev/wiki/mistakes.md` #26
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9PR#15 擴充,只動 `mcp/`
+40
View File
@@ -547,6 +547,43 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
---
## 26. 「身分來自環境變數」——同一句話寫錯兩次,因為規則有、機制沒有(2026-08-12Arcrun#105#108
**症狀**leo 打開藏書地圖回 **0 個庫**,同一分鐘 KBDB 裡有 **1854 條三元組**
`arcrun_whoami` 顯示 admin/全部知識庫,`kbdb_search` 也查得到東西——**只有地圖那格是空的**。
**根因**(不是資料掉了,是讀寫兩端各拿一個來源):
| | 寫入端用什麼當 owner_id | 讀取端用什麼過濾 |
|---|---|---|
| 之前 | `~/.arcrun/config.yaml``api_key`CLI push/小幫手上傳/MCPleo `bfezv28v` | `env.CONSOLE_TENANT \|\| "leo"`repo toml 帶的**官方 prod 值** |
`acr` 從來不注入 `CONSOLE_TENANT`,所以那個 `"leo"` 不是理論邊角,**是每台 self-hosted 實例的實際行為**。
**這是第二次**。`#105` 前一天才修掉 `env.MCP_OWNER_NAMESPACE || "leo"`——同一句話,換一個檔案。
**判準(下次照用)**
1. **「這個字串是用來決定誰的資料嗎?」** 是 → 它是身分,不是部署設定。
身分要嘛來自請求(登入 session/`X-Arcrun-API-Key`),要嘛來自「寫入端用的那個值」,
**不可以是一個各自抄一份的環境變數預設值**
2. **`|| '預設值'` 出現在身分解析路徑上=把「這台機器沒設定」偽裝成「你沒有資料」**。
解析不到就誠實丟錯(#100 同一條:讀不到就說讀不到)。
3. **「規則存在但沒有機制驗證」=它會再犯**。所以本次除了修 bug,還留下三道會擋的:
- 型別閘:`TenantId` 只能由 `cypher-executor/src/lib/tenant.ts` 產出,
資料面過濾只吃 `ownerQuery()/ownerField()` → 拿隨手一個 string 去過濾,`tsc` 當場不給過。
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編成品前先跑租戶來源檢查
**違規的碼編不出成品、出不了貨**(不是「有人記得跑才會發現」)。
- 這道閘自己可測:規則是純函式(`cypher-executor/scripts/tenant-source-rules.mjs`),
`tests/tenant-gate.test.ts` 逐條驗「壞例子會擋、11 種合法寫法零誤攔」。
**誤攔比漏攔更容易殺死一道閘**——被擋煩了就有人把它關掉。
4. **修法不能比 bug 更危險**`acr update` 注入 `ARCRUN_NAMESPACE` 前**先驗**
`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫)。無條件覆蓋會把「知識本來就在
`CONSOLE_TENANT` 底下」的一鍵安裝實例指向空的那一格——那是 #97/#106 那類
「更新一次把人家的東西弄不見」。
**順手挖出的同族**(同一道閘一次抓到):`console-dashboard.ts`**4 處**
`console-auth.ts` 有 1 處相同寫法——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
## 快速檢查清單(做新功能前)
- [ ] 這是工作流還是零件?問「有必要嗎?」
@@ -568,3 +605,6 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。
- [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25
- [ ] 寫下 `env.X || '預設值'`?先問「這個字串是用來決定誰的資料嗎?」是 → 它是身分不是設定,不准有字面預設值(#26
- [ ] 要用某個字串過濾 owner_id?確認它與**寫入端**用的是同一個來源,不是另一份手抄的環境變數(#26
- [ ] 留了一道新的閘?它自己有測試嗎、誤攔案例驗過嗎、擋不擋得到自己?(#26