From 74a5f9103043201a929f22d2ad17d612c1633742 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Wed, 29 Jul 2026 11:44:22 -0400 Subject: [PATCH 1/3] fix(backend): fail closed on rejected tool credentials at the proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveUser` mapped a `wtk_` bearer to a user or to null, and null was indistinguishable from "no session at all". `attributeRequest` serves sessionless traffic on the demo key when one is configured, so a revoked or expired tool token still bought AI access — billed to the shared demo bucket rather than being refused. Replace `resolveUser` in the proxy's options with `resolveIdentity`, returning a three-state `ProxyIdentity`. A presented-but-invalid tool credential is now its own state and 401s before any upstream fetch, instead of collapsing into the sessionless path. Auth failures that originate here (rather than upstream at OpenAI) also now carry `X-Writing-Tools-Error: platform-auth` and an `{ error: { code: 'platform_auth' } }` body, and the header is added to the CORS expose list. A cross-origin tool cannot read a response header the server does not expose, and it needs to tell "your Writing Tools grant died, re-launch me" apart from "the model provider rejected the request" — only the first is worth sending the writer back to the add-in for. An upstream 401 deliberately does not get the marker. Extracted from #569 / #570 so it can be reviewed on its own. Co-Authored-By: Nhyira Mante Co-Authored-By: Claude Opus 5 --- backend/src/__tests__/handoff.test.ts | 32 ++++++++- backend/src/__tests__/openaiProxy.test.ts | 82 ++++++++++++++++++++++- backend/src/app.ts | 27 ++++++-- backend/src/openaiProxy.ts | 37 ++++++++-- 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/backend/src/__tests__/handoff.test.ts b/backend/src/__tests__/handoff.test.ts index 3d7b4841..b9c4a0ff 100644 --- a/backend/src/__tests__/handoff.test.ts +++ b/backend/src/__tests__/handoff.test.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../app.js'; import type { Auth } from '../auth.js'; -import { closeDb } from '../db.js'; +import { closeDb, db } from '../db.js'; +import { PLATFORM_AUTH_ERROR_HEADER } from '../openaiProxy.js'; const env = { ...process.env }; @@ -19,6 +20,7 @@ beforeEach(async () => { afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); closeDb(); process.env = { ...env }; }); @@ -175,6 +177,34 @@ describe('tool token end-to-end', () => { const res = await post(createApp(), '/api/log', { event: 'after_revoke' }, token); expect(res.status).toBe(401); }); + + it.each(['revoked', 'expired'] as const)( + 'fails a %s tool token closed at the proxy even with demo access', + async (state) => { + vi.stubEnv('OPENAI_DEMO_API_KEY', 'demo-key'); + const token = await tokenFor(['openai:chat']); + if (state === 'revoked') { + await post(createApp(), '/api/handoff/revoke', {}, token); + } else { + db() + .prepare( + 'UPDATE tool_grant SET token_expires_at = ? WHERE access_token = ?', + ) + .run(Date.now() - 1, token); + } + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const res = await post( + createApp(), + '/api/openai/chat/completions', + { model: 'gpt-4o' }, + token, + ); + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); }); describe('X-Client-Id header attribution (device-flow tools)', () => { diff --git a/backend/src/__tests__/openaiProxy.test.ts b/backend/src/__tests__/openaiProxy.test.ts index e2d79664..6f93f344 100644 --- a/backend/src/__tests__/openaiProxy.test.ts +++ b/backend/src/__tests__/openaiProxy.test.ts @@ -9,6 +9,7 @@ import { attributeRequest, createSseUsageScanner, parseUsage, + PLATFORM_AUTH_ERROR_HEADER, } from '../openaiProxy.js'; import { ANONYMOUS_USER_ID, @@ -252,6 +253,33 @@ describe('POST /api/openai/chat/completions', () => { ); expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects a presented invalid tool token even when demo access is configured', async () => { + vi.stubEnv('OPENAI_DEMO_API_KEY', 'demo-key'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const res = await appWithUser(null).request( + '/api/openai/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wtk_invalid-or-revoked', + }, + body: JSON.stringify({ model: 'gpt-4o' }), + }, + ); + + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(await res.json()).toEqual({ + error: { code: 'platform_auth' }, + detail: 'Tool access token is invalid or expired.', + }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -281,9 +309,48 @@ describe('POST /api/openai/chat/completions', () => { ); expect(res.status).toBe(403); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); expect(fetchMock).not.toHaveBeenCalled(); }); + it('does not mark an upstream OpenAI authentication error as platform auth', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ error: { message: 'provider key' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + const res = await appWithUser(real('usr-upstream')).request( + '/api/openai/chat/completions', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-4o' }), + }, + ); + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBeNull(); + }); + + it('exposes the platform-auth marker header through CORS', async () => { + const res = await appWithUser(null).request( + '/api/openai/chat/completions', + { + method: 'OPTIONS', + headers: { + Origin: 'https://mindmap.example', + 'Access-Control-Request-Method': 'POST', + }, + }, + ); + expect(res.headers.get('Access-Control-Expose-Headers')).toContain( + PLATFORM_AUTH_ERROR_HEADER, + ); + }); + it('serves a sessionless request on the demo key and meters it to `demo`', async () => { // Demo mode sends a token that isn't a session, so it lands here — the path // that would otherwise 401 once the proxy started requiring auth. @@ -558,12 +625,20 @@ describe('POST /api/openai/responses', () => { ); vi.stubGlobal('fetch', fetchMock); + const requestBody = { + model: 'gpt-4o', + input: 'hi', + tools: [{ type: 'web_search_preview' }], + temperature: 0.4, + max_output_tokens: 321, + metadata: { surface: 'mindmap' }, + }; const res = await appWithUser(real('usr-r')).request( '/api/openai/responses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'gpt-4o', input: 'hi' }), + body: JSON.stringify(requestBody), }, ); @@ -571,6 +646,11 @@ describe('POST /api/openai/responses', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe( 'https://api.openai.com/v1/responses', ); + expect( + JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.body), + ), + ).toEqual(requestBody); expect(await res.json()).toEqual(body); const rows = await waitForUsage(); expect(rows[0]).toMatchObject({ diff --git a/backend/src/app.ts b/backend/src/app.ts index b83cdf70..ba89c4d6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,12 @@ import { import { deviceClientIds, gitCommit, logSecret } from './config.js'; import { eraseLoggedData } from './erasure.js'; import { appendLog, pollLogs, zipLogs } from './logging.js'; -import { attributeRequest, openaiProxy } from './openaiProxy.js'; +import { + attributeRequest, + openaiProxy, + PLATFORM_AUTH_ERROR_HEADER, + type ProxyIdentity, +} from './openaiProxy.js'; import { captureException, posthogMiddleware } from './posthog.js'; import { costUsd } from './pricing.js'; import { @@ -74,7 +79,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { const app = new Hono(); // CORS stays fully permissive for now to preserve existing behaviour. - app.use('*', cors()); + app.use('*', cors({ exposeHeaders: [PLATFORM_AUTH_ERROR_HEADER] })); app.use('*', posthogMiddleware); if (auth) { @@ -101,7 +106,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { openaiProxy('chat/completions', { // The proxy only reads { id, isAnonymous }; resolveUser also returns // loggingConsent, which is structurally compatible and simply ignored here. - resolveUser, + resolveIdentity: resolveProxyIdentity, authEnabled: !!auth, }), ); @@ -110,7 +115,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { app.post( '/api/openai/responses', openaiProxy('responses', { - resolveUser, + resolveIdentity: resolveProxyIdentity, authEnabled: !!auth, }), ); @@ -221,6 +226,20 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { }; } + async function resolveProxyIdentity(c: Context): Promise { + const bearer = bearerToken(c); + if (bearer?.startsWith('wtk_')) { + const tool = resolveToolToken(bearer); + return tool + ? { kind: 'authenticated', user: tool.user } + : { kind: 'rejected_tool_credential' }; + } + const user = await resolveUser(c); + return user + ? { kind: 'authenticated', user } + : { kind: 'sessionless' }; + } + // Client event logging. Requires an authenticated session: the log is keyed by // the Better Auth user id (not a client-supplied name), and content fields are // stripped to the user's consent level before they ever hit disk. diff --git a/backend/src/openaiProxy.ts b/backend/src/openaiProxy.ts index b60bb285..d2d4ec85 100644 --- a/backend/src/openaiProxy.ts +++ b/backend/src/openaiProxy.ts @@ -39,9 +39,16 @@ export type ProxyUser = Pick< 'id' | 'isAnonymous' | 'isAllowed' | 'clientId' >; +export type ProxyIdentity = + | { kind: 'authenticated'; user: ProxyUser } + | { kind: 'sessionless' } + | { kind: 'rejected_tool_credential' }; + +export const PLATFORM_AUTH_ERROR_HEADER = 'X-Writing-Tools-Error'; +export const PLATFORM_AUTH_ERROR_VALUE = 'platform-auth'; + export interface ProxyOptions { - /** Authenticated user for this request, or null if there's no session. */ - resolveUser: (c: Context) => Promise; + resolveIdentity: (c: Context) => Promise; /** * Whether auth is configured at all. When it isn't (local dev with * BETTER_AUTH_ENABLED unset), unauthenticated requests are served rather than @@ -223,15 +230,23 @@ function requestedModel(parsed: Record | null): string { export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { return async (c: Context): Promise => { - const user = await options.resolveUser(c); + const identity = await options.resolveIdentity(c); + if (identity.kind === 'rejected_tool_credential') { + return platformAuthError(c, 401, 'Tool access token is invalid or expired.'); + } + const user = identity.kind === 'authenticated' ? identity.user : null; // Enforce the beta allowlist server-side. The client also shows a "not allowed" // screen, but that's UX — this is the gate a disallowed user can't bypass by // calling the proxy directly. isAllowed already folds in anonymous/demo users // (always allowed) and the per-user alwaysAllow grant, so this one check covers // every authenticated case; sessionless traffic is left to attributeRequest. - if (user && !user.isAllowed) return c.json({ detail: 'Forbidden' }, 403); + if (user && !user.isAllowed) { + return platformAuthError(c, 403, 'This account is not permitted.'); + } const attribution = attributeRequest(user, options.authEnabled); - if (!attribution) return c.json({ detail: 'Unauthorized' }, 401); + if (!attribution) { + return platformAuthError(c, 401, 'Authentication is required.'); + } // Which client the tokens are provenance-tagged to (a tool's client_id, or // null for the first-party add-in). Independent of who pays: a demo user's // spend goes to the capped key but the row still names their tool. @@ -369,3 +384,15 @@ export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { }); }; } + +function platformAuthError( + c: Context, + status: 401 | 403, + detail: string, +): Response { + return c.json( + { error: { code: 'platform_auth' }, detail }, + status, + { [PLATFORM_AUTH_ERROR_HEADER]: PLATFORM_AUTH_ERROR_VALUE }, + ); +} From 03d23c0e13f0dc67833eb2e80ef23c48b5f2eac6 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Wed, 29 Jul 2026 11:44:36 -0400 Subject: [PATCH 2/3] feat(frontend): name the document a handoff snapshot came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `doc:read` handoff sends a document snapshot to a tool running in a separate browser tab, on a different origin. Once it lands there the writer has no way to tell which document they are looking at — the tool only ever receives text. Add an optional `documentLabel` to `DocContext`. Word derives it from `Office.context.document.url`; `wordDocumentLabel` is exported and tested separately because the naive read leaks the whole SharePoint or OneDrive path, and the label crosses a trust boundary. It keeps the final path segment only, over both URL and Windows/UNC path forms, and falls back to "Word document" for a document that has never been saved. Google Docs uses the existing `getDocumentName` Apps Script bridge (`google-docs-addon/Code.gs`), so no add-on redeploy is needed; the call is failure-tolerant because a deployment can predate the bridge, and the bundle and the add-on ship separately. Extracted from #569 / #570 so it can be reviewed on its own. Co-Authored-By: Nhyira Mante Co-Authored-By: Claude Opus 5 --- .../api/__tests__/googleDocsEditorAPI.test.ts | 12 ++++++++- .../src/api/__tests__/wordEditorAPI.test.ts | 25 +++++++++++++++++++ frontend/src/api/googleDocsEditorAPI.ts | 7 +++++- frontend/src/api/wordEditorAPI.ts | 21 ++++++++++++++++ frontend/src/editor/index.tsx | 7 +++++- frontend/src/types.d.ts | 2 ++ 6 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/__tests__/wordEditorAPI.test.ts diff --git a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts index b4f51a52..91c53c6a 100644 --- a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts +++ b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts @@ -66,7 +66,10 @@ beforeEach(() => { getDocContextMock = vi.fn().mockResolvedValue(CONTEXT_A); fakeWindow = Object.assign(makeEmitter(), { - GoogleAppsScript: { getDocContext: getDocContextMock }, + GoogleAppsScript: { + getDocContext: getDocContextMock, + getDocumentName: vi.fn().mockResolvedValue('Research notes'), + }, }); fakeDocument = Object.assign(makeEmitter(), { hasFocus: () => focused, @@ -88,6 +91,13 @@ afterEach(() => { }); describe('googleDocsEditorAPI selection polling', () => { + it('includes the Google document title in an explicit context fetch', async () => { + await expect(googleDocsEditorAPI.getDocContext()).resolves.toEqual({ + ...CONTEXT_A, + documentLabel: 'Research notes', + }); + }); + it('pulls immediately and notifies handlers when the selection changes', async () => { getDocContextMock .mockResolvedValueOnce(CONTEXT_A) // initial pull diff --git a/frontend/src/api/__tests__/wordEditorAPI.test.ts b/frontend/src/api/__tests__/wordEditorAPI.test.ts new file mode 100644 index 00000000..8fdc6522 --- /dev/null +++ b/frontend/src/api/__tests__/wordEditorAPI.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { wordDocumentLabel } from '../wordEditorAPI'; + +describe('wordDocumentLabel', () => { + it('uses the decoded filename from the Office document URL', () => { + expect( + wordDocumentLabel('https://example.sharepoint.com/docs/My%20Essay.docx'), + ).toBe('My Essay.docx'); + }); + + it('falls back for unsaved documents', () => { + expect(wordDocumentLabel('')).toBe('Word document'); + expect(wordDocumentLabel(undefined)).toBe('Word document'); + }); + + it.each([ + ['C:\\Users\\writer\\OneDrive\\Essay.docx', 'Essay.docx'], + ['C:/Users/writer/OneDrive/Essay%20Draft.docx', 'Essay Draft.docx'], + ['\\\\server\\private\\team\\Shared.docx', 'Shared.docx'], + ['https://example.test/private/folder/Final%20Draft.docx', 'Final Draft.docx'], + ['https://example.test/private/folder/Bad%ZZ.docx', 'Bad%ZZ.docx'], + ])('never exposes directory components from %s', (input, expected) => { + expect(wordDocumentLabel(input)).toBe(expected); + }); +}); diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 6961e4b0..ec8d8452 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -43,6 +43,7 @@ declare global { setDocumentProperty?: (key: string, value: string) => Promise; getDocumentProperty?: (key: string) => Promise; getDocumentId: () => Promise; + getDocumentName: () => Promise; getAllTabs: () => Promise< { id: string; title: string; text: string }[] >; @@ -269,10 +270,14 @@ export const googleDocsEditorAPI: EditorAPI = { * Gets the current document context (before cursor, selection, after cursor). */ async getDocContext(): Promise { - const context = await window.GoogleAppsScript.getDocContext(); + const [context, documentName] = await Promise.all([ + window.GoogleAppsScript.getDocContext(), + window.GoogleAppsScript.getDocumentName().catch(() => ''), + ]); // Normalize line endings (Google Docs uses \n) return { + documentLabel: documentName.trim() || 'Google Docs document', beforeCursor: context.beforeCursor || '', selectedText: context.selectedText || '', afterCursor: context.afterCursor || '', diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index ef27941c..240b91e7 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,3 +1,23 @@ +export function wordDocumentLabel(url: string | undefined): string { + if (!url) return 'Word document'; + let value = url; + if (/^https?:\/\//i.test(url)) { + try { + value = new URL(url).pathname; + } catch { + // Treat malformed HTTP(S) values as opaque paths below. + } + } + const segments = value.split(/[\\/]/).filter(Boolean); + const filename = segments[segments.length - 1]; + if (!filename) return 'Word document'; + try { + return decodeURIComponent(filename); + } catch { + return filename; + } +} + /** * Whether the host supports `getReviewedText` (WordApi 1.4). We use it to read * the document as if tracked changes were accepted, so the AI never sees deleted @@ -33,6 +53,7 @@ export const wordEditorAPI: EditorAPI = { Word.run(async (context: Word.RequestContext) => { const body: Word.Body = context.document.body; const docContext: DocContext = { + documentLabel: wordDocumentLabel(Office.context.document.url), beforeCursor: '', selectedText: '', afterCursor: '', diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 20cea5be..982ed57f 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -60,7 +60,12 @@ export function EditorScreen({ const editorAPI: EditorAPI = useMemo( () => ({ getDocContext: async (): Promise => { - return Promise.resolve(docContextRef.current); + return Promise.resolve({ + ...docContextRef.current, + documentLabel: + docContextRef.current.documentLabel ?? + 'Standalone editor draft', + }); }, addSelectionChangeHandler: (handler: () => void) => { selectionChangeHandlers.current.push(handler); diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 012fd296..40b2ddeb 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -127,6 +127,8 @@ interface ContextSection { interface DocContext { contextData?: ContextSection[]; + /** Human-readable source label carried with launcher document snapshots. */ + documentLabel?: string; beforeCursor: string; selectedText: string; afterCursor: string; From 6672edf0bea54da55b5b4377395e878ae3f9be53 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Wed, 29 Jul 2026 11:44:48 -0400 Subject: [PATCH 3/3] fix(frontend): reserve the tool window before minting a grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launching ran `await token -> await doc -> await grant -> window.open`. By the time `window.open` was reached the user gesture had expired, so browsers blocked the popup — and the failure came *after* the document had been read and a grant had been minted, leaving a live credential and a snapshot behind for a window that never opened. Reserve the window synchronously on click via `reserveBrowserLaunch` (`about:blank`, `opener` cleared), then `location.replace` it once the URL exists, and close it if any step fails. Office's `context.ui.openBrowserWindow` is not popup-blocked, so that path stays deferred to the end. Extract `launchFirstPartyTool` from the component so the ordering is testable: the new test asserts that a blocked popup reads no document and creates no grant, and that a successful launch reserves first. Also drop the dev-only `test-tool` entry, which was marked "Remove before merge"; drop `log:write` from the mindmap's scopes, since the mindmap keeps its audit ledger local; and let `VITE_MINDMAP_TOOL_URL` point the entry at a local mindmap dev server. The Tools page stays behind the `tool-launcher` flag, so none of this reaches beta users yet. Extracted from #569 / #570 so it can be reviewed on its own. Co-Authored-By: Nhyira Mante Co-Authored-By: Claude Opus 5 --- frontend/src/api/handoff.ts | 49 ++++++++ .../tools/__tests__/tools-config.test.ts | 84 +++++++++++++ frontend/src/pages/tools/index.tsx | 111 ++++++++++++------ frontend/src/vite-env.d.ts | 4 + 4 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 frontend/src/pages/tools/__tests__/tools-config.test.ts diff --git a/frontend/src/api/handoff.ts b/frontend/src/api/handoff.ts index 398a6b01..eff4fdb4 100644 --- a/frontend/src/api/handoff.ts +++ b/frontend/src/api/handoff.ts @@ -84,3 +84,52 @@ export function openInBrowser(url: string): void { } window.open(url, '_blank', 'noopener,noreferrer'); } + +export type BrowserLaunchReservation = + | { kind: 'office' } + | { kind: 'window'; popup: Window }; + +function officeBrowserOpener(): ((url: string) => void) | undefined { + const officeUi = ( + globalThis as { + Office?: { + context?: { ui?: { openBrowserWindow?: (u: string) => void } }; + }; + } + ).Office?.context?.ui; + return officeUi?.openBrowserWindow?.bind(officeUi); +} + +/** + * Reserve a browser popup before any asynchronous work or document access. + * Office's system-browser API does not use popup blocking, so it remains deferred + * until the final URL is ready. + */ +export function reserveBrowserLaunch(): BrowserLaunchReservation | null { + if (officeBrowserOpener()) return { kind: 'office' }; + const popup = window.open('about:blank', '_blank'); + if (!popup) return null; + try { + popup.opener = null; + } catch { + // Some browser wrappers expose a read-only opener. + } + return { kind: 'window', popup }; +} + +export function completeBrowserLaunch( + reservation: BrowserLaunchReservation, + url: string, +): void { + if (reservation.kind === 'office') { + officeBrowserOpener()?.(url); + return; + } + reservation.popup.location.replace(url); +} + +export function cancelBrowserLaunch(reservation: BrowserLaunchReservation): void { + if (reservation.kind === 'window' && !reservation.popup.closed) { + reservation.popup.close(); + } +} diff --git a/frontend/src/pages/tools/__tests__/tools-config.test.ts b/frontend/src/pages/tools/__tests__/tools-config.test.ts new file mode 100644 index 00000000..15f7c3ad --- /dev/null +++ b/frontend/src/pages/tools/__tests__/tools-config.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + launchFirstPartyTool, + MINDMAP_TOOL, + resolveMindmapToolUrl, + type FirstPartyTool, +} from '../index'; + +const mindmap: FirstPartyTool = { + id: 'mindmap', + name: 'Mindmap', + description: 'Test mindmap', + url: 'https://mindmap.example/', + scopes: ['openai:chat', 'doc:read'], +}; + +describe('mindmap tool registration', () => { + it('uses local and production URL defaults with an environment override', () => { + expect(resolveMindmapToolUrl(undefined, true)).toBe( + 'http://localhost:5181/', + ); + expect(resolveMindmapToolUrl(undefined, false)).toBe( + 'https://mindmap.thoughtful-ai.com/', + ); + expect(resolveMindmapToolUrl('https://preview.example/', true)).toBe( + 'https://preview.example/', + ); + }); + + it('requests AI and document-read access without the retired logging scope', () => { + expect(MINDMAP_TOOL.scopes).toEqual(['openai:chat', 'doc:read']); + expect(MINDMAP_TOOL.scopes).not.toContain('log:write'); + }); + + it('does not access the document or backend when the popup is blocked', async () => { + const getAccessToken = vi.fn(); + const getDocContext = vi.fn(); + const createGrant = vi.fn(); + await expect( + launchFirstPartyTool(mindmap, { + getAccessToken, + getDocContext, + createGrant, + reserveLaunch: () => null, + completeLaunch: vi.fn(), + cancelLaunch: vi.fn(), + }), + ).rejects.toThrow('blocked'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(getDocContext).not.toHaveBeenCalled(); + expect(createGrant).not.toHaveBeenCalled(); + }); + + it('reserves before reading a document and completes a granted launch', async () => { + const calls: string[] = []; + const result = await launchFirstPartyTool(mindmap, { + getAccessToken: () => { + calls.push('token'); + return Promise.resolve('token'); + }, + getDocContext: () => { + calls.push('doc'); + return Promise.resolve({ + beforeCursor: 'draft', + selectedText: '', + afterCursor: '', + }); + }, + createGrant: () => { + calls.push('grant'); + return Promise.resolve({ grantId: 'grant', expiresIn: 120 }); + }, + reserveLaunch: () => { + calls.push('reserve'); + return { kind: 'office' }; + }, + completeLaunch: (_reservation, url) => calls.push(`open:${url}`), + cancelLaunch: vi.fn(), + }); + expect(calls.slice(0, 4)).toEqual(['reserve', 'token', 'doc', 'grant']); + expect(calls[4]).toContain('#wt_grant=grant'); + expect(result).toEqual({ sharedDoc: true }); + }); +}); diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx index 9b3d7b9d..732a4e49 100644 --- a/frontend/src/pages/tools/index.tsx +++ b/frontend/src/pages/tools/index.tsx @@ -12,8 +12,12 @@ import { useContext, useState } from 'react'; import { Button } from 'reshaped'; import { + cancelBrowserLaunch, + completeBrowserLaunch, createHandoff, openInBrowser, + reserveBrowserLaunch, + type BrowserLaunchReservation, type ToolScope, withGrantFragment, } from '@/api/handoff'; @@ -23,7 +27,7 @@ import { useAppAuth } from '@/contexts/appAuthContext'; import { useLog } from '@/hooks/useLog'; import classes from './styles.module.css'; -interface FirstPartyTool { +export interface FirstPartyTool { /** Tool client_id — must also be listed in the backend device allowlist. */ id: string; name: string; @@ -38,26 +42,71 @@ interface FirstPartyTool { * backend's BETTER_AUTH_DEVICE_CLIENT_IDS allowlist, and the URL points at wherever * the tool is hosted. A manifest-driven registry replaces this list in a later phase. */ -const FIRST_PARTY_TOOLS: FirstPartyTool[] = [ - { - // Throwaway tool for exercising the handoff flow end-to-end in dev. - // Served from sandbox/test-tool/ (see that dir's README). Remove before merge. - id: 'test-tool', - name: 'Platform API test tool', - description: - 'Dev-only. Exchanges the handoff grant and lets you fire each Platform API v0 call (LLM proxy, log, doc re-fetch, revoke).', - url: 'http://localhost:4000/', - scopes: ['openai:chat', 'log:write', 'doc:read'], - }, - { - id: 'mindmap', - name: 'Mindmap', - description: - 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', - url: 'https://mindmap.thoughtful-ai.com/', - scopes: ['openai:chat', 'log:write', 'doc:read'], - }, -]; +export function resolveMindmapToolUrl( + explicit: string | undefined, + isDevelopment: boolean, +): string { + return ( + explicit || + (isDevelopment + ? 'http://localhost:5181/' + : 'https://mindmap.thoughtful-ai.com/') + ); +} + +export const MINDMAP_TOOL_URL = resolveMindmapToolUrl( + import.meta.env.VITE_MINDMAP_TOOL_URL, + import.meta.env.DEV, +); + +export const MINDMAP_TOOL: FirstPartyTool = { + id: 'mindmap', + name: 'Mindmap', + description: + 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', + url: MINDMAP_TOOL_URL, + scopes: ['openai:chat', 'doc:read'], +}; + +export const FIRST_PARTY_TOOLS: FirstPartyTool[] = [MINDMAP_TOOL]; + +interface LaunchToolDependencies { + getAccessToken(): Promise; + getDocContext(): Promise; + createGrant: typeof createHandoff; + reserveLaunch(): BrowserLaunchReservation | null; + completeLaunch(reservation: BrowserLaunchReservation, url: string): void; + cancelLaunch(reservation: BrowserLaunchReservation): void; +} + +export async function launchFirstPartyTool( + tool: FirstPartyTool, + dependencies: LaunchToolDependencies, +): Promise<{ sharedDoc: boolean }> { + const reservation = dependencies.reserveLaunch(); + if (!reservation) { + throw new Error('Your browser blocked the new window. Allow popups and try again.'); + } + try { + const token = await dependencies.getAccessToken(); + const doc = tool.scopes.includes('doc:read') + ? await dependencies.getDocContext() + : undefined; + const { grantId } = await dependencies.createGrant(token, { + toolClientId: tool.id, + scopes: tool.scopes, + doc, + }); + dependencies.completeLaunch( + reservation, + withGrantFragment(tool.url, grantId), + ); + return { sharedDoc: doc !== undefined }; + } catch (error) { + dependencies.cancelLaunch(reservation); + throw error; + } +} const SCOPE_LABELS: Record = { 'openai:chat': 'AI access', @@ -88,21 +137,17 @@ export default function Tools() { setError(null); setBusyToolId(tool.id); try { - const token = await getAccessToken(); - // Only read the document when the tool asked for it — the snapshot leaves - // our trust boundary, so we don't gather it speculatively. - const doc = tool.scopes.includes('doc:read') - ? await editorAPI.getDocContext() - : undefined; - const { grantId } = await createHandoff(token, { - toolClientId: tool.id, - scopes: tool.scopes, - doc, + const result = await launchFirstPartyTool(tool, { + getAccessToken, + getDocContext: () => editorAPI.getDocContext(), + createGrant: createHandoff, + reserveLaunch: reserveBrowserLaunch, + completeLaunch: completeBrowserLaunch, + cancelLaunch: cancelBrowserLaunch, }); - openInBrowser(withGrantFragment(tool.url, grantId)); void toolsLog.toolLaunched(log, { tool: tool.id, - sharedDoc: doc !== undefined, + sharedDoc: result.sharedDoc, scopes: tool.scopes, }); } catch (e) { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe2..d2d6b188 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface ImportMetaEnv { + readonly VITE_MINDMAP_TOOL_URL?: string; +}