Skip to content

Tool-launcher platform hardening (extracted from #569) - #575

Closed
kcarnold wants to merge 3 commits into
mainfrom
feat/tool-launcher-platform-hardening
Closed

Tool-launcher platform hardening (extracted from #569)#575
kcarnold wants to merge 3 commits into
mainfrom
feat/tool-launcher-platform-hardening

Conversation

@kcarnold

@kcarnold kcarnold commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Extracts the frontend/ and backend/ 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 into mindmap-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-launcher feature 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.

resolveUser mapped a wtk_ bearer to a user or 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 instead of being refused.

A three-state ProxyIdentity makes "presented an invalid tool credential" its own case, which 401s before any upstream fetch. Tests cover both revoked and expired with OPENAI_DEMO_API_KEY set, asserting fetch is never called.

The same commit adds X-Writing-Tools-Error: platform-auth plus 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. documentLabel on DocContext

A doc:read handoff 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 from Office.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 the getDocumentName bridge that already exists in google-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's openBrowserWindow isn't popup-blocked, so that path stays deferred. launchFirstPartyTool is extracted from the component to make the ordering testable.

Also drops the dev-only test-tool entry (marked "Remove before merge"), drops log:write from the mindmap's scopes since its audit ledger stays local, and adds VITE_MINDMAP_TOOL_URL for 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-time VITE_ENABLE_MINDMAP_TOOL that 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_URL stays, 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 in src/api/__tests__/documentSettings.test.ts and are pre-existing on main — unrelated to this branch.

Not included

scripts/get_env.py stays with #569. It adds mindmap to BETTER_AUTH_DEVICE_CLIENT_IDS so POST /api/handoff doesn't 400 on an unknown tool_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.yml also stays with #569. Its integration-units job re-runs the full backend and frontend suites that backend-tests.yml and frontend-tests.yml already cover, gated on a hand-maintained path list that will silently rot — worth trimming to just the prototype-mindmap job.

🤖 Generated with Claude Code

kcarnold and others added 3 commits July 29, 2026 11:44
`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>
@kcarnold

Copy link
Copy Markdown
Contributor Author

Superseded by #569, which landed all of this in main at 43baf2a.

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 backend/ diff between this branch and main is now empty:

  • Proxy fails closed on rejected tool credentials (+ the platform-auth marker and CORS expose) — in main, byte-identical.
  • documentLabel on DocContext — in main, byte-identical.
  • Popup reservation before minting a grant — in main, and improved in fd54c85 with a clearer "why not a plain <a href>" rationale on reserveBrowserLaunch than the version here.

The only remaining difference was a deliberate deviation I made: this branch kept enabled: () => isFlagEnabled('tool-launcher') on the Tools registry entry and dropped the build-time VITE_ENABLE_MINDMAP_TOOL. fd54c85 settled that the other way, with a test that now enforces it — Tools ships reachable from the Labs menu, and re-adding a predicate should fail loudly and be a deliberate rollout decision. That is a reasonable call, and a flag-gated page would have been unreachable in the Word task pane where the handoff flow actually runs. Closing rather than resolving the conflicts.

Two follow-ups noted while reviewing, neither blocking:

  • VITE_ENABLE_MINDMAP_TOOL is set nowhere — not in the Dockerfile, not in any workflow — so production builds get FIRST_PARTY_TOOLS = [] and frontend/src/pages/tools/index.tsx renders no empty state. Lab-tier users can currently open Tools and see the intro paragraph, no tools, then the ad-hoc URL box.
  • .github/workflows/mindmap-tests.yml's integration-units job re-runs the full backend and frontend suites already covered by backend-tests.yml and frontend-tests.yml, gated on a hand-maintained path list.

The grant-chaining observation from this review is filed as #578.

@kcarnold kcarnold closed this Jul 29, 2026
@kcarnold
kcarnold deleted the feat/tool-launcher-platform-hardening branch July 29, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant