chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)
頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定, Gitea private=除機敏值/build 產物/.github 外全 push。 解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。 機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# 2026-05-13 cypher-executor multi-node chain context propagation 漏失
|
||||
|
||||
> **總耗時**:約 20 分鐘 debug(找 P0 #9 過程中順帶找到)
|
||||
> **根因**:4 個 edge type 沒 spread baseCtx 給下游節點,原始 context 從第 2 節點開始消失
|
||||
> **修法**:ON_SUCCESS / ON_FAIL / IF / ON_CLICK 套用 PIPE / FOREACH 同模式 `{...baseCtx, ...result}`
|
||||
> **影響**:任何 chain workflow 從第 2 節點開始 interpolate context key 都失敗
|
||||
|
||||
---
|
||||
|
||||
## 症狀
|
||||
|
||||
mira `acr run wiki_synthesis`(7 節點 workflow)回 `Unauthorized`。trace 顯示:
|
||||
|
||||
- `load_schema`(節點 1)✓ 200,input 含 `api_key=ak_xxx`
|
||||
- `load_skill`(節點 2)✗ 401 Unauthorized,input 含 `api_key="{{api_key}}"` ← **模板原文未替換**
|
||||
|
||||
## 推測 → 驗證
|
||||
|
||||
**對照組**:2 節點 chain 看 input keys:
|
||||
|
||||
| 節點 | input keys |
|
||||
|---|---|
|
||||
| n1 | `api_key, b1, b2, block_id` ✓ 全 context 在 |
|
||||
| n2 | `blocks, count, success, api_key, block_id` ✗ **`b1, b2` 不見**,`api_key` 是原文 `"{{api_key}}"` |
|
||||
|
||||
n2 的 ctx **只有 n1 output spread**,原始 context(`b1`, `b2`, ...)全消失。
|
||||
|
||||
## 根因
|
||||
|
||||
`graph-executor.ts` 在 outEdges 處理時,PIPE 跟 FOREACH 已有 baseCtx merge:
|
||||
|
||||
```typescript
|
||||
// PIPE (line 381-384)
|
||||
const pipeContext = {
|
||||
...(context as Record<string, unknown>),
|
||||
...baseResult,
|
||||
};
|
||||
|
||||
// FOREACH (line 438-444)
|
||||
const baseCtx = ...;
|
||||
const itemContext = {
|
||||
...baseCtx,
|
||||
...(result as Record<string, unknown>),
|
||||
[iteratorKey]: item,
|
||||
};
|
||||
```
|
||||
|
||||
但 ON_SUCCESS / ON_FAIL / IF / ON_CLICK 直接傳 `result`:
|
||||
|
||||
```typescript
|
||||
case 'ON_SUCCESS':
|
||||
result = await this.executeNode(nextNode, graph, result, ...); // ← bug
|
||||
```
|
||||
|
||||
`result` 是上游節點的 output,**沒有原始 context**。下游 interpolate 找不到原始 key 就原文留下。
|
||||
|
||||
## 歷史脈絡
|
||||
|
||||
2026-05-07 commit `e8fca33`("FOREACH preserves outer context")已意識到問題並修了 FOREACH。但**沒同步處理另外 4 個 edge type**。今天才被 mira 7 節點 workflow 踩到。
|
||||
|
||||
→ 教訓:架構級修法要全 edge type 一致掃過,不只修當下踩到的。
|
||||
|
||||
## 修法
|
||||
|
||||
`cypher-executor/src/graph-executor.ts` 4 個 edge case 補:
|
||||
|
||||
```typescript
|
||||
// 改前
|
||||
result = await this.executeNode(nextNode, graph, result, ...);
|
||||
|
||||
// 改後
|
||||
const baseCtx = (typeof context === 'object' && context !== null) ? context as Record<string, unknown> : {};
|
||||
const baseResult = (typeof result === 'object' && result !== null) ? result as Record<string, unknown> : {};
|
||||
const mergedCtx = { ...baseCtx, ...baseResult };
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, ...);
|
||||
```
|
||||
|
||||
套用在 line 407 (ON_SUCCESS) / 415 (ON_FAIL) / 423 (IF) / 472 (ON_CLICK)。
|
||||
|
||||
## 驗證
|
||||
|
||||
`acr run wiki_synthesis` 7 節點 workflow 端對端跑通:
|
||||
|
||||
| 節點 | 修前 input | 修後 input |
|
||||
|---|---|---|
|
||||
| n2 (load_skill) | `api_key={{api_key}}` ✗ | `api_key=ak_xxx` ✓ |
|
||||
| n7 (emit_result) | 上游 spread only | baseCtx + 各上游 spread ✓ |
|
||||
|
||||
整條 16.2 秒(含 claude_api 真實 Claude 呼叫),結果 `{success: true, data: {...}}`。
|
||||
|
||||
## Known Limitation
|
||||
|
||||
`interpolateData()` 只展開 `node.data` top-level string values,**不遞迴 nested object**:
|
||||
|
||||
```yaml
|
||||
# emit_result 的 values 內 {{...}} 不會展開
|
||||
emit_result:
|
||||
component: set
|
||||
values:
|
||||
text: "{{classify.data.text}}" # ← 原文傳,不展開
|
||||
```
|
||||
|
||||
非阻擋 P1,SDD 待開 `interpolate-nested-config`。當前 workaround:直接看上游節點的 trace output。
|
||||
|
||||
## 未來怎麼避免
|
||||
|
||||
1. **新 edge type 加進來時必須走 baseCtx merge 模式**——可以抽出 helper `mergeCtxForDownstream(context, result)` 強制所有 caller 用,避免漏
|
||||
2. **interpolation 邊界要有測試**:寫一個 2 節點 chain 用 `{{api_key}}` 引用原始 context 的 e2e test,CI 跑過
|
||||
3. **trace output 要顯示 input** —— 沒看 trace 內 `input` 欄位很難看出 interpolation 失敗(目前 acr CLI 不顯示 input,只顯示 result)。CLI 應加 `--verbose` 顯示每節點 input
|
||||
|
||||
## Reference
|
||||
|
||||
- 對應 SDD:`matrix/arcrun/.agents/specs/arcrun/arcrun.md` P0 #10
|
||||
- 相關 commit:`e8fca33` 2026-05-07(FOREACH 同類修法)
|
||||
- 受影響 SDD:`polaris/mira/.agents/specs/mira-app/tasks.md` 7B.3c
|
||||
- 同日另一個 incident:[2026-05-13-cypher-outbound-522.md](./2026-05-13-cypher-outbound-522.md)
|
||||
@@ -0,0 +1,143 @@
|
||||
# 2026-05-13 cypher-executor outbound fetch 全失效(CF 同 zone 自循環死鎖)
|
||||
|
||||
> **總耗時**:約一整天 debug
|
||||
> **根因**:Cloudflare Workers 對「綁 custom domain 的 Worker fetch 同 zone 另一個 custom domain Worker」會撞 zone routing 死鎖 → 回 522
|
||||
> **修法**:cypher-executor fetch component worker 改走 `*.workers.dev` 子域(不同路由系統,繞過 zone proxy)
|
||||
> **影響**:mira 7B.3c 阻擋一整天;封測 P0 從「全綠」revert 成 #9 阻擋
|
||||
|
||||
---
|
||||
|
||||
## 症狀
|
||||
|
||||
mira 跑 `acr run wiki_synthesis`(5 節點 workflow,含 kbdb_get / claude_api)回:
|
||||
|
||||
```
|
||||
n1 → {"success": false, "status": 522, "error": "error code: 522"}
|
||||
```
|
||||
|
||||
每節點精準 ~1000ms timeout 後 522。
|
||||
|
||||
進一步測試發現**所有 outbound HTTP fetch from cypher-executor 都 522**,無論目標是:
|
||||
|
||||
- 同 zone:`kbdb-get.arcrun.dev`、`claude-api.arcrun.dev`、`cypher.arcrun.dev/health`(self)
|
||||
- 外部:`httpbin.org`、`github.com`、`google.com`
|
||||
|
||||
但 **service binding 路徑(SVC_STRING_OPS 等 15 個邏輯零件)完全正常**。
|
||||
|
||||
## 觀察矩陣
|
||||
|
||||
| 路徑 | 結果 |
|
||||
|---|---|
|
||||
| 本機 curl → kbdb-get.arcrun.dev | 200 ✓ |
|
||||
| cypher-executor → kbdb-get.arcrun.dev (HTTP fetch) | **522** |
|
||||
| cypher-executor → claude-api.arcrun.dev (HTTP fetch) | **522** |
|
||||
| cypher-executor → httpbin.org (HTTP fetch) | **522** |
|
||||
| cypher-executor → string_ops (Service Binding) | 200 ✓ |
|
||||
| `acr run hello`(純 SB 1 節點)| 200 ✓ |
|
||||
| `acr run`(5 節點純 SB chain,wallTime 2.2s)| 200 ✓ |
|
||||
| **wrangler dev(本機跑 cypher-executor)→ httpbin / kbdb-get** | **200 ✓** |
|
||||
| 同 src 部署成 `arcrun-cypher-executor-probe`(純 workers.dev,無 routes 綁定)→ httpbin / kbdb-get / claude-api | **200 ✓** |
|
||||
|
||||
最關鍵兩條:
|
||||
|
||||
1. **本機 wrangler dev 跑同 src,fetch 全通** → 不是 code bug,是 prod 環境問題
|
||||
2. **probe worker(同 src、不同 name、走 workers.dev、無 cypher.arcrun.dev route)→ 全通** → 是 `cypher.arcrun.dev/*` route 觸發的環境問題
|
||||
|
||||
## 誤判路徑(重要 — 避免重犯)
|
||||
|
||||
| 假設 | 為什麼錯 |
|
||||
|---|---|
|
||||
| **A. CF Free Tier 10ms CPU cap**(首先猜的)| 5 節點 SB chain 跑 2.2 秒卻通過,CPU cap 假設立刻被推翻。但前期 debug 走了不少彎路 |
|
||||
| **B. 用戶從 Paid 掉到 Free** | 用戶確實掉到 Free,當下繳費恢復 Paid,**重測仍全 522**,徹底排除付費假設 |
|
||||
| **C. compatibility_date 太舊(2025-02-19)**| 沒嘗試 bump,但本機 dev 跟 prod 同個 compatibility_date 一通一不通,**不是這個** |
|
||||
| **D. 5/8-5/9 manual deploy 推了壞掉的 unpushed commits** | git diff 本機 vs origin/main 確實有 3 個 unpushed commits,但檢查 prod bundle 內 `makeHttpRunner` 跟本機 src 一字不差。**code 沒問題** |
|
||||
| **E. Bot Fight Mode / WAF 攔截 outbound fetch** | 用戶截圖 zone Security 設定,Bot Fight Mode 未開。**不是這個** |
|
||||
| **F. 帳號層 outbound network policy 限制** | dashboard worker bindings 完全乾淨(無 outbound worker / tail consumer / Hyperdrive 等),**不是這個** |
|
||||
| **G. CF Worker subrequest quota** | wrangler tail 顯示 `wallTime: 497ms, cpuTime: 2ms, outcome: ok` —— cypher-executor 自己沒撞任何 quota,是 fetch 出去就被攔 |
|
||||
| **H. u6u-mcp service trigger 攔截** | 那是 reverse direction(u6u-mcp 把 cypher-executor 當 service binding 呼叫),跟 outbound fetch 無關 |
|
||||
|
||||
**共通教訓**:522 來自「fetch 出去就被攔截,cypher-executor 不知情把 522 包成 component output 回 client」。wrangler tail 看 cypher-executor outcome 是 `ok` —— **不要被 worker 自己的 `ok` 騙了**,要看 trace 內 component output 才是真相。
|
||||
|
||||
## 真相
|
||||
|
||||
CF Workers 對「綁 custom domain Worker A 用 `fetch()` 打同 zone 另一個 custom domain Worker B」會撞 **zone reverse proxy 路由死鎖**。具體機制(推測):
|
||||
|
||||
1. cypher.arcrun.dev/* route 觸發 → cypher-executor 收到請求
|
||||
2. cypher-executor 內部 `fetch("https://kbdb-get.arcrun.dev/")` 出去
|
||||
3. CF edge 看「目標 hostname 是同 zone (`arcrun.dev`),有 worker route」 → 走 zone reverse proxy
|
||||
4. 同 zone re-entry 觸發 CF 自循環防護 → fetch 不能完成
|
||||
5. 對 cypher-executor 表現為「fetch 等到 timeout,得到一個假 522 response」
|
||||
6. cypher-executor 不知情,把 522 當作 component 真實回應傳回 client
|
||||
|
||||
httpbin.org 也 522 不是 same-zone 問題,可能是「進入 outbound fetch code path 就被同樣機制 abort」的副作用(這部分仍不完全清楚,但實證 workers.dev 路徑無此問題)。
|
||||
|
||||
**`*.workers.dev` 不走 zone reverse proxy**,是 CF 內部的另一條路由(worker-to-worker internal routing),不撞死鎖。**Service Binding 同理**(不走網路,process 內呼叫)。
|
||||
|
||||
## 為什麼以前能跑
|
||||
|
||||
4/18 arcrun.md 記錄「httpbin_post 端對端驗證通過」,當時 cypher-executor 也是綁 cypher.arcrun.dev/*,也是 fetch *.arcrun.dev,**卻能通**。推測 CF 之後某次平台更新收緊了 same-zone fetch 防護,但確切時間不可考。
|
||||
|
||||
→ **教訓**:CF 平台行為會悄悄變,依賴 `fetch(*.same-zone.dev)` 是脆弱設計,從此架構上不該再依賴。
|
||||
|
||||
## 修法
|
||||
|
||||
**改 4 個檔案**(commit see git log around 2026-05-13):
|
||||
|
||||
1. `cypher-executor/src/lib/component-loader.ts`:
|
||||
- `wasmWorkerUrl(canonicalId, subdomain)` 簽名加 `subdomain` 參數
|
||||
- URL pattern 從 `https://${kebab}.arcrun.dev` 改為 `https://arcrun-${kebab}.${subdomain}.workers.dev`
|
||||
- 兩個 caller(line 128 / line 192 fallback)同步改
|
||||
|
||||
2. `cypher-executor/src/actions/auth-dispatcher.ts`:
|
||||
- `wasmWorkerUrl(...)` 呼叫同步加 `subdomain` 參數,從 `env.WORKER_SUBDOMAIN` 取
|
||||
|
||||
3. `cypher-executor/src/types.ts`:
|
||||
- `Bindings` 加 `WORKER_SUBDOMAIN: string`
|
||||
|
||||
4. `cypher-executor/wrangler.toml`:
|
||||
- `[vars]` 加 `WORKER_SUBDOMAIN = "uncle6-me"`
|
||||
|
||||
**Dashboard 一次性手動操作**:5 個 P0 component worker(kbdb-get / kbdb-ingest / kbdb-create-block / kbdb-patch-block / claude-api)→ Settings → Domains & Routes → workers.dev → **Enable**
|
||||
|
||||
**未來新增 component worker 時必須**:dashboard 啟用 workers.dev URL(rule 03 已加入步驟 5)
|
||||
|
||||
## 驗證
|
||||
|
||||
修復後重測同樣 3 個 trigger:
|
||||
|
||||
| Trigger | 修復前 | 修復後 |
|
||||
|---|---|---|
|
||||
| cypher → kbdb-get | 522 (1064ms) | **200 (2027ms)** 拿回真實 block 內容 |
|
||||
| cypher → claude-api | 522 (1014ms) | **200 (6226ms)** Claude 真實回應 |
|
||||
| cypher → httpbin | 522 (1023ms) | 404 error 1042(不是 522 死鎖了;外部 fetch 限制是另一個問題,不影響 mira)|
|
||||
|
||||
mira `acr run wiki_synthesis` 5 節點 workflow 跑通前 3 節點(load_schema / load_skill / load_entities),後續節點失敗是 mira 業務邏輯問題(Unauthorized 在某個 block id),不是 cypher-executor 平台問題。
|
||||
|
||||
## 影響範圍與善後
|
||||
|
||||
**影響**:
|
||||
|
||||
- 任何用 cypher-executor 跑外部 fetch 的 workflow 都壞(封測 Step 2/5/6 / mira / 自架用戶)
|
||||
- arcrun 「每個零件 = 公開 URL」承諾在純 `*.arcrun.dev` 體系下無法跟「cypher-executor HTTP fetch」並存
|
||||
|
||||
**善後(已做)**:
|
||||
|
||||
- ✅ rule 01-tech-stack.md:URL 慣例改為「對內 workers.dev / 對外 arcrun.dev」二元
|
||||
- ✅ rule 03-component-architecture.md:第一核心概念改為「每個零件 = 兩個 URL」,部署步驟加 dashboard workers.dev enable
|
||||
- ✅ arcrun.md P0 #9 標 resolved 並 reference 本 incident
|
||||
- ✅ probe worker 已刪
|
||||
- ⏳ mira tasks.md 7B.3c 解除阻擋(即將)
|
||||
|
||||
## 未來怎麼避免
|
||||
|
||||
1. **新 component worker 部署 checklist 強制包含 dashboard workers.dev enable**(rule 03 已加,但實務上靠 dashboard 容易忘,未來可考慮寫 deploy 後驗證 script)
|
||||
2. **不要再加 outbound HTTP fetch 對同 zone hostname** —— cypher-executor 對任何 `*.arcrun.dev` 的 fetch 都該走 workers.dev URL
|
||||
3. **wrangler dev 是診斷神器** —— 本機跑 prod 同份 src 是區分「環境問題 vs code 問題」最快方法,未來 prod 出怪異行為先跑這個
|
||||
4. **wrangler tail outcome:ok 不代表沒問題** —— 要看 component trace output 才是真相
|
||||
5. **Self-hosted fork 文件要明寫**:必須改 `WORKER_SUBDOMAIN` + 所有 component worker dashboard 啟用 workers.dev(rule 03 已加,BETA_TEST.md 待 onboarding 章節加)
|
||||
|
||||
## Reference
|
||||
|
||||
- 對應 SDD:`matrix/arcrun/.agents/specs/arcrun/arcrun.md` P0 #9
|
||||
- 規範更新:`matrix/arcrun/.claude/rules/01-tech-stack.md`、`rules/03-component-architecture.md`
|
||||
- 受影響 SDD:`polaris/mira/.agents/specs/mira-app/tasks.md` 7B.3c(阻擋一整天)
|
||||
@@ -0,0 +1,113 @@
|
||||
# 2026-05-13 cypher-executor 巢狀 FOREACH 內層找不到 iterable
|
||||
|
||||
> **總耗時**:約 30 分鐘
|
||||
> **根因**:`getIterableFromContext()` 只看當前節點 result + 只看 top-level,巢狀 FOREACH 內層拿不到外層注入的 nested array
|
||||
> **修法**:fallback 找 context;掃 ctx 內 object 取 nested key
|
||||
> **影響**:任何想做「FOREACH X → 每個 X 內再 FOREACH Y」結構的 workflow 都壞
|
||||
|
||||
---
|
||||
|
||||
## 症狀
|
||||
|
||||
mira `wiki_synthesis` 想做三層樹寫入:
|
||||
|
||||
```yaml
|
||||
flow:
|
||||
- "classify >> ON_SUCCESS >> create_wiki_page"
|
||||
- "create_wiki_page >> 對每個 paragraph >> create_paragraph"
|
||||
- "create_paragraph >> 對每個 triplet >> create_triplet"
|
||||
```
|
||||
|
||||
classify 回 `{ entity, paragraphs: [{ facet, content, triplets: [...] }, ...] }`。
|
||||
|
||||
- 外層 FOREACH(對每個 paragraph):✓ 正常跑 N 次
|
||||
- 內層 FOREACH(對每個 triplet):✗ 跑 0 次
|
||||
|
||||
KBDB 內結果:N 個 wiki-paragraph 建好,0 個 triplet。
|
||||
|
||||
## 兩個獨立根因
|
||||
|
||||
### 根因 A:`result` only, no fallback to context
|
||||
|
||||
外層 FOREACH 跑時:
|
||||
- `create_paragraph` output: `{ data: { id, ... }, success: true }`
|
||||
- FOREACH 處理 `>> 對每個 triplet >> create_triplet`:iteratorKey = `triplet`
|
||||
- `getIterableFromContext(result, 'triplet')` 找 `result.triplet` / `result.triplets` → 都沒,回 `[]`
|
||||
|
||||
但 `paragraph.triplets` 早就在 ctx(外層 FOREACH 注入了 `paragraph` 物件)。
|
||||
|
||||
→ FOREACH 該 fallback 找 context。
|
||||
|
||||
### 根因 B:只看 top-level,不看 nested
|
||||
|
||||
即使 fallback 找 context,`getIterableFromContext(ctx, 'triplet')` 找 `ctx.triplet` / `ctx.triplets`,**找 top-level**。
|
||||
|
||||
但 triplets 在 `ctx.paragraph.triplets`(外層 FOREACH 把 paragraph 整個物件注入 ctx 的 `paragraph` key)。
|
||||
|
||||
→ `getIterableFromContext` 該掃一層 nested。
|
||||
|
||||
## 修法
|
||||
|
||||
`cypher-executor/src/graph-executor.ts`:
|
||||
|
||||
```typescript
|
||||
// FOREACH case
|
||||
let items = getIterableFromContext(result, iteratorKey);
|
||||
if (items.length === 0) {
|
||||
items = getIterableFromContext(context, iteratorKey); // ← A. fallback
|
||||
}
|
||||
|
||||
// getIterableFromContext
|
||||
function getIterableFromContext(context: unknown, key: string): unknown[] {
|
||||
if (!context || typeof context !== 'object') return [];
|
||||
const plural = key + 's';
|
||||
const obj = context as Record<string, unknown>;
|
||||
let items = obj[plural] ?? obj[key];
|
||||
if (!Array.isArray(items)) {
|
||||
for (const v of Object.values(obj)) { // ← B. 掃 nested
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const nested = (v as Record<string, unknown>)[plural] ?? (v as Record<string, unknown>)[key];
|
||||
if (Array.isArray(nested)) {
|
||||
items = nested;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
```
|
||||
|
||||
## 驗證
|
||||
|
||||
mira wiki_synthesis 跑「物理 AI」raw → KBDB 內出現:
|
||||
|
||||
```
|
||||
wiki-page "物理 AI" (ef644ec3)
|
||||
├─ paragraph 00d8f819
|
||||
│ ├─ triplet: 物理 AI >> 對立於 >> 純數位空間的 AI
|
||||
│ └─ triplet: 物理 AI >> 提出者 >> Andrej Karpathy
|
||||
└─ paragraph 9a5b11b7
|
||||
├─ triplet: leo >> 支持 >> 物理 AI
|
||||
└─ triplet: 物理 AI >> 需要 >> 傳感器與機器人協同
|
||||
```
|
||||
|
||||
4 個 triplet 都正確接到對應 paragraph parent_id。
|
||||
|
||||
## 為什麼這個沒早被踩到
|
||||
|
||||
cypher binding 之前的用例都是「一層 FOREACH」(commit e8fca33 wiki workflow 範例就是單層)。mira V2 wiki 結構是首個真正用巢狀 FOREACH 的,所以才剛踩到。
|
||||
|
||||
## 未來避免
|
||||
|
||||
1. **設計 FOREACH 時假設 iterable 可能在 ctx 任何深度** —— 這次的「掃一層 nested」其實還不夠通用,未來如果有三層 FOREACH(A → B → C → D),可能要遞迴掃。MVP 先一層,需要時擴
|
||||
2. **驗證 yaml 應該支援巢狀 FOREACH 測試** —— CLI validator 沒擋住巢狀,但「能 parse」不等於「能跑」。未來加 e2e 測試
|
||||
3. **`對每個 X` 命名建議用單數** —— iteratorKey 自動補 `s` 找 plural(`paragraph` → 找 `paragraphs`)。如果 ctx 內的 array 用單數命名(如 `items`),會找不到。MVP 階段建議 yaml 內 array 用複數,FOREACH 用單數,符合慣例
|
||||
|
||||
## Reference
|
||||
|
||||
- 對應 SDD:`matrix/arcrun/.agents/specs/arcrun/arcrun.md` P0 #10 補完 C 段
|
||||
- 同日先解:
|
||||
- [2026-05-13-cypher-outbound-522.md](./2026-05-13-cypher-outbound-522.md)
|
||||
- [2026-05-13-chain-ctx-propagation.md](./2026-05-13-chain-ctx-propagation.md)
|
||||
- 受影響 SDD:`polaris/mira/.agents/specs/mira-app/tasks.md` 7B.3e (V2 樹狀結構)
|
||||
@@ -0,0 +1,87 @@
|
||||
# 2026-05-29 credential 解密失敗(兩個 Worker 的 ENCRYPTION_KEY 漂移)
|
||||
|
||||
> **症狀**:`acr recipe test kbdb`(credential 注入)回 HTTP 500,`auth_static_key` 回 `credential kbdb_api_key 解密失敗`
|
||||
> **根因(主)**:`arcrun-auth-static-key` Worker 的 `ENCRYPTION_KEY` secret 跟正本(cypher-executor / CLI 用的那把)值不同、格式也不同(44-char base64 vs 64-char hex)。AES-GCM 用錯 key 必然解密失敗。
|
||||
> **根因(附)**:`component-loader.ts` 用 `res.json().catch(() => res.text())` 讀 response body → body 被讀兩次 → `Body has already been used`。
|
||||
> **修法**:(1) `wrangler secret put ENCRYPTION_KEY` 把 auth-static-key 對齊正本 64-hex;(2) 新增 `readBodyOnce()` 先取 text 再 parse JSON。
|
||||
> **影響**:BACKLOG 步驟 2(credential 注入鏈路)阻擋;Phase 3 降級假零件成 recipe 的前置。
|
||||
|
||||
---
|
||||
|
||||
## 症狀
|
||||
|
||||
`acr recipe test kbdb` 端到端打不到 2xx。直接 probe `auth_static_key`:
|
||||
|
||||
```
|
||||
POST https://auth-static-key.arcrun.dev/ {action:"authenticate", api_key:"ak_…", service:"kbdb"}
|
||||
→ {"success":false, "error":"credential kbdb_api_key 解密失敗", ...}
|
||||
```
|
||||
|
||||
前置都綠(排除誤判方向):
|
||||
- `auth_recipe:kbdb` 存在、`primitive=static_key`(kv_get 命中 410 bytes)
|
||||
- `kbdb_api_key` credential 存在 KV(kv_get 命中 108 bytes 的 `{encrypted, iv}`)
|
||||
- 失敗精準落在「解密」這一步
|
||||
|
||||
## 定位(key-fingerprint 診斷,只印 SHA-256 前綴,不印 key/明文)
|
||||
|
||||
在 `aesGcmDecrypt`(`wasi-shim.ts`)暫加:
|
||||
|
||||
```
|
||||
console.error(`[decrypt] ENCRYPTION_KEY sha256_prefix=${fpHex} keyLen=${len}`)
|
||||
```
|
||||
|
||||
deploy auth-static-key + `wrangler tail` 抓到:
|
||||
|
||||
| 來源 | keyLen | sha256 前綴 | 格式 |
|
||||
|---|---|---|---|
|
||||
| 加密端(CLI `~/.arcrun/config.yaml` 的 `encryption_key`) | 64 | `fa84f2ce9027` | hex(→32 bytes)✓ |
|
||||
| 解密端(`arcrun-auth-static-key` 的 `ENCRYPTION_KEY` secret) | **44** | **`ff219b123c89`** | base64 ✗ |
|
||||
|
||||
**兩個 mismatch 同時存在**:值不同 + 格式不同。`hexToUint8Array` 套在 44-char base64 上會解成垃圾 bytes,AES-GCM 必失敗。
|
||||
|
||||
漂移源頭:`arcrun/.env` 裡的 `ENCRYPTION_KEY` 就是那把錯的 base64(`ff219b123c89`),有人拿它去 `wrangler secret put` 設進 auth-static-key。
|
||||
|
||||
## 為什麼正本是 64-hex
|
||||
|
||||
`/register`(register.ts:42)把 `encryption_key: c.env.ENCRYPTION_KEY` 原樣回給用戶 —— 即 **cypher-executor 的** `ENCRYPTION_KEY`。用戶 config 是 64-hex(`fa84f2ce9027`),所以正本 = cypher-executor 那把 64-hex。CLI 加密 credential 也用這把。auth-static-key 必須跟它一致才能解開。
|
||||
|
||||
診斷用完即移除(`wasi-shim.ts` 還原,git diff 為空)。
|
||||
|
||||
## 附帶 bug:Body has already been used
|
||||
|
||||
修對 key 後,`/execute` 端到端從 500 變成「Node n1 failed: Body has already been used」。
|
||||
|
||||
`component-loader.ts` 的 `makeRecipeRunner` / `makeAuthRecipeRunner`:
|
||||
|
||||
```ts
|
||||
const data = await res.json().catch(() => res.text()); // ✗ res.json() 失敗時 body 已消費
|
||||
```
|
||||
|
||||
KBDB `/health` 回非 JSON(純文字)→ `res.json()` throw → `.catch(() => res.text())` 第二次讀 body → throw。
|
||||
|
||||
修法 — 讀一次:
|
||||
|
||||
```ts
|
||||
async function readBodyOnce(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
```
|
||||
|
||||
## 修法步驟
|
||||
|
||||
1. `cd .component-builds/auth_static_key && wrangler secret put ENCRYPTION_KEY`,貼正本 64-hex(= `~/.arcrun/config.yaml` 的 `encryption_key`)。**richblack 手動**(rule 05:runtime secret 不進 CI、CC 不碰)。
|
||||
2. `component-loader.ts` 加 `readBodyOnce()`,兩處 `res.json().catch(...)` 換掉。`tsc --noEmit` 綠,deploy cypher-executor。
|
||||
3. 修正源頭文件 `arcrun/.env` 的 `ENCRYPTION_KEY` 改成 64-hex(避免下次再設錯)。
|
||||
|
||||
## 驗證證據
|
||||
|
||||
- 直接 probe auth-static-key:**HTTP 200**, `success:true`, 產出 `Authorization: Bearer …`
|
||||
- 端到端 `/execute`:**HTTP 200**, trace 乾淨
|
||||
- auth 確證:直接 curl KBDB `/blocks` 不帶 token → `401 {"error":"Missing token"}`;經 cypher-executor(注入 token)→ 過 auth,進 KBDB handler 回 ZodError(缺 `content`)。**無 401 = token 被接受**。
|
||||
|
||||
## 教訓
|
||||
|
||||
- **同一把 key 出現在 ≥2 個 Worker 的 secret = 漂移風險**。auth-static-key / auth_service_account / cypher-executor 都讀 `ENCRYPTION_KEY`,靠人各設一次必漂。長期應有單一發放來源或部署時自動同步。
|
||||
- **debug 加密問題,先比 key 指紋(SHA-256 前綴),不要碰 key 明文**。一個 fingerprint log 就分辨出「值錯」vs「格式錯」vs「資料壞」。
|
||||
- **`res.json().catch(() => res.text())` 是反模式** —— body 只能讀一次。永遠先 `res.text()` 再 `JSON.parse`。
|
||||
@@ -0,0 +1,33 @@
|
||||
# Incidents
|
||||
|
||||
平台/架構級事件 post-mortem。每份檔案 = 一個事件。
|
||||
|
||||
不同於 SDD(`.agents/specs/`)的「我們要怎麼設計」,incident 記錄「我們實際撞過什麼雷、怎麼診斷、怎麼修、未來怎麼避免」。
|
||||
|
||||
## 命名慣例
|
||||
|
||||
`YYYY-MM-DD-{short-slug}.md`
|
||||
|
||||
例:`2026-05-13-cypher-outbound-522.md`
|
||||
|
||||
## 寫的時機
|
||||
|
||||
- 花了顯著時間(> 1 小時)才查清根因的問題
|
||||
- 平台層 / CF / 架構層問題(不是普通 code bug)
|
||||
- 需要修架構決策或 URL 慣例的問題
|
||||
- 「以為解決了結果不是」的反覆事件
|
||||
|
||||
普通 bug fix 不需要寫 incident。
|
||||
|
||||
## 檔案結構建議
|
||||
|
||||
每份 incident 至少含:
|
||||
|
||||
- **症狀**:怎麼觀察到的
|
||||
- **誤判路徑**:走過哪些錯方向(含為什麼錯)
|
||||
- **真相**:根因
|
||||
- **修法**:實際改了什麼
|
||||
- **驗證**:怎麼確認解了
|
||||
- **未來避免**:下次怎麼提早識別
|
||||
|
||||
最重要的是**誤判路徑**——這是未來自己(或其他 dev)會踩同樣假設的最好預防。
|
||||
Reference in New Issue
Block a user