Files
oh-my-pi/packages/ai/test/callback-server-launch-route.test.ts
T
roboomp b25775ecbc fix(mcp): resolve /launch route collision and thread launchUrl through RPC client
Codex review flagged two P2s the reporter (@DylanBohlender) confirmed:

1. OAuthCallbackFlow#handleCallback checked LAUNCH_PATH BEFORE the
   `pathname !== this.callbackPath` guard, so an OMP config that pinned
   the provider callback at `/launch` (via `oauth.callbackPath` or a
   matching `oauth.redirectUri`) had the launch route eat its
   `/launch?code=...&state=...` redirect and 302 it back to the
   authorization URL instead of resolving the callback. Reorder so
   `callbackPath` resolution wins the collision, and suppress `launchUrl`
   in that case (also when `redirectUri`'s pathname resolves to `/launch`
   even without an explicit `callbackPath` override) so UIs never
   advertise a self-redirecting copy target.

2. RpcClient.login's `open_url` listener called
   `onOpenUrl(req.url, req.instructions)` and dropped `launchUrl`, so SDK
   hosts built on the public helper couldn't surface the truncation-safe
   copy target. Extend the callback signature to
   `(url, instructions?, launchUrl?)` and forward the field — backward
   compatible for existing 1-2 arg consumers.

Regression tests in packages/ai/test/callback-server-launch-route.test.ts:
- callbackPath = /launch: launchUrl is undefined AND a
  `/launch?code=...&state=...` request resolves via the callback template
  (200/HTML), never 302s to the authorize URL.
- redirectUri pathname = /launch (callbackPath default): launchUrl still
  suppressed defensively by the parsed-pathname guard so the base class
  never advertises a colliding launch route even when callers skip the
  MCPOAuthFlow path-derivation.
2026-07-03 08:46:28 +00:00

186 lines
6.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/registry/oauth/callback-server";
import type { OAuthAuthInfo, OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types";
/**
* Regression harness for #4418 — the `/launch` route the callback server hosts
* so UIs can advertise a short (~30-char) copy target that survives TUI viewport
* truncation. Without it, the full authorize URL (~260+ chars on Linear/GitHub/…)
* gets silently truncated mid-parameter and downgrades the flow to plain PKCE.
*/
class LaunchProbeFlow extends OAuthCallbackFlow {
authUrls: string[] = [];
// Long enough that a 270-col TUI would clip `code_challenge_method=S256`.
static readonly PADDING = "x".repeat(200);
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string }> {
const url =
"https://mcp.example.com/authorize?" +
new URLSearchParams({
response_type: "code",
client_id: "test-client",
redirect_uri: redirectUri,
state,
scope: LaunchProbeFlow.PADDING,
code_challenge: "test-challenge",
code_challenge_method: "S256",
}).toString();
this.authUrls.push(url);
return { url };
}
async exchangeToken(): Promise<OAuthCredentials> {
return { access: "unused", refresh: "unused", expires: Date.now() + 60_000 };
}
}
/**
* Start a flow and resolve once `onAuth` fires — that's the exact instant
* `/launch` becomes live, so tests can hit it without a wall-clock sleep.
* Returns the captured auth info, the abort controller (so tests can shut the
* flow down), and the pending `login` promise (so tests can await teardown).
*/
async function startFlowAndWaitForAuth(): Promise<{
info: OAuthAuthInfo;
abort: AbortController;
login: Promise<void>;
}> {
const abort = new AbortController();
const authFired = Promise.withResolvers<OAuthAuthInfo>();
const flow = new LaunchProbeFlow(
{
onAuth: info => {
authFired.resolve(info);
},
signal: abort.signal,
},
{ preferredPort: 0, allowPortFallback: true },
);
// Kick off login in the background; tests own its lifetime via `abort`.
const login = flow.login().catch(() => undefined) as Promise<void>;
const info = await authFired.promise;
return { info, abort, login };
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("OAuthCallbackFlow /launch route", () => {
it("advertises a short launch URL and 302s it to the pending authorization URL", async () => {
const { info, abort, login } = await startFlowAndWaitForAuth();
// Contract 1 — launch URL is short and shaped like a loopback URL. A
// terminal that truncates below ~40 columns is degenerate; anything above
// that keeps the launch URL intact regardless of the full URL length.
expect(info.launchUrl).toBeDefined();
expect(info.launchUrl!.length).toBeLessThan(40);
expect(info.launchUrl).toMatch(/^http:\/\/localhost:\d+\/launch$/);
// Contract 2 — GET /launch returns 302 pointing at the pending authorize URL,
// byte-for-byte (the whole point: no truncation surface between UI and provider).
const response = await fetch(info.launchUrl!, { redirect: "manual" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(info.url);
abort.abort("test done");
await login;
});
it("stops answering /launch once the flow completes so no stale URL is redirected", async () => {
const { info, abort, login } = await startFlowAndWaitForAuth();
expect(info.launchUrl).toBeDefined();
abort.abort("test done");
await login;
// Server has stopped and `#pendingAuthUrl` was cleared — the launch URL
// no longer connects. The correct end-state is that the redirect NEVER
// points at a stale URL; the loopback socket is gone so `fetch` rejects.
await expect(fetch(info.launchUrl!)).rejects.toThrow();
});
it("routes `/callback` and `/launch` on the same server without interfering", async () => {
const { info, abort, login } = await startFlowAndWaitForAuth();
expect(info.launchUrl).toBeDefined();
// A GET at an unrelated path still 404s — `/launch` is additive, not a
// blanket catch-all.
const origin = new URL(info.launchUrl!).origin;
const stray = await fetch(`${origin}/nope`);
expect(stray.status).toBe(404);
abort.abort("test done");
await login;
});
it("suppresses launchUrl and routes /launch to the callback handler when callbackPath is /launch", async () => {
const abort = new AbortController();
const authFired = Promise.withResolvers<OAuthAuthInfo>();
const flow = new LaunchProbeFlow(
{
onAuth: info => {
authFired.resolve(info);
},
signal: abort.signal,
},
// Caller pins the provider redirect at `/launch` — an OMP config
// setting `oauth.callbackPath: "/launch"` or a matching
// `oauth.redirectUri`. Callback resolution MUST win the route
// collision, and no self-redirecting launchUrl should be advertised.
{ preferredPort: 0, allowPortFallback: true, callbackPath: "/launch" },
);
const login = flow.login().catch(() => undefined) as Promise<void>;
const info = await authFired.promise;
// Contract — no launchUrl surfaced when it would collide.
expect(info.launchUrl).toBeUndefined();
// Contract — a provider redirect to `/launch?code=…&state=…` resolves the
// real callback rather than self-redirecting to the authorize URL.
const authUrl = new URL(info.url);
const redirectUri = authUrl.searchParams.get("redirect_uri");
expect(redirectUri).toMatch(/^http:\/\/localhost:\d+\/launch$/);
const state = authUrl.searchParams.get("state") ?? "";
const callbackResponse = await fetch(`${redirectUri}?code=test-code&state=${encodeURIComponent(state)}`, {
redirect: "manual",
});
// Callback handler responds with the templated HTML page (200), never a 302.
expect(callbackResponse.status).toBe(200);
expect(callbackResponse.headers.get("content-type")).toContain("text/html");
abort.abort("test done");
await login;
});
it("suppresses launchUrl even when only redirectUri (not callbackPath) resolves to /launch", async () => {
const abort = new AbortController();
const authFired = Promise.withResolvers<OAuthAuthInfo>();
const flow = new LaunchProbeFlow(
{
onAuth: info => {
authFired.resolve(info);
},
signal: abort.signal,
},
{
preferredPort: 0,
allowPortFallback: true,
// Base-class caller: `redirectUri` pinned at `/launch` while
// `callbackPath` stays at the default. `MCPOAuthFlow` normally
// derives `callbackPath` from `redirectUri.pathname`, but the
// base class doesn't, and the launchUrl guard must still catch
// the collision defensively.
redirectUri: "http://localhost:14599/launch",
},
);
const login = flow.login().catch(() => undefined) as Promise<void>;
const info = await authFired.promise;
expect(info.launchUrl).toBeUndefined();
abort.abort("test done");
await login;
});
});