Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
76b3820
fix(executors): forward X-Session-ID/X-Title agent metadata headers (…
diegosouzapw Jul 14, 2026
7335557
fix(sse): sanitize non-ok Antigravity streaming error body (port from…
diegosouzapw Jul 14, 2026
b17eaed
chore(changelog): move #2413 entry to changelog.d fragment
diegosouzapw Jul 14, 2026
8ce4ac5
fix(sse): handle space-separated arg name/value in Composer tool call…
diegosouzapw Jul 14, 2026
88c2e1a
fix(cli): remove MITM DNS spoof entries before killing server process…
diegosouzapw Jul 14, 2026
0706a93
fix(api): check Vercel SSO-protection PATCH response on relay deploy …
diegosouzapw Jul 14, 2026
19b295f
refactor(api): extract vercel-deploy POST helpers to keep the cogniti…
diegosouzapw Jul 15, 2026
6eef682
refactor(mitm): extract repair planning out of manager to respect the…
diegosouzapw Jul 15, 2026
3de7b97
fix(mitm): split stopMitm() DNS/kill steps to fix complexity ratchet …
diegosouzapw Jul 15, 2026
361b39a
Merge branch 'release/v3.8.49' into fix/port-issue-2413-preserve-agen…
diegosouzapw Jul 16, 2026
ca6aa42
Merge branch 'release/v3.8.49' into fix/port-issue-2461-antigravity-4…
diegosouzapw Jul 16, 2026
7d0f749
Merge branch 'release/v3.8.49' into fix/port-issue-1811-composer-spac…
diegosouzapw Jul 16, 2026
f89cefc
Merge branch 'release/v3.8.49' into fix/port-issue-1809-mitm-dns-order
diegosouzapw Jul 16, 2026
55b071e
Merge branch 'release/v3.8.49' into fix/port-issue-1037-vercel-relay-…
diegosouzapw Jul 16, 2026
47ae4a8
Merge of #7104
mergify[bot] Jul 16, 2026
b4daf6c
Merge of #7106
mergify[bot] Jul 16, 2026
50950c0
Merge of #7116
mergify[bot] Jul 16, 2026
68d0404
Merge of #7117
mergify[bot] Jul 16, 2026
444bead
Merge of #7119
mergify[bot] Jul 16, 2026
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/1809-mitm-stop-dns-before-kill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(cli):** `stopMitm()` now removes /etc/hosts DNS-spoof entries before killing the MITM server process, closing the window where a client's DNS still resolved a target host to `127.0.0.1` while nothing was listening there — the cause of `connect ECONNREFUSED 127.0.0.1:443` right after stopping the MITM proxy (thanks @dionisius95).
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)
1 change: 1 addition & 0 deletions changelog.d/fixes/2413-preserve-agent-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(executors):** forward agent-supplied `X-Session-ID`/`X-Title` metadata headers to upstream providers — previously dropped for every client outside the `x-opencode-*` allowlist. (thanks @chitholian) (#7104)
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
19 changes: 19 additions & 0 deletions open-sse/utils/opencodeHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ const OPENCODE_HEADER_KEYS = [
"x-opencode-client",
] as const;

/**
* Common agent-metadata headers used by non-OpenCode clients (custom agents/
* providers) for upstream request tracking and attribution. Forwarded the same
* way as the x-opencode-* set: case-insensitive lookup, client value wins.
* Added for 9router#2413 — these were previously dropped for every client
* outside the OpenCode allowlist.
*/
const AGENT_METADATA_HEADER_KEYS = ["x-session-id", "x-title"] as const;

/**
* Case-insensitive lookup for a header in a headers record.
*/
Expand All @@ -26,6 +35,8 @@ function findHeader(headers: Record<string, string>, name: string): string | und
* 1. Forwards User-Agent from clientHeaders via `setUserAgentHeader()`
* 2. Forwards x-opencode-session, x-opencode-request, x-opencode-project,
* x-opencode-client headers (case-insensitive match)
* 3. Forwards x-session-id, x-title agent-metadata headers (case-insensitive
* match) — common conventions used by non-OpenCode agent clients (9router#2413)
*
* @param headers - The outbound headers record to mutate
* @param clientHeaders - The client-provided headers to forward from
Expand Down Expand Up @@ -60,6 +71,14 @@ export function forwardOpencodeClientHeaders(
}
}

// 2b. Forward agent-metadata headers (x-session-id, x-title) — 9router#2413
for (const headerName of AGENT_METADATA_HEADER_KEYS) {
const value = findHeader(clientHeaders, headerName);
if (value) {
headers[headerName] = value;
}
}

// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
if (options?.synthesizeRequestId && !headers["x-opencode-session"]) {
const sessionAffinity =
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
Loading
Loading