diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f36858..936b01b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,13 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed +- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` + mapped drain / `POST /v1/requests/:id/cancel` onto HTTP 401 + `"missing or invalid bearer token"`, the same body as a disabled + tenant. Cancel now returns 409 `"request cancelled"`. Tenant + disable/rotate still 401 so clients cannot enumerate revoked + tenants. - **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string - `id` (`"1"`) in addition to a JSON number. JSON-RPC 2.0 allows both; JS - MCP servers commonly echo the integer we sent as a string. The old path - left `Response::id` at 0, so `Client::rpc` treated a valid reply as a - notification and hung until `init_timeout` / `call_timeout` then killed - the subprocess. - **Reconcile rollback no longer wipes the workspace on a failed restore.** - **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting - **Secret key/token writes do not follow a planted dest symlink.** diff --git a/docs/api/advise-gate.md b/docs/api/advise-gate.md index 0be8410c..00f52550 100644 --- a/docs/api/advise-gate.md +++ b/docs/api/advise-gate.md @@ -67,6 +67,7 @@ The gate is one history-less `complete()` on `advisor_model`. Transport or provi |--------|------|------| | 400 | Body is not a JSON object, or `advisor_model` / `original_task` / `terminating_text` is missing. | `{"error":"..."}` | | 401 | Bearer token missing/invalid, or tenant disabled. | `{"error":"..."}` | +| 409 | In-flight gate call was cancelled (server drain, or `POST /v1/requests/:id/cancel`). Not an auth failure — the bearer is still valid. | `{"error":"request cancelled"}` | | 405 | Method is not POST. | plain text | | 429 | Per-tenant rate or concurrency limiter rejected the call. | `{"error":"rate limit exceeded","reason":"rate_limit"\|"concurrent_request_limit","retry_after_seconds":N}` | | 500 | Orchestrator / client init failed. | `{"error":"..."}` | diff --git a/include/advisor.h b/include/advisor.h index cbd4ff83..648415a4 100644 --- a/include/advisor.h +++ b/include/advisor.h @@ -55,6 +55,20 @@ const char* default_gate_prompt(); // POST /v1/advise/gate. inline constexpr const char kAdvisorProviderError[] = "advisor API error"; +// After run_advisor_gate, POST /v1/advise/gate must split tenant revoke +// from caller/drain cancel. Orchestrate already does: disable/rotate +// stay 401 "missing or invalid bearer token" (anti-enumeration); +// sticky/hard cancel must not reuse that bearer text — clients would +// treat a shutdown or POST /v1/requests/:id/cancel as a bad token. +// When both are true, revoke wins (disable/rotate also cancel in-flight). +// status 200 + error == nullptr means return the gate verdict. +struct AdvisorGateHttpAbort { + int status = 200; + const char* error = nullptr; +}; +AdvisorGateHttpAbort advisor_gate_http_abort(bool tenant_alive, + bool cancelled); + // On transport/model error returns kind=Halt, malformed=true, with // kAdvisorProviderError in `text` (raw left empty). This function applies // NO fail-open / fail-closed policy on a *parseable-but-malformed* reply — diff --git a/src/advisor.cpp b/src/advisor.cpp index 138483ec..5262baeb 100644 --- a/src/advisor.cpp +++ b/src/advisor.cpp @@ -103,4 +103,23 @@ AdvisorGateOutput run_advisor_gate( return parse_advisor_signal(resp.content); } +AdvisorGateHttpAbort advisor_gate_http_abort(bool tenant_alive, + bool cancelled) { + AdvisorGateHttpAbort result; + if (!tenant_alive) { + result.status = 401; + result.error = "missing or invalid bearer token"; + return result; + } + if (cancelled) { + // 409 not 401: the bearer is still valid; the in-flight call was + // aborted (drain, /v1/requests/:id/cancel, admin cancel without + // a disable). Same cancelled taxonomy as orchestrate `done`. + result.status = 409; + result.error = "request cancelled"; + return result; + } + return result; +} + } // namespace arbiter diff --git a/src/api_server.cpp b/src/api_server.cpp index 019b9b40..f545f633 100644 --- a/src/api_server.cpp +++ b/src/api_server.cpp @@ -4713,11 +4713,18 @@ void handle_advise_gate(int fd, const HttpRequest& req, AdvisorGateOutput out = run_advisor_gate( orch->client(), advisor_model, prompt_override, in); - if (orch->sticky_cancelled() || orch->client().hard_cancelled() || - !refresh_active_tenant(tenants, tenant)) { + // Post-call: distinguish tenant revoke (401) from drain / caller + // cancel (409). Lumping them into bearer-invalid made a shutdown + // or POST /v1/requests/:id/cancel look like a bad token. + const bool tenant_alive = + refresh_active_tenant(tenants, tenant); + const bool cancelled = + orch->sticky_cancelled() || orch->client().hard_cancelled(); + if (auto gate_abort = advisor_gate_http_abort(tenant_alive, cancelled); + gate_abort.error) { auto err = jobj(); - err->as_object_mut()["error"] = jstr("missing or invalid bearer token"); - write_json_response(fd, 401, err); + err->as_object_mut()["error"] = jstr(gate_abort.error); + write_json_response(fd, gate_abort.status, err); return; } diff --git a/tests/test_advisor_gate.cpp b/tests/test_advisor_gate.cpp index 52099c96..855d50c0 100644 --- a/tests/test_advisor_gate.cpp +++ b/tests/test_advisor_gate.cpp @@ -188,3 +188,24 @@ TEST_CASE("run_advisor_gate: provider failure does not echo raw error") { CHECK(out.text.find("Authentication") == std::string::npos); CHECK(out.text.find("offline") == std::string::npos); } + +TEST_CASE("advisor_gate_http_abort: revoke stays 401; cancel is not a bad token") { + auto ok = advisor_gate_http_abort(true, false); + CHECK(ok.status == 200); + CHECK(ok.error == nullptr); + + auto revoke = advisor_gate_http_abort(false, false); + CHECK(revoke.status == 401); + CHECK(std::string(revoke.error) == "missing or invalid bearer token"); + + auto cancel = advisor_gate_http_abort(true, true); + CHECK(cancel.status == 409); + CHECK(std::string(cancel.error) == "request cancelled"); + CHECK(std::string(cancel.error).find("bearer") == std::string::npos); + + // Disable/rotate also cancel in-flight; revoke wins so clients + // cannot tell a disabled tenant from a bad token. + auto both = advisor_gate_http_abort(false, true); + CHECK(both.status == 401); + CHECK(std::string(both.error) == "missing or invalid bearer token"); +}