Tool-launcher platform hardening (extracted from #569) - #575
Conversation
`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 <nnm7@calvin.edu>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <nnm7@calvin.edu> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <nnm7@calvin.edu> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4952fbb to
6672edf
Compare
|
Superseded by #569, which landed all of this in This branch existed to make the platform changes reviewable apart from the 42k-line prototype. That worked, and there is nothing left to merge — the
The only remaining difference was a deliberate deviation I made: this branch kept Two follow-ups noted while reviewing, neither blocking:
The grant-chaining observation from this review is filed as #578. |
Extracts the
frontend/andbackend/changes out of #569 so they can be reviewed without the 42k-line mindmap prototype around them. #569 became hard to review because #570 (the launcher integration) was merged intomindmap-main, so its description — "this PR does not expose the mindmap from the launcher" — no longer described its contents.Nothing here is mindmap-specific except the tool-registry entry, and nothing reaches beta users: the Tools page stays behind the
tool-launcherfeature flag.Three commits, each independently reviewable.
1. Proxy fails closed on rejected tool credentials
The one to look at hardest — it's a live bug in already-merged code.
resolveUsermapped awtk_bearer to a user or null, and null was indistinguishable from "no session at all".attributeRequestserves 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 instead of being refused.A three-state
ProxyIdentitymakes "presented an invalid tool credential" its own case, which 401s before any upstream fetch. Tests cover both revoked and expired withOPENAI_DEMO_API_KEYset, assertingfetchis never called.The same commit adds
X-Writing-Tools-Error: platform-authplus an{ error: { code: 'platform_auth' } }body to auth failures that originate here, and puts that header on the CORS expose list. A cross-origin tool can't read a response header the server doesn't expose, and it needs to distinguish "your grant died, re-launch from the add-in" from "the model provider rejected the request". A 401 relayed from OpenAI deliberately does not get the marker.2.
documentLabelonDocContextA
doc:readhandoff ships a snapshot to a different origin, where the writer has no way to tell which document they're looking at. Word derives the label fromOffice.context.document.url— via a separately tested helper, because the naive read leaks the full SharePoint/OneDrive path and this string crosses a trust boundary. Google Docs uses thegetDocumentNamebridge that already exists ingoogle-docs-addon/Code.gs, so no add-on redeploy is required.3. Reserve the tool window before minting a grant
Launch ran
await token → await doc → await grant → window.open. The user gesture was long gone by then, so browsers blocked the popup — and the failure landed after the document had been read and a grant minted, leaving a live credential and a snapshot behind for a window that never opened.The window is now reserved synchronously on click and
location.replaced once the URL exists, or closed if any step fails. Office'sopenBrowserWindowisn't popup-blocked, so that path stays deferred.launchFirstPartyToolis extracted from the component to make the ordering testable.Also drops the dev-only
test-toolentry (marked "Remove before merge"), dropslog:writefrom the mindmap's scopes since its audit ledger stays local, and addsVITE_MINDMAP_TOOL_URLfor pointing at a local mindmap dev server.Changed from the #569 version: that branch also removed
enabled: () => isFlagEnabled('tool-launcher')from the registry and added a build-timeVITE_ENABLE_MINDMAP_TOOLthat defaulted off in production — which together would have shipped a visible Tools page with an empty tool list. The runtime flag already covers visibility, so this PR keeps the flag and drops the build-time toggle.VITE_MINDMAP_TOOL_URLstays, since where the tool is hosted is a separate question from whether it's shown.Verification
backend: typecheck clean, 111/111 tests pass.frontend: typecheck clean, production build succeeds, 216/220 tests pass. The 4 failures are insrc/api/__tests__/documentSettings.test.tsand are pre-existing onmain— unrelated to this branch.Not included
scripts/get_env.pystays with #569. It addsmindmaptoBETTER_AUTH_DEVICE_CLIENT_IDSsoPOST /api/handoffdoesn't 400 on an unknowntool_client_id— but that only matters once there is a mindmap to launch, and nothing in this PR can launch one. It's mindmap-specific dev setup, not platform work..github/workflows/mindmap-tests.ymlalso stays with #569. Itsintegration-unitsjob re-runs the full backend and frontend suites thatbackend-tests.ymlandfrontend-tests.ymlalready cover, gated on a hand-maintained path list that will silently rot — worth trimming to just theprototype-mindmapjob.🤖 Generated with Claude Code