Files
oh-my-pi/packages/coding-agent/test/modes/controllers/mcp-authorization-link.test.ts
T
Dylan Bohlender 54f0a00dd2 fix(oauth): copy-safe URL chunks and loopback-only launch URLs
Resolves the two Codex P2s raised on #4420 that merged unaddressed:

- wrapUrlRows indented every continuation chunk. A multi-row terminal
  selection includes the newline plus that indent; address bars strip
  newlines but preserve or percent-encode embedded spaces, so the
  reassembled URL was corrupted at every chunk boundary - silently,
  when the damage landed inside a query value. Chunk rows now carry
  zero leading bytes (label rows keep their indent), and the test
  reassembly helper concatenates chunks raw instead of stripping the
  indent that previously masked exactly this defect.

- #launchUrlIfSafe advertised a localhost /launch copy target for
  flows whose redirectUri never returns to the loopback server. Its
  catch-comment assumed custom-scheme URIs are non-parseable, but
  new URL('vscode://gitlab.gitlab-workflow/authentication') parses
  fine and sailed through the pathname check. The guard now requires
  an http(s) loopback redirectUri (localhost / 127.0.0.1 / [::1]);
  custom schemes, non-loopback hosts, and unparseable URIs all
  suppress the launch URL. Regression tests cover the GitLab Duo
  vscode:// shape and a fixed non-loopback HTTPS redirect.

Refs #4418
2026-07-05 16:01:30 -06:00

