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
5 changes: 5 additions & 0 deletions .changeset/refactor-attach-root-url-595.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ait-co/devtools": patch
---

디버그 대시보드 사용자 URL을 `/attach?u=<deep-link>` 에서 루트 `/`(server-state 렌더)로 수렴 (#595) — 주소창·히스토리에서 TOTP at=·tunnel host 노출 표면 제거. `/attach?u=` 라우트는 back-compat으로 유지.
35 changes: 30 additions & 5 deletions src/mcp/__tests__/qr-browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* QR HTTP 서버 + browser open 기능 테스트 (issue #244):
* QR HTTP 서버 + browser open 기능 테스트 (issue #244, #595):
* - startQrHttpServer: /attach → HTML (base64 inline QR + 스캔 절차 + 진단 체크리스트)
* - startQrHttpServer: /qr.png → image/png + PNG magic bytes
* - buildAttachPageUrl: 루트 `/` URL 반환 (#595 — 시크릿 없는 주소창 노출)
* - openQrInBrowser: httpUrl이 루트 `/`이고 `?u=`/`at=`를 포함하지 않음
* - openQrInBrowser: platform별 fallback chain, 1차 실패 → 2차 호출
* - openQrInBrowser: 모두 실패 시 URL + stderr 안내
* - SECRET-HANDLING: at= 코드가 stderrSummary에서 redact되는지
Expand Down Expand Up @@ -87,12 +89,13 @@ describe('canOpenBrowser', () => {

describe('startQrHttpServer', () => {
it('GET /attach → HTML with base64 inline QR + scan steps + diagnostic checklist + attachUrl', async () => {
// buildAttachPageUrl은 루트 `/`를 반환하므로 (#595), /attach 라우트를 직접 테스트.
const { startQrHttpServer } = await import('../qr-http-server.js');
const srv = await startQrHttpServer();

const attachUrl =
'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.trycloudflare.com';
const pageUrl = srv.buildAttachPageUrl(attachUrl);
const pageUrl = `http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`;

const res = await fetch(pageUrl, { headers: { 'Accept-Language': 'ko' } });
expect(res.status).toBe(200);
Expand Down Expand Up @@ -144,15 +147,17 @@ describe('startQrHttpServer', () => {
await srv.close();
});

it('buildAttachPageUrl encodes attachUrl into /attach?u= query', async () => {
it('buildAttachPageUrl returns root / without query string (#595 — 시크릿 노출 표면 축소)', async () => {
const { startQrHttpServer } = await import('../qr-http-server.js');
const srv = await startQrHttpServer();

const attachUrl = 'intoss-private://app?_deploymentId=abc&debug=1&relay=wss://r.tc.com';
const url = srv.buildAttachPageUrl(attachUrl);

expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/attach\?u=/);
expect(url).toContain(encodeURIComponent(attachUrl));
// 루트 `/`를 반환해야 한다 — 쿼리 없음, at= 없음.
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
expect(url).not.toContain('?u=');
expect(url).not.toContain('at=');

await srv.close();
});
Expand Down Expand Up @@ -385,6 +390,26 @@ describe('openQrInBrowser', () => {
expect(result.stderrSummary).not.toContain('ABC123');
expect(result.stderrSummary).toContain('at=<redacted>');
});

// #595: buildAttachPageUrl 이 루트 `/`를 반환하므로, 실제 caller가 넘기는 httpUrl은 루트 URL이다.
// openQrInBrowser 자체는 인자를 에코하므로, 루트 URL을 넘겼을 때 결과가 시크릿 없는 루트임을 검증.
it('#595: httpUrl이 루트 `/`이면 결과에 ?u= / at= 포함되지 않음', async () => {
setPlatform('darwin');
const { spawnSync } = await import('node:child_process');
(spawnSync as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0, stderr: '', error: null });

const rootUrl = 'http://127.0.0.1:12345/';
const pngUrl = 'http://127.0.0.1:12345/qr.png?u=intoss-private%3A%2F%2Fapp';
const result = await openQrInBrowser(rootUrl, pngUrl);

expect(result.opened).toBe(true);
expect(result.httpUrl).toBe(rootUrl);
// httpUrl에 시크릿 포함 금지 — 쿼리 파라미터 없음
expect(result.httpUrl).not.toContain('?u=');
expect(result.httpUrl).not.toContain('at=');
// pngUrl은 여전히 ?u= 를 가질 수 있음 (stateless 이미지 헬퍼)
expect(result.pngUrl).toContain('/qr.png');
});
});

// ---------------------------------------------------------------------------
Expand Down
85 changes: 66 additions & 19 deletions src/mcp/__tests__/qr-http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,9 @@ describe('startQrHttpServer — SSE /events', () => {
// ---------------------------------------------------------------------------

describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주입)', () => {
it('GET /attach → HTML (dashboard 주입 시에도 동일)', async () => {
it('buildAttachPageUrl → 루트 `/` 반환 (#595), GET / → 200 HTML', async () => {
// #595: buildAttachPageUrl이 루트 URL을 반환한다 — 시크릿 없는 주소창 노출.
// 루트 `/`는 buildDashboardHtml(server-state 렌더)을 서빙한다.
const srv = await startQrHttpServer(() => ({
tunnel: { up: true, wssUrl: null },
pages: [],
Expand All @@ -732,11 +734,39 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주
try {
const attachUrl =
'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com';
const res = await fetch(srv.buildAttachPageUrl(attachUrl), {
const pageUrl = srv.buildAttachPageUrl(attachUrl);
// buildAttachPageUrl은 루트 `/`를 반환해야 한다.
expect(pageUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
expect(pageUrl).not.toContain('?u=');

const res = await fetch(pageUrl, {
headers: { 'Accept-Language': 'ko' },
});
expect(res.status).toBe(200);
const html = await res.text();
// 루트 Dashboard HTML — "Attach QR" 섹션이 있음.
expect(html).toContain('Attach QR');
} finally {
await srv.close();
}
});

it('GET /attach?u= → HTML (back-compat 라우트 유지)', async () => {
// /attach?u= 라우트는 back-compat으로 유지됨 (#595).
const srv = await startQrHttpServer(() => ({
tunnel: { up: true, wssUrl: null },
pages: [],
attachUrl: null,
}));
try {
const attachUrl =
'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com';
const res = await fetch(
`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`,
{ headers: { 'Accept-Language': 'ko' } },
);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('QR 스캔');
} finally {
await srv.close();
Expand All @@ -758,9 +788,10 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주
});

// ── /attach page — SSE injection & id="attach-section" (Defect 1 fix, #435) ─
// back-compat: /attach?u= 라우트는 #595 이후에도 유지됨. 이 테스트들은 직접 URL로 검증.

it('GET /attach HTML contains EventSource(/events) SSE script (#435)', async () => {
// buildAttachHtml now injects buildSseScript so the /attach page subscribes
// buildAttachHtml injects buildSseScript so the /attach page subscribes
// to /events and can update the QR img live — avoiding expired TOTP at= codes.
const srv = await startQrHttpServer(() => ({
tunnel: { up: true, wssUrl: null },
Expand All @@ -770,9 +801,11 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주
try {
const attachUrl =
'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com';
const res = await fetch(srv.buildAttachPageUrl(attachUrl), {
headers: { 'Accept-Language': 'ko' },
});
// /attach?u= 직접 사용 (back-compat 라우트, #595 이후에도 유지됨).
const res = await fetch(
`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`,
{ headers: { 'Accept-Language': 'ko' } },
);
expect(res.status).toBe(200);
const html = await res.text();
// SSE subscription script must be present.
Expand All @@ -794,8 +827,9 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주
try {
const attachUrl =
'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com';
// /attach?u= 직접 사용 (back-compat 라우트, #595 이후에도 유지됨).
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrl), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand All @@ -820,7 +854,10 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주
try {
const markerCode = '123456'; // 6-digit stand-in (not a real TOTP value)
const attachUrl = `intoss-private://app?_deploymentId=test&debug=1&relay=wss%3A%2F%2Fx.tc.com&at=${markerCode}`;
const html = await (await fetch(srv.buildAttachPageUrl(attachUrl))).text();
// /attach?u= 직접 사용 (back-compat 라우트, #595 이후에도 유지됨).
const html = await (
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`)
).text();
// The at= code is inside attachUrl (url-box + QR src placeholder) — not a
// standalone field. No text node outside the url or QR img should contain
// a bare 6-digit code as an isolated token.
Expand Down Expand Up @@ -1067,14 +1104,15 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', (
// ── /attach 표면 ─────────────────────────────────────────────────────────

it('GET /attach — .url-row + .copy-btn + id="url-box" 포함', async () => {
// back-compat 라우트 직접 테스트 (#595).
const srv = await startQrHttpServer(() => ({
tunnel: { up: true, wssUrl: null },
pages: [],
attachUrl: null,
}));
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand All @@ -1089,14 +1127,15 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', (
it('GET /attach — id="url-section" 섹션에 url-row 포함 (url-box는 #url-section에만)', async () => {
// 이중 표시 방지 (#458): url-box가 #attach-section 안에 없고
// #url-section 안에만 있어야 한다.
// back-compat 라우트 직접 테스트 (#595).
const srv = await startQrHttpServer(() => ({
tunnel: { up: true, wssUrl: null },
pages: [],
attachUrl: null,
}));
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand All @@ -1117,14 +1156,15 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', (
it('GET /attach — SSE 스크립트가 attach 표면 분기(img src 교체 + #url-box textContent 갱신) 포함', async () => {
// /attach 표면: innerHTML 전체 교체가 아니라 img src 교체 + url-box textContent만 갱신해야
// SSE push 때 url-box가 이중으로 생기지 않는다(#458 핵심 수정).
// back-compat 라우트 직접 테스트 (#595).
const srv = await startQrHttpServer(() => ({
tunnel: { up: true, wssUrl: null },
pages: [],
attachUrl: null,
}));
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand Down Expand Up @@ -1174,10 +1214,12 @@ describe('startQrHttpServer — /attach mode-aware chrome 분기 (#468)', () =>
attachUrl: string,
lang: 'ko' | 'en',
): Promise<string> {
// /attach?u= 직접 사용 — back-compat 라우트 (#595 이후에도 유지됨).
// mode-aware 카피 분기는 /attach 라우트에서 렌더되므로 직접 URL로 검증한다.
const srv = await startQrHttpServer(() => makeState(mode, attachUrl));
try {
return await (
await fetch(srv.buildAttachPageUrl(attachUrl), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`, {
headers: { 'Accept-Language': lang },
})
).text();
Expand Down Expand Up @@ -1451,6 +1493,10 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () =
const attachUrlDummy =
'intoss-private://aitc-sdk-example?_deploymentId=test-inspector&debug=1&relay=wss%3A%2F%2Fx.tc.com';

// #595: 이 테스트들은 /attach?u= 라우트를 직접 사용 (back-compat 유지됨).
// buildAttachPageUrl은 루트 `/`를 반환하지만 /attach 라우트의 inspector 동작을 검증하려면
// 직접 URL이 필요하다.

it('getDirectInspectorUrl 주입 + pages.length > 0 → "디버그 툴 열기" 활성 버튼(ko)', async () => {
const srv = await startQrHttpServer(
() => ({
Expand All @@ -1468,7 +1514,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () =
);
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand Down Expand Up @@ -1501,7 +1547,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () =
);
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand All @@ -1522,7 +1568,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () =
}));
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand All @@ -1545,7 +1591,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () =
);
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, {
headers: { 'Accept-Language': 'ko' },
})
).text();
Expand Down Expand Up @@ -1577,9 +1623,10 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () =
capturedPort = srv.port;
try {
const html = await (
await fetch(srv.buildAttachPageUrl(attachUrlDummy), {
headers: { 'Accept-Language': 'ko' },
})
await fetch(
`http://127.0.0.1:${capturedPort}/attach?u=${encodeURIComponent(attachUrlDummy)}`,
{ headers: { 'Accept-Language': 'ko' } },
)
).text();
// href는 /inspector 안정 URL (127.0.0.1 로컬, 시크릿 없음)
expect(html).toContain(`http://127.0.0.1:${capturedPort}/inspector`);
Expand Down
9 changes: 7 additions & 2 deletions src/mcp/qr-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,8 +894,13 @@ export async function startQrHttpServer(

return {
port,
buildAttachPageUrl(attachUrl: string): string {
return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`;
buildAttachPageUrl(_attachUrl: string): string {
// 사용자 대면 URL을 루트 `/`로 수렴 (#595).
// 같은 데몬이 attachUrl을 이미 server-state(getDashboardState)로 보유하므로
// `/attach?u=<encoded>` 쿼리는 redundant하다.
// SECRET-HANDLING: 브라우저에 열리는 URL에서 tunnel host·relay wss·TOTP at= 제거.
// /attach?u= 라우트 자체는 back-compat으로 유지(기존 인쇄된 링크 보호).
return `http://127.0.0.1:${port}/`;
},
// 안정 인스펙터 진입점 URL (issue #530) — 클릭 시 302 redirect (TOTP 클릭 시점 mint).
// URL 자체에 시크릿 없음 → 대시보드/stdout/로그 어디든 출력 가능.
Expand Down
12 changes: 6 additions & 6 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,13 +932,13 @@ export function canOpenBrowser(): boolean {
* Result of `openQrInBrowser`.
*
* HTTP URL 기반으로 재구현 — tmp 파일 없음. `httpUrl`이 브라우저에 전달되는 URL이다.
* SECRET-HANDLING: `httpUrl`은 127.0.0.1 로컬 전용이며 at= 코드 값을 직접 담지 않는다
* (attachUrl은 /attach?u= query로 전달되어 서버 메모리에서만 처리).
* SECRET-HANDLING: `httpUrl`은 127.0.0.1 로컬 전용이며 tunnel host·relay wss·TOTP at= 코드를
* 담지 않는다 (#595). attachUrl은 server-state(getDashboardState)로만 보유된다.
*/
export interface OpenQrInBrowserResult {
/** `true` if the browser was successfully opened. */
opened: boolean;
/** `http://127.0.0.1:<port>/attach?u=...` — 브라우저에 전달된 URL. */
/** `http://127.0.0.1:<port>/` — 브라우저에 전달되는 루트 URL (#595). */
httpUrl: string;
/** `http://127.0.0.1:<port>/qr.png?u=...` — PNG fallback URL. */
pngUrl: string;
Expand Down Expand Up @@ -1002,19 +1002,19 @@ function isLaunchFailureStderr(stderr: string): boolean {
}

/**
* 로컬 HTTP 서버 URL(`http://127.0.0.1:<port>/attach?u=...`)을 OS 기본 브라우저로 연다.
* 로컬 HTTP 서버 루트 URL(`http://127.0.0.1:<port>/`)을 OS 기본 브라우저로 연다 (#595).
*
* platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
* (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
* `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
*
* SECRET-HANDLING:
* - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
* - httpUrl/pngUrl은 127.0.0.1 로컬 전용.
* - httpUrl은 `http://127.0.0.1:<port>/`(루트, 시크릿 없음). pngUrl은 127.0.0.1 로컬 전용.
* - stderr 캡처 결과에서 at= 코드 값을 redact한 후 stderrSummary에 포함.
* - attachUrl, deploymentId, TOTP 코드를 stdout/stderr/로그에 직접 출력 금지.
*
* @param httpUrl - `http://127.0.0.1:<port>/attach?u=<encoded>` HTTP URL.
* @param httpUrl - `http://127.0.0.1:<port>/` 루트 URL (시크릿 없음, #595).
* @param pngUrl - `http://127.0.0.1:<port>/qr.png?u=<encoded>` PNG fallback URL.
*/
export async function openQrInBrowser(
Expand Down
Loading