Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(api):** Vercel Relay deploy now checks the Deployment Protection (SSO) PATCH response and surfaces `ssoProtectionWarning` when Vercel rejects it, instead of silently activating a relay that later returns an undiagnosed `403 Access denied`. (thanks @ricatix)
1 change: 1 addition & 0 deletions changelog.d/fixes/1811-composer-space-sep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** Cursor Composer/Auto tool calls that separate the arg name and value with a space instead of a newline (e.g. `path /Users/.../test`) no longer produce empty-valued, malformed argument keys, fixing silent no-op Write/tool calls. (thanks @way-art)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** Antigravity streaming requests that hit a non-ok upstream response (e.g. a 403) no longer pipe the raw upstream bytes straight through to the client — a binary/non-UTF8 error body (observed as gzip-magic-byte garbage) is now routed through the same sanitized `buildAntigravityUpstreamError()` path the non-streaming branch already used, instead of corrupting the client-visible error message. Regression guard: `tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts` — thanks @Duongkhanhtool
28 changes: 28 additions & 0 deletions open-sse/executors/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,34 @@ export class AntigravityExecutor extends BaseExecutor {
};
}

// #2461: a non-ok upstream response (e.g. 403) must never be piped through the
// streaming pass-through below as if it were an SSE body. Google occasionally
// returns non-UTF8/binary error bodies (observed: gzip-magic-byte payloads) for
// 403s on this endpoint; reading/forwarding those raw bytes corrupts the
// client-visible error message. Mirror the non-streaming branch above and build
// a sanitized JSON error via buildAntigravityUpstreamError (hard rule #12)
// instead of streaming unknown bytes straight through.
if (!response.ok) {
const rawBody = await response
.clone()
.text()
.catch(() => "");
const errorBody = buildAntigravityUpstreamError(
response.status,
response.statusText,
rawBody
);
return {
response: new Response(JSON.stringify(errorBody), {
status: response.status,
headers: { "Content-Type": "application/json" },
}),
url,
headers: finalHeaders,
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
};
}

// Streaming path: wrap the response body in a pass-through TransformStream
// that extracts remainingCredits from the final SSE chunk(s) without
// consuming the stream. The client receives the unmodified SSE data.
Expand Down
23 changes: 18 additions & 5 deletions open-sse/utils/composerToolCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,28 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul
const args: Record<string, unknown> = {};
for (const seg of segments) {
if (!seg) continue;
// Each segment is `arg_name\nvalue\n...`. The arg name is the first
// line; everything after the first newline is the value (verbatim,
// including additional newlines).
// Each segment is normally `arg_name\nvalue\n...`: the arg name is the
// first line, everything after the first newline is the value
// (verbatim, including additional newlines). Some live Composer/Auto
// captures instead separate the arg name and value with a single space
// on the same line (no newline at all in the segment) — fall back to
// splitting on the first whitespace boundary in that case so the value
// isn't swallowed into an empty-valued, space-containing "arg name".
const idxNl = seg.indexOf("\n");
let argName: string;
let argValue: string;
if (idxNl < 0) {
argName = seg.trim();
argValue = "";
const idxSp = seg.search(/\s/);
if (idxSp < 0) {
argName = seg.trim();
argValue = "";
} else {
argName = seg.slice(0, idxSp).trim();
// Unlike the newline-delimited form, a space-delimited value has no
// multi-line content to preserve — trim the trailing whitespace left
// over from the boundary with the next `<|tool▁sep|>` marker.
argValue = seg.slice(idxSp + 1).trim();
}
} else {
argName = seg.slice(0, idxNl).trim();
argValue = seg.slice(idxNl + 1);
Expand Down
133 changes: 101 additions & 32 deletions src/app/api/settings/proxy/vercel-deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,92 @@ export default async function handler(req) {
*/
export const __buildRelayFunctionForTest = buildRelayFunction;

/**
* Disable Vercel project SSO/Deployment Protection so the relay is publicly
* reachable. The PATCH response was previously fired-and-forgotten
* (`.catch(() => {})`, no `res.ok` check) — if Vercel rejects or no-ops the
* request (plan does not allow disabling protection, an under-scoped token,
* etc.), the relay still got saved and activated as a healthy proxy pool,
* and later requests through it failed with an undiagnosed
* `403 Access denied` from Vercel's own deployment protection. Callers must
* now check `.ok` and surface the failure instead of assuming success.
*/
async function disableSsoProtection(
vercelApiBase: string,
projectId: string,
token: string
): Promise<{ ok: boolean; status?: number }> {
try {
const res = await fetch(`${vercelApiBase}/v9/projects/${projectId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ssoProtection: null }),
});
return { ok: res.ok, status: res.status };
} catch {
return { ok: false };
}
}

/**
* Test-only hook exposing `disableSsoProtection` so the regression test can
* assert the PATCH response is checked instead of silently swallowed. Not
* part of the route contract.
*/
export const __disableSsoProtectionForTest = disableSsoProtection;

/**
* Builds the sanitized error response for a rejected Vercel deployment
* request. Extracted from POST to keep the handler's cognitive complexity
* within the ratchet — parses the canonical `{ error: { message } } }` shape
* and never forwards raw upstream error text (may contain project IDs, team
* slugs, deployment hashes or internal Vercel error strings).
*/
async function buildDeployErrorResponse(deployRes: Response) {
let upstreamMessage = "Vercel API rejected the deployment";
try {
const parsed = (await deployRes.json().catch(() => null)) as {
error?: { message?: string };
} | null;
const candidate = parsed?.error?.message;
if (typeof candidate === "string" && candidate.trim()) {
upstreamMessage = candidate.trim().slice(0, 200);
}
} catch {
/* fall through to generic message */
}
return createErrorResponse({
status: deployRes.status,
message: `Vercel deployment failed: ${upstreamMessage}`,
type: "upstream_error",
});
}

/**
* Disables Vercel SSO/Deployment Protection for the deployed project and
* returns a caller-facing warning when it could not be disabled. Extracted
* from POST to keep the handler's cognitive complexity within the ratchet.
* See `disableSsoProtection` doc comment for the bug this guards against.
*/
async function resolveSsoProtectionWarning(
projectId: string | undefined,
vercelApiBase: string,
token: string
): Promise<string | undefined> {
if (!projectId) return undefined;
const ssoResult = await disableSsoProtection(vercelApiBase, projectId, token);
if (ssoResult.ok) return undefined;
return (
"Could not disable Vercel Deployment Protection (SSO) for this project" +
(ssoResult.status ? ` (status ${ssoResult.status})` : "") +
". Requests through this relay may fail with a 403 Access denied from " +
"Vercel until protection is disabled manually in the Vercel dashboard."
);
}

async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> {
for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
Expand Down Expand Up @@ -171,27 +257,9 @@ export async function POST(request: Request) {
});

if (!deployRes.ok) {
// Avoid forwarding 200 bytes of raw Vercel error text — it may contain
// project IDs, team slugs, deployment hashes or internal Vercel error
// strings. Parse the canonical { error: { message } } shape and surface
// only the human-readable message (or a generic fallback).
let upstreamMessage = "Vercel API rejected the deployment";
try {
const parsed = (await deployRes.json().catch(() => null)) as {
error?: { message?: string };
} | null;
const candidate = parsed?.error?.message;
if (typeof candidate === "string" && candidate.trim()) {
upstreamMessage = candidate.trim().slice(0, 200);
}
} catch {
/* fall through to generic message */
}
return createErrorResponse({
status: deployRes.status,
message: `Vercel deployment failed: ${upstreamMessage}`,
type: "upstream_error",
});
// Avoid forwarding raw Vercel error text — it may contain project IDs,
// team slugs, deployment hashes or internal Vercel error strings.
return buildDeployErrorResponse(deployRes);
}

const deployment = (await deployRes.json()) as {
Expand All @@ -208,17 +276,17 @@ export async function POST(request: Request) {
});
}

// Disable Vercel SSO protection so the relay is publicly accessible
if (deployment.projectId) {
await fetch(`${VERCEL_API_BASE}/v9/projects/${deployment.projectId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ssoProtection: null }),
}).catch(() => {});
}
// Disable Vercel SSO protection so the relay is publicly accessible.
// The PATCH response is checked — if Vercel rejects/no-ops it (plan
// doesn't allow disabling protection, under-scoped token, etc.) the
// relay is still deployed and saved, but the caller is warned so a
// later `403 Access denied` can be diagnosed as Vercel-side deployment
// protection rather than an upstream provider rejection.
const ssoProtectionWarning = await resolveSsoProtectionWarning(
deployment.projectId,
VERCEL_API_BASE,
token
);

// Poll until READY
const deploymentApiUrl = `${VERCEL_API_BASE}/v13/deployments/${deployment.id}`;
Expand Down Expand Up @@ -254,6 +322,7 @@ export async function POST(request: Request) {
success: true,
relayUrl: `https://${deployment.url}`,
poolProxyId: poolProxy?.id,
...(ssoProtectionWarning ? { ssoProtectionWarning } : {}),
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Vercel deploy failed");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
import {
clearAntigravityVersionCache,
seedAntigravityVersionCache,
} from "../../open-sse/services/antigravityVersion.ts";

// Ports decolua/9router#2461: a non-ok (e.g. 403) Antigravity upstream response in the
// STREAMING path was piped straight through to the client via a raw pass-through
// TransformStream, with no `response.ok` check at all — unlike the non-streaming path,
// which already builds a sanitized error via buildAntigravityUpstreamError. When the
// upstream 403 body is gzip-compressed (or otherwise binary/non-UTF8), those raw bytes
// end up surfaced verbatim in the client-visible error message, corrupting it (reporters
// saw literal control-byte garbage after "[ERROR] [403]:").
test.afterEach(() => {
clearAntigravityVersionCache();
});

test("AntigravityExecutor.execute (stream=true) sanitizes a non-ok upstream body instead of piping raw bytes", async () => {
const executor = new AntigravityExecutor();
const originalFetch = globalThis.fetch;
seedAntigravityVersionCache("2026.04.17-test");

// Simulate a gzip-compressed 403 body (magic bytes 0x1f 0x8b), the exact shape
// reported upstream — reading it as text without decoding produces garbage.
const binaryBody = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x02, 0xff, 0x52, 0x41, 0x4e]);

globalThis.fetch = async () =>
new Response(binaryBody, {
status: 403,
headers: { "Content-Type": "application/json" },
});

try {
const result = await executor.execute({
model: "antigravity/gemini-2.5-flash",
body: { request: { contents: [] } },
stream: true,
credentials: { accessToken: "token", projectId: "project-1" },
log: { debug() {}, warn() {} },
});

assert.equal(result.response.status, 403);

const bodyText = await result.response.text();

// The raw gzip magic bytes must never reach the client-visible error text.
assert.ok(
!bodyText.includes("\x1f\x8b"),
`expected sanitized error body, got raw bytes leaking through: ${JSON.stringify(bodyText)}`
);

// Must be routed through buildErrorBody()/buildAntigravityUpstreamError() — a clean,
// parseable JSON error shape (hard rule #12), not an arbitrary pass-through stream.
const parsed = JSON.parse(bodyText) as { error?: { message?: string } };
assert.ok(parsed.error?.message, "expected a structured error.message");
assert.match(parsed.error.message, /Antigravity upstream error \(403\)/);
} finally {
globalThis.fetch = originalFetch;
}
});
24 changes: 24 additions & 0 deletions tests/unit/composer-tool-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,27 @@ test("feedStreamingChunk: noop after done state", () => {
assert.equal(out.safeDelta, "");
assert.equal(out.ready, false);
});

// ─── Regression: space-separated arg name/value (9router#1811) ───────────────
// Cursor's real Composer/Auto output has been observed using a single space
// (instead of a newline) between the arg name and its value inside a
// <|tool▁sep|> segment, e.g. "<|tool▁sep|>path /Users/.../test". The parser
// must still extract {path: "/Users/.../test"} rather than treating the whole
// segment as the (empty-valued) arg name.
test("parseComposerToolCalls: parses args separated by a space instead of a newline (Cursor Composer live capture)", () => {
const text =
"<|tool▁calls▁begin|><|tool▁call▁begin|> Write " +
"<|tool▁sep|>path /Users/kabawagang/Desktop/Code/iOS_Review/test " +
"<|tool▁sep|>contents 22\n\n<|tool▁call▁end|><|tool▁calls▁end|>";

const result = parseComposerToolCalls(text);

assert.equal(result.toolCalls.length, 1);
const tc = result.toolCalls[0];
assert.equal(tc.function.name, "Write");
const args = JSON.parse(tc.function.arguments);
assert.deepEqual(args, {
path: "/Users/kabawagang/Desktop/Code/iOS_Review/test",
contents: 22,
});
});
Loading