152 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { MCPAuthorizationLinkPrompt } from "@oh-my-pi/pi-coding-agent/modes/controllers/mcp-command-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { visibleWidth } from "@oh-my-pi/pi-tui";
const OSC = "\x1b]";
const BEL = "\x07";
function extractLinkUri(text: string): string | undefined {
return text.match(/\x1b\]8;[^;]*;([^\x1b\x07]+)(?:\x1b\\|\x07)/)?.[1];
}
const COPY_URL_LABEL = "Copy URL:";
const SHORTCUT_LABEL = "Local shortcut (this machine only):";
const LONG_AUTH_URL =
"https://mcp.notion.com/oauth/authorize?response_type=code&client_id=notion-mcp-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A17895%2Fcallback&scope=read%3Aworkspace%20read%3Acontent&state=abcdef0123456789abcdef0123456789";
// Linear-shaped repro from #4418: the whole point of the fix is that the
// trailing `code_challenge_method=S256` is preserved even when the composed
// row would exceed the viewport.
const LINEAR_AUTH_URL = `https://mcp.linear.app/authorize?response_type=code&client_id=abcdefghij0123456789ABCDEFGHIJ0123456789&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=read%20write%20mcp%3Aall&state=0123456789abcdef0123456789abcdef&code_challenge=5MlkJfN2GhX9uP0rQ7sT8vB1oCwDeFgHiJkLmNoPqRsTuVwXyZ&code_challenge_method=S256`;
/**
* Reassemble the copy-URL rows for `label` into a single string, mirroring a
* real multi-row terminal selection pasted into an address bar: browsers
* strip the newlines, but any other leading bytes survive (verbatim or
* percent-encoded) — so chunks are concatenated RAW, with no indent-stripping
* that could mask a corrupting prefix. Returns "" if the label row isn't
* found.
*/
function reassembleUrl(plainLines: string[], label: string): string {
const start = plainLines.findIndex(line => line.startsWith(` ${label}`));
if (start < 0) return "";
const first = plainLines[start]!;
// Inline form contains the whole URL on the label row.
const inlineMatch = first.match(new RegExp(`^ ${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (.*)$`));
if (inlineMatch) return inlineMatch[1]!;
// Wrapped form: indented label row, then UNINDENTED continuation chunks.
// Any indented row (the next label) or blank row ends this URL's chunks.
let joined = "";
for (let i = start + 1; i < plainLines.length; i++) {
const line = plainLines[i]!;
if (line.startsWith(" ") || line.trim().length === 0) break;
joined += line;
}
return joined;
}
describe("MCPAuthorizationLinkPrompt", () => {
beforeEach(async () => {
initTheme();
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
afterEach(() => {
settings.clearOverride("tui.hyperlinks");
resetSettingsForTest();
});
it("renders a clickable hyperlink label and inline Copy URL row when the row fits the viewport", () => {
// Width large enough to keep ` Copy URL: <LONG_AUTH_URL>` on one row.
const lines = new MCPAuthorizationLinkPrompt(LONG_AUTH_URL).render(1000);
const plainLines = lines.map(line => stripVTControlCharacters(line));
expect(lines).toHaveLength(3);
expect(lines[1]).toContain(`${OSC}8;`);
expect(lines[1]).toContain(`${OSC}8;;${BEL}`);
expect(extractLinkUri(lines[1])).toBe(LONG_AUTH_URL);
expect(plainLines[1]).toContain("Click here to authorize");
expect(plainLines[2]).toBe(` ${COPY_URL_LABEL} ${LONG_AUTH_URL}`);
});
it("hard-wraps the full URL into width-fitted chunks so a narrow viewport cannot silently drop trailing parameters", () => {
// #4418 fingerprint: narrow terminal + long S256 URL. Pre-fix, the
// row went through `truncateToWidth(..., Ellipsis.Omit)` and dropped
// `code_challenge_method=S256`. Post-fix, the render itself keeps
// every character.
const width = 80;
const lines = new MCPAuthorizationLinkPrompt(LINEAR_AUTH_URL).render(width);
const plainLines = lines.map(line => stripVTControlCharacters(line));
// Every emitted row fits inside the viewport, so `TUI#prepareLine`'s
// truncation branch is unreachable.
for (const line of plainLines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
// Wrapping puts the label on its own indented row followed by
// UNINDENTED continuation chunks — zero leading bytes, so a multi-row
// selection pastes back to the exact URL.
const labelRow = plainLines.indexOf(` ${COPY_URL_LABEL}`);
expect(labelRow).toBeGreaterThanOrEqual(0);
expect(plainLines[labelRow + 1]!.startsWith(" ")).toBe(false);
// Chunks reassemble to the URL byte-for-byte — the trailing
// `code_challenge_method=S256` MUST be present.
const reassembled = reassembleUrl(plainLines, COPY_URL_LABEL);
expect(reassembled).toBe(LINEAR_AUTH_URL);
expect(reassembled).toEndWith("&code_challenge_method=S256");
});
it("keeps the full URL as the primary copy target even when a launch shortcut is available", () => {
const launchUrl = "http://localhost:14570/launch";
const lines = new MCPAuthorizationLinkPrompt(LINEAR_AUTH_URL, launchUrl).render(80);
const plainLines = lines.map(line => stripVTControlCharacters(line));
// Full URL primacy: an SSH user copying the top URL after the label
// still gets the whole authorize URL, byte-for-byte, so their local
// browser reaches the provider.
expect(reassembleUrl(plainLines, COPY_URL_LABEL)).toBe(LINEAR_AUTH_URL);
// OSC 8 hyperlink continues to carry the full URL.
expect(extractLinkUri(lines[1])).toBe(LINEAR_AUTH_URL);
});
it("advertises launchUrl as an additional local shortcut, wrapped to width so it also can never truncate", () => {
const launchUrl = "http://localhost:14570/launch";
const width = 80;
const lines = new MCPAuthorizationLinkPrompt(LINEAR_AUTH_URL, launchUrl).render(width);
const plainLines = lines.map(line => stripVTControlCharacters(line));
for (const line of plainLines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
// Local-shortcut row is emitted, either inline (when the label + URL
// fit) or wrapped. Either way it reassembles to `launchUrl` exactly.
expect(plainLines.some(line => line.includes(SHORTCUT_LABEL))).toBe(true);
expect(reassembleUrl(plainLines, SHORTCUT_LABEL)).toBe(launchUrl);
});
it("omits the local-shortcut row when launchUrl is absent or identical to the full URL", () => {
const withoutLaunch = new MCPAuthorizationLinkPrompt(LONG_AUTH_URL).render(1000);
expect(withoutLaunch.some(line => line.includes(SHORTCUT_LABEL))).toBe(false);
const withRedundantLaunch = new MCPAuthorizationLinkPrompt(LONG_AUTH_URL, LONG_AUTH_URL).render(1000);
expect(withRedundantLaunch.some(line => line.includes(SHORTCUT_LABEL))).toBe(false);
});
it("floors the wrap width so degenerately-narrow viewports still emit every character", () => {
// Below 16 cols the terminal is unusable, but the render still emits
// chunks (bounded at the 16-column floor). No character is silently
// dropped; the user can widen and reflow.
const lines = new MCPAuthorizationLinkPrompt(LINEAR_AUTH_URL).render(4);
const plainLines = lines.map(line => stripVTControlCharacters(line));
expect(reassembleUrl(plainLines, COPY_URL_LABEL)).toBe(LINEAR_AUTH_URL);
});
});