From 08a46a22c74015035c6f8e2466c520f01c6f4772 Mon Sep 17 00:00:00 2001 From: AgentRun Artificer Date: Thu, 11 Jun 2026 16:42:53 +0800 Subject: [PATCH] fix: clarify todo-note route misses --- docs/reference/cli.md | 6 +- docs/reference/microservices.md | 6 +- scripts/src/e2e.ts | 7 +- .../backend-core/src/microservice_proxy.rs | 236 ++++++++++++++++++ 4 files changed, 247 insertions(+), 8 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0b555998..27ab1f6a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -170,7 +170,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/instances//redo --metho 看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时**第一反应**是路径错(用了不存在的 REST 端点被 catch-all 兜底),不是 mode 锁了写;详见 `docs/reference/microservices.md` 的 Todo Note 段。 -**Todo Note 错路径结构化诊断(issue #198,CLI 侧 interim 修)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all handler 当前把任何未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。`microservice proxy todo-note` 看到这个特征时会把响应 body 改写成结构化诊断: +**Todo Note 错路径结构化诊断(issue #198)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all handler 当前把任何未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。backend-core 的 `/api/microservices/todo-note/proxy/...` 同源代理看到这个特征时会把响应 body 改写成结构化诊断;CLI 侧保留相同改写作为本地/旧 runtime 兜底: ```json { @@ -192,7 +192,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/instances//redo --metho } ``` -改写后的响应同时带 `bodyRewritten: true`、`rewriteReason` 和原始 `upstreamBody`(`{"ok":false,"error":"Todo Note is running in backend-only mode"}`),所以审计 / 排障仍能拿到上游真相;上游 PR 落地后这个改写会自动退化为 no-op,CLI 不再覆盖。 +改写后的响应同时带 `bodyRewritten: true`、`rewriteReason` 和原始 `upstreamBody`(`{"ok":false,"error":"Todo Note is running in backend-only mode"}`),所以审计 / 排障仍能拿到上游真相;上游 PR 落地后这个特征 body 不再出现,backend-core proxy 和 CLI 兜底改写会自动退化为 no-op。 **`--check-path` 预检(同样针对 todo-note)**:在真正发请求前先校验 path/method 是否命中 CLI 侧 endpoint catalog,命中返回 `ok: true, matched: true, endpoint: {...}`,未命中返回与 rewrite 同源的结构化诊断且**不调用上游**。这能在 1 步内识别 typo、错端口、错 host 等引起的 catch-all 404。命令样例: @@ -210,7 +210,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/todos --method POST --check bun scripts/cli.ts microservice proxy code-queue /api/foo --method GET --check-path ``` -`--check-path` 与默认 proxy 行为是正交的:默认 proxy 仍然打 upstream 并对误导 404 做 rewrite;`--check-path` 完全跳过 upstream 调用,只走 CLI 侧 catalog。详见 `scripts/src/microservices.ts` 的 `TODO_NOTE_WRITABLE_ENDPOINTS` / `matchTodoNoteEndpoint` / `rewriteTodoNoteMisleadingRouteNotFound` / `runTodoNoteCheckPath`,以及 `scripts/src/e2e.ts` 的 `microservice:todo-note-route-diagnostic` 门禁。 +`--check-path` 与默认 proxy 行为是正交的:默认 proxy 仍然打 upstream,并由 backend-core proxy 对误导 404 做 rewrite;`--check-path` 完全跳过 upstream 调用,只走 CLI 侧 catalog。详见 `src/components/backend-core/src/microservice_proxy.rs` 的 Todo Note route diagnostic、`scripts/src/microservices.ts` 的 `TODO_NOTE_WRITABLE_ENDPOINTS` / `matchTodoNoteEndpoint` / `rewriteTodoNoteMisleadingRouteNotFound` / `runTodoNoteCheckPath`,以及 `scripts/src/e2e.ts` 的 `microservice:todo-note-route-diagnostic` 门禁。 **复杂 JSON body 优先 `--body-file ` 而非 `--body-json ''`**(2026-06-01 摩擦改进验证)。`--body-json` 在 shell 里要把双引号 escape 成 `\"`,遇到 action 对象里有嵌套字段或中英文混排标题几乎必坏;`--body-file` 走文件直读,反引号、反斜杠、中文、嵌套 JSON 都安全,且支持 stdin(`--body-file -`)。推荐做法:先 `cat > /tmp/x.json <<'EOF' ... EOF`(heredoc quoted 防 shell 展开),再 `bun scripts/cli.ts microservice proxy todo-note .../actions --method POST --body-file /tmp/x.json`。已交互验证 200 OK 全套 write actions(addTodo / toggleTodoCompleted / deleteTodo),probe 写入 + 删除完整 lifecycle 通。 diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 2e563ab4..8176c450 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -61,9 +61,9 @@ Code Queue runner 也是分布式开发执行面。runner 镜像必须内置 `tr - **写操作端点形态(2026-06-01 复盘 [#188](https://github.com/pikasTech/unidesk/issues/188) 固化)**:Todo Note 不走 REST 集合(如 `/api/instances/:id/todos`),所有 task 写都走 **action 队列**模式 `POST /api/instances/:id/actions` + body `{action: {type, ...}}`。已注册 action type:`addTodo` / `updateTodoTitle` / `toggleTodoCompleted` / `toggleTodoExpanded` / `setAllTodosExpanded` / `moveTodo` / `deleteTodo` / `renameInstance` / `setTodoReminder`。其他写端点:`POST /api/instances`(body `{name}` 新建清单)、`DELETE /api/instances/:id`、`POST /api/instances/:id/undo`、`POST /api/instances/:id/redo`。 - **`TODO_NOTE_BACKEND_ONLY=1` 真实语义**:仅关闭 Todo Note 自带 Vite 前端 SPA,不阻挡任何已注册 API 路由(包括所有 POST/DELETE 写)。`/api/health` 暴露的 `backendOnly` 字段是观察用,不是读路径开关。看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时的第一反应是路径写错(用了不存在的 REST 端点被 catch-all 兜底),不是写被 mode 锁。CLI 写操作范式见 `docs/reference/cli.md` 的 `microservice proxy` 段。 -- **Catch-all 误导文案 + CLI 改写 / 预检(issue #198 interim)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all 把所有未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,秘书/agent 看到这条第一反应容易误判成 "写被 mode 锁了"。UniDesk CLI 侧在 `microservice proxy todo-note` 检测到该特征 body 时会做就地改写,输出结构化诊断(`writableApiEndpoints`、`actionTypes`、`backendOnly: true`、`method`、`path`、`issueReference`、`upstreamBody` 完整保留原始误导文案用于审计),并把 `bodyRewritten: true` 显式返回;上游 catch-all 修复 PR 合并后改写自动退化为 no-op,CLI 不再覆盖。 - - 同样在 CLI 侧提供 `--check-path` 预检:未命中时返回同源结构化诊断并**不调用 upstream**;命中时返回 `ok: true, matched: true, endpoint: {...}`。当前 `--check-path` 只支持 `service=todo-note`,对其他服务返回结构化 `unsupported` 错误。命令样例见 `docs/reference/cli.md` 的 `microservice proxy` 段。 - - 验证门禁走 `scripts/src/e2e.ts` 的 `microservice:todo-note-route-diagnostic`(覆盖错路径 rewrite、命中 / 未命中 check-path、非 todo-note 服务的 unsupported)。该门禁是 CLI 侧 interim 自检,不替代上游 catch-all 修复 PR;上游落地后由 `bun scripts/cli.ts server rebuild todo-note` 拉新 commit 镜像,并继续用 `microservice health todo-note` + `microservice proxy todo-note /api/instances//actions` 验证。 +- **Catch-all 误导文案 + proxy 结构化诊断(issue #198)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all 把所有未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,秘书/agent 看到这条第一反应容易误判成 "写被 mode 锁了"。UniDesk backend-core 在 `/api/microservices/todo-note/proxy/...` 同源代理层检测到该特征 body 时会改写为结构化诊断(`error: "Todo Note route not found"`、`writableApiEndpoints`、`actionTypes`、`backendOnly: true`、`method`、`path`、`issueReference`、`upstreamBody` 完整保留原始误导文案用于审计),并把 `bodyRewritten: true` 显式返回;CLI 侧保留相同改写作为本地/旧 runtime 兜底。上游 catch-all 修复 PR 合并后,该特征 body 不再出现,proxy/CLI 改写自动退化为 no-op。 + - CLI 侧仍提供 `--check-path` 预检:未命中时返回同源结构化诊断并**不调用 upstream**;命中时返回 `ok: true, matched: true, endpoint: {...}`。当前 `--check-path` 只支持 `service=todo-note`,对其他服务返回结构化 `unsupported` 错误。命令样例见 `docs/reference/cli.md` 的 `microservice proxy` 段。 + - 验证门禁走 `scripts/src/e2e.ts` 的 `microservice:todo-note-route-diagnostic`(覆盖错路径 proxy/CLI rewrite、命中 / 未命中 check-path、非 todo-note 服务的 unsupported)。上游落地后由 `bun scripts/cli.ts server rebuild todo-note` 或版本化 artifact consumer 拉新 commit 镜像,并继续用 `microservice health todo-note` + `microservice proxy todo-note /api/instances//actions` 验证。 - UniDesk 前端:`用户服务 / Todo Note` React 页面负责展示清单列表、树形任务、筛选、提醒、拖放/上移下移、撤销/重做、字号控制和显式原始 JSON 按钮。 Todo Note 在 UniDesk 语境中按纯后端服务管理:不得继续公开 Todo Note 自身 Vite/Web 前端,也不得把 `4211` 映射为公网端口。浏览器只能通过 UniDesk frontend 的 `/api/microservices/todo-note/...` 同源代理访问 Todo Note 后端。标准 artifact consumer 路径为 `bun scripts/cli.ts deploy apply --env dev|prod --service todo-note`;由于 Todo Note 源码仍在外部 Gitee 仓库,D601 registry 中必须先已有 `127.0.0.1:5000/unidesk/todo-note:`。Compose 在 recreate 时注入 `UNIDESK_TODO_NOTE_DEPLOY_*`,artifact consumer 的健康探针读取 `/api/health` 并合成 `deploy.commit` 和 `deploy.requestedCommit` 供强校验。 diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index f1ade193..82026040 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -1780,6 +1780,9 @@ function runTodoNoteRouteDiagnosticChecks(): { ok: boolean; detail: unknown } { // 1) Bad path: CLI must rewrite the misleading 404 to the structured diagnostic. const badPathRewrite = runTodoNoteCliProbe(["/api/instances/instance_probe_bad/todos", "--method", "POST", "--body-json", JSON.stringify({ title: "e2e-route-diagnostic" })]); const badPathBody = (badPathRewrite.payload as { data?: { body?: Record } } | null)?.data?.body ?? null; + const badPathData = (badPathRewrite.payload as { data?: Record } | null)?.data ?? null; + const badPathBodyRewritten = badPathBody?.bodyRewritten === true || badPathData?.bodyRewritten === true; + const badPathUpstreamError = (badPathBody?.upstreamBody as { error?: string } | undefined)?.error ?? (badPathData?.upstreamBody as { error?: string } | undefined)?.error; const rewriteOk = ( badPathRewrite.exitCode === 1 && badPathBody !== null @@ -1791,8 +1794,8 @@ function runTodoNoteRouteDiagnosticChecks(): { ok: boolean; detail: unknown } { && (badPathBody.writableApiEndpoints as Array<{ path?: string }>).some((endpoint) => endpoint.path === "/api/instances/:instanceId/actions") && Array.isArray(badPathBody.actionTypes) && (badPathBody.actionTypes as string[]).includes("addTodo") - && (badPathRewrite.payload as { data?: { bodyRewritten?: boolean } } | null)?.data?.bodyRewritten === true - && (badPathRewrite.payload as { data?: { upstreamBody?: { error?: string } } } | null)?.data?.upstreamBody?.error === "Todo Note is running in backend-only mode" + && badPathBodyRewritten + && badPathUpstreamError === "Todo Note is running in backend-only mode" ); if (!rewriteOk) issues.push("bad-path-rewrite"); // 2) --check-path matched: POST /api/instances must succeed and return matched=true. diff --git a/src/components/backend-core/src/microservice_proxy.rs b/src/components/backend-core/src/microservice_proxy.rs index a168ab6f..e7f1cd1a 100644 --- a/src/components/backend-core/src/microservice_proxy.rs +++ b/src/components/backend-core/src/microservice_proxy.rs @@ -591,6 +591,162 @@ fn content_type_is_json(content_type: &str) -> bool { content_type.to_ascii_lowercase().contains("json") } +const TODO_NOTE_ACTION_TYPES: &[&str] = &[ + "addTodo", + "updateTodoTitle", + "toggleTodoCompleted", + "toggleTodoExpanded", + "setAllTodosExpanded", + "moveTodo", + "deleteTodo", + "renameInstance", + "setTodoReminder", +]; + +const TODO_NOTE_WRITABLE_ENDPOINTS: &[(&str, &str, &str)] = &[ + ( + "POST", + "/api/instances", + "Create a new todo list; body {name}.", + ), + ( + "DELETE", + "/api/instances/:instanceId", + "Delete a todo list.", + ), + ( + "POST", + "/api/instances/:instanceId/actions", + "Apply a typed action; body {action: {type, ...}}. Use the action queue pattern, not REST collection paths like /api/instances/:id/todos.", + ), + ( + "POST", + "/api/instances/:instanceId/undo", + "Undo the last applied action.", + ), + ( + "POST", + "/api/instances/:instanceId/redo", + "Redo the last undone action.", + ), +]; + +fn is_todo_note_misleading_route_not_found( + status: u16, + content_type: &str, + body_text: &str, +) -> Option { + if status != 404 || !content_type_is_json(content_type) { + return None; + } + let body = serde_json::from_str::(body_text).ok()?; + let record = body.as_object()?; + if record.get("ok").and_then(Value::as_bool) == Some(false) + && record.get("error").and_then(Value::as_str) + == Some("Todo Note is running in backend-only mode") + { + Some(body) + } else { + None + } +} + +fn todo_note_route_not_found_body( + method: &Method, + target_path: &str, + upstream_body: Value, +) -> Value { + json!({ + "ok": false, + "error": "Todo Note route not found", + "backendOnly": true, + "method": method.as_str(), + "path": target_path, + "bodyRewritten": true, + "rewriteReason": "Todo Note catch-all 404 body was misleading; UniDesk proxy wrapped it with a structured route diagnostic. See docs/reference/cli.md and pikasTech/unidesk issue #198 for context.", + "writableApiEndpoints": TODO_NOTE_WRITABLE_ENDPOINTS.iter().map(|(method, path, hint)| json!({ "method": method, "path": path, "hint": hint })).collect::>(), + "actionTypes": TODO_NOTE_ACTION_TYPES, + "hint": "Write operations use the action queue pattern: POST /api/instances/:instanceId/actions with body {action: {type, ...}}. The requested path/method is not a registered Todo Note API route; TODO_NOTE_BACKEND_ONLY only disables the upstream Vite SPA and does not lock registered API writes.", + "issueReference": "pikasTech/unidesk#198", + "upstreamBody": upstream_body, + }) +} + +async fn rewrite_todo_note_misleading_route_not_found_response( + service: &MicroserviceConfig, + method: &Method, + target_path: &str, + response: Response, +) -> Response { + if service.id != "todo-note" { + return response; + } + let status = response.status().as_u16(); + let headers = response.headers().clone(); + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + if status != 404 || !content_type_is_json(&content_type) { + return response; + } + let bytes = to_bytes(response.into_body(), 16 * 1024 * 1024) + .await + .unwrap_or_default(); + let body_text = String::from_utf8_lossy(&bytes).to_string(); + let Some(upstream_body) = + is_todo_note_misleading_route_not_found(status, &content_type, &body_text) + else { + let mut rebuilt = response_with_body(status, &content_type, body_text); + for name in FORWARD_RESPONSE_HEADERS { + if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) { + add_header(&mut rebuilt, name, value); + } + } + if let Some(value) = headers + .get("x-unidesk-proxy-mode") + .and_then(|value| value.to_str().ok()) + { + add_header(&mut rebuilt, "x-unidesk-proxy-mode", value); + } + if let Some(value) = headers + .get("x-unidesk-upstream-proxy-mode") + .and_then(|value| value.to_str().ok()) + { + add_header(&mut rebuilt, "x-unidesk-upstream-proxy-mode", value); + } + return rebuilt; + }; + let mut rewritten = json_response( + todo_note_route_not_found_body(method, target_path, upstream_body), + 404, + ); + for name in FORWARD_RESPONSE_HEADERS { + if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) { + add_header(&mut rewritten, name, value); + } + } + if let Some(value) = headers + .get("x-unidesk-proxy-mode") + .and_then(|value| value.to_str().ok()) + { + add_header(&mut rewritten, "x-unidesk-proxy-mode", value); + } + if let Some(value) = headers + .get("x-unidesk-upstream-proxy-mode") + .and_then(|value| value.to_str().ok()) + { + add_header(&mut rewritten, "x-unidesk-upstream-proxy-mode", value); + } + add_header( + &mut rewritten, + "x-unidesk-todo-note-route-diagnostic", + "true", + ); + rewritten +} + fn apply_json_array_limits(body_text: String, content_type: &str, limits: &Value) -> String { let Some(limits) = limits.as_object() else { return body_text; @@ -2391,6 +2547,13 @@ pub async fn microservice_route( body_text, ) .await; + let response = rewrite_todo_note_misleading_route_not_found_response( + &service, + &method, + &target_path, + response, + ) + .await; if (method == Method::GET || method == Method::HEAD) && is_microservice_transient_failure_response(&response) { @@ -2417,6 +2580,79 @@ pub async fn microservice_route( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn todo_note_route_diagnostic_matches_only_misleading_404_body() { + let upstream = r#"{"ok":false,"error":"Todo Note is running in backend-only mode"}"#; + let matched = is_todo_note_misleading_route_not_found( + 404, + "application/json; charset=utf-8", + upstream, + ) + .expect("misleading body should match"); + assert_eq!( + matched.get("error").and_then(Value::as_str), + Some("Todo Note is running in backend-only mode") + ); + assert!( + is_todo_note_misleading_route_not_found( + 404, + "application/json", + r#"{"ok":false,"error":"Cannot POST /api/todos"}"#, + ) + .is_none() + ); + assert!( + is_todo_note_misleading_route_not_found(200, "application/json", upstream).is_none() + ); + } + + #[test] + fn todo_note_route_diagnostic_describes_missing_post_path() { + let body = todo_note_route_not_found_body( + &Method::POST, + "/api/instances/instance_probe_bad/todos", + json!({ "ok": false, "error": "Todo Note is running in backend-only mode" }), + ); + assert_eq!( + body.get("error").and_then(Value::as_str), + Some("Todo Note route not found") + ); + assert_eq!(body.get("backendOnly").and_then(Value::as_bool), Some(true)); + assert_eq!(body.get("method").and_then(Value::as_str), Some("POST")); + assert_eq!( + body.get("path").and_then(Value::as_str), + Some("/api/instances/instance_probe_bad/todos") + ); + assert_eq!( + body.get("bodyRewritten").and_then(Value::as_bool), + Some(true) + ); + assert!( + body.get("writableApiEndpoints") + .and_then(Value::as_array) + .is_some_and(|endpoints| endpoints.iter().any(|endpoint| endpoint + .get("path") + .and_then(Value::as_str) + == Some("/api/instances/:instanceId/actions"))) + ); + assert!( + body.get("actionTypes") + .and_then(Value::as_array) + .is_some_and(|actions| actions + .iter() + .any(|action| action.as_str() == Some("addTodo"))) + ); + assert_eq!( + body.pointer("/upstreamBody/error").and_then(Value::as_str), + Some("Todo Note is running in backend-only mode") + ); + } +} + fn urlencoding_like(value: &str) -> String { percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string() }