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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ _In development — bullets added per PR; finalized at release._

### 🔧 Bug Fixes

- **fix(oauth): Antigravity refresh no longer nulls the stored refresh_token on an empty upstream response** — Google's OAuth token endpoint uses non-rotating refresh tokens: a refresh response normally OMITS `refresh_token` and occasionally returns it as an empty string. The Antigravity executor's `refreshCredentials` used `typeof tokens.refresh_token === "string" ? tokens.refresh_token : credentials.refreshToken`, and because `typeof "" === "string"` is true, an empty-string response overwrote the good token with `""` — nulling it on first refresh. The check now treats a non-string **or empty** value as absent and preserves the stored token, matching the canonical `refreshGoogleToken` (`tokens.refresh_token || refreshToken`) semantics. ([#3850](https://github.com/diegosouzapw/OmniRoute/issues/3850) — thanks @3xa228148)
- **fix(api): LAN/Tailscale dashboard access — `ws:` CSP scheme, GET-exempt version route, surface combo field errors** — three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` allowed the `ws:` scheme only for loopback origins, blocking the dashboard's `ws://<lan-host>:*` Live WebSocket from LAN/Tailscale clients; the bare `ws:` scheme is now permitted (symmetric with the bare `wss:` already allowed), kept declarative in `next.config.mjs` with no global middleware (the project has none by design); (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) — a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message — `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) — thanks @KooshaPari for the diagnosis and original PR #5084)
- **fix(sse): defer `</think>` close so it never leaks before `tool_calls` in Claude→OpenAI streaming** — when a Claude thinking block was followed by a tool_use block, the translator unconditionally emitted a `content: "</think>"` chunk at `content_block_stop`, injecting a spurious assistant text chunk immediately before the `tool_calls` delta and corrupting OpenAI-compatible clients (e.g. Kimi Coding). The close marker is now deferred: it is flushed at the first `text_delta` that follows the thinking block (preserving the #4633 / decolua/9router#454 behavior for Claude Code / Cursor) or at stream finish when no tool_calls were collected. Tool-use streams never get a `text_delta` after the thinking block, so `</think>` is never emitted into content before `tool_calls`. ([#5123](https://github.com/diegosouzapw/OmniRoute/issues/5123))
- **fix(sse): normalize array user-message content in the Command Code executor to prevent upstream 400** — when a client sends a user turn whose `content` is an array of content parts (e.g. `[{type:"text",text:"…"}, …]`), the raw array was forwarded verbatim to the Command Code upstream, which requires `messages[N].content` for the `user` role to be a plain string — resulting in `expected string, received array` / HTTP 400 on DeepSeek V4-Pro and other Command Code models. The user branch of `convertMessages` now calls `normalizeContentText()` (already used by system, assistant, and tool branches) so multi-part user content is joined to a string before dispatch. Partially addresses ([#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166)); the 0-output-token symptom on reasoning-only models is tracked separately.
Expand Down
2 changes: 1 addition & 1 deletion open-sse/executors/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,7 @@ export class AntigravityExecutor extends BaseExecutor {
return {
accessToken: typeof tokens.access_token === "string" ? tokens.access_token : undefined,
refreshToken:
typeof tokens.refresh_token === "string"
typeof tokens.refresh_token === "string" && tokens.refresh_token
? tokens.refresh_token
: credentials.refreshToken,
expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : undefined,
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/antigravity-refresh-empty-token-3850.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Issue #3850 — Antigravity refresh nulls the stored refresh_token.
*
* Google's OAuth token endpoint normally OMITS `refresh_token` on a refresh
* (its refresh tokens are non-rotating), and occasionally returns it as an
* EMPTY STRING. The canonical `refreshGoogleToken` preserves the existing token
* via `tokens.refresh_token || refreshToken` (treats "" as absent), but the
* Antigravity executor's `refreshCredentials` used
* `typeof tokens.refresh_token === "string" ? tokens.refresh_token : credentials.refreshToken`
* — and `typeof "" === "string"` is true, so an empty-string response
* OVERWROTE the good token with "", effectively nulling it on first refresh.
*
* This regression guard asserts the executor preserves the existing refresh
* token when the upstream returns it empty or omits it.
*/
import { test } from "node:test";
import assert from "node:assert/strict";

import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";

const OLD_REFRESH = "1//old-non-rotating-refresh-token";

async function withStubbedFetch<T>(
jsonBody: Record<string, unknown>,
fn: () => Promise<T>
): Promise<T> {
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify(jsonBody), {
status: 200,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
try {
return await fn();
} finally {
globalThis.fetch = original;
}
}

test("#3850 empty-string refresh_token from Google preserves the existing token", async () => {
const executor = new AntigravityExecutor();
const refreshed = await withStubbedFetch(
{ access_token: "new-access", refresh_token: "", expires_in: 3600 },
() => executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
);

assert.ok(refreshed, "refreshCredentials should return credentials, not null");
assert.equal(refreshed.accessToken, "new-access");
assert.equal(
refreshed.refreshToken,
OLD_REFRESH,
"empty-string refresh_token must NOT overwrite the stored token"
);
});

test("#3850 omitted refresh_token from Google preserves the existing token", async () => {
const executor = new AntigravityExecutor();
const refreshed = await withStubbedFetch({ access_token: "new-access", expires_in: 3600 }, () =>
executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
);

assert.ok(refreshed);
assert.equal(refreshed.refreshToken, OLD_REFRESH);
});

test("#3850 a real rotated refresh_token still replaces the stored token", async () => {
const executor = new AntigravityExecutor();
const refreshed = await withStubbedFetch(
{ access_token: "new-access", refresh_token: "1//brand-new-token", expires_in: 3600 },
() => executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
);

assert.ok(refreshed);
assert.equal(refreshed.refreshToken, "1//brand-new-token");
});
Loading