Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
1 change: 1 addition & 0 deletions docs/api/advise-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"..."}` |
Expand Down
14 changes: 14 additions & 0 deletions include/advisor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
19 changes: 19 additions & 0 deletions src/advisor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 11 additions & 4 deletions src/api_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
21 changes: 21 additions & 0 deletions tests/test_advisor_gate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading