build-manifest: put credentials in the URL fragment, not the query string (#356)

* build-manifest: put credentials in the URL fragment, not the query string

Claude for Office reads manifest settings from the URL fragment; query-string
credentials are deprecated there and ignored from 2026-10-19
(anthropics/office-agent#2857). Fragments are not sent in HTTP requests.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTWZSYSeY8PZFPmQ3PXJin

* manifest docs: trim the fragment explanation

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTWZSYSeY8PZFPmQ3PXJin

* build-manifest: rename FRAGMENT_KEYS to SENSITIVE_KEYS

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTWZSYSeY8PZFPmQ3PXJin

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
john-michael
2026-09-18 19:48:55 -04:00
committed by GitHub
co-authored by Claude
parent 35c80df9f4
commit fca3cc8e6c
3 changed files with 46 additions and 8 deletions
@@ -1,7 +1,7 @@
{
"name": "claude-for-msft-365-install",
"description": "Provision direct cloud access (Vertex AI, Bedrock, Azure AI Foundry, or LLM gateway) for the Claude Office add-in. Generates the customized add-in manifest, walks through Azure admin consent, and writes per-user config via Microsoft Graph extension attributes.",
"version": "0.1.12",
"version": "0.1.13",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
@@ -4,8 +4,11 @@ description: Generate the add-in manifest XML with your cloud config baked in
# Generate add-in manifest
The script fetches the canonical manifest and appends your config as URL query
parameters. The add-in reads them at startup. Outlook uses a separate template
The script fetches the canonical manifest and appends your config to the
taskpane URL. Sensitive settings (`gateway_token`, `azure_api_key`,
`google_client_secret`, `otlp_headers`, `inference_headers`, `mcp_servers`) go
after `#`; other settings are query parameters. Regenerate and redeploy
manifests built with earlier versions. Outlook uses a separate template
because Microsoft's `MailApp` schema is distinct from the `TaskPaneApp` schema
Excel/Word/PowerPoint share, so ask which apps they're deploying and generate
one file per host.
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Fetches the canonical add-in manifest and writes a customized copy with your
// org's config baked into the taskpane URL as query parameters.
// org's config baked into the taskpane URL: sensitive settings after #, other settings after ?.
//
// Usage: node build-manifest.mjs <office|outlook> <out.xml> key=value [key=value ...]
// Example: node build-manifest.mjs office acme.xml gcp_project_id=acme gcp_region=us-east5
@@ -16,6 +16,43 @@ const MANIFESTS = {
// schema repeats Taskpane.Url across V1_0 and V1_1 VersionOverrides, hence /g.
const URL_SLOTS = [/(<SourceLocation\s+DefaultValue=")([^"]+)(")/g, /(id="Taskpane\.Url"\s+DefaultValue=")([^"]+)(")/g];
// Sensitive settings; emitted in the URL fragment so they aren't part of the request.
const SENSITIVE_KEYS = new Set([
"gateway_token",
"azure_api_key",
"google_client_secret",
"otlp_headers",
"inference_headers",
"mcp_servers",
]);
function splitParams(params) {
const query = new URLSearchParams();
const fragment = new URLSearchParams();
for (const [k, v] of params) {
if (SENSITIVE_KEYS.has(k)) fragment.set(k, v);
else query.set(k, v);
}
return { query, fragment };
}
// URLSearchParams joins pairs with `&`; XML attribute values need it escaped.
function xmlEscape(s) {
return s.replaceAll("&", "&amp;");
}
// `url` is the template's taskpane URL as it appears in the XML (already escaped,
// already carrying ?m=<tag>), so only the appended parts are escaped here.
function appendParams(url, { query, fragment }) {
if (url.includes("#")) throw new Error(`template URL already has a fragment: ${url}`);
let result = url;
const qs = query.toString();
if (qs) result += (url.includes("?") ? "&amp;" : "?") + xmlEscape(qs);
const frag = fragment.toString();
if (frag) result += "#" + xmlEscape(frag);
return result;
}
// Recognized config keys. `pattern` is a shape hint — mismatches warn but don't block
// (your infra may look different). `secret` keys warn louder: the manifest is an
// org-wide file and its URL can land in deploy logs; per-user secrets typically go
@@ -249,8 +286,7 @@ async function main() {
throw new Error(`graph_cloud=${cloud} requires a graph_client_id registered in that cloud`);
}
// URLSearchParams joins with `&`; XML attribute values need it escaped.
const qs = params.toString().replaceAll("&", "&amp;");
const split = splitParams(params);
const res = await fetch(manifestUrl);
if (!res.ok) throw new Error(`fetch ${manifestUrl}: ${res.status} ${res.statusText}`);
@@ -260,8 +296,7 @@ async function main() {
slot.lastIndex = 0;
if (!slot.test(xml)) throw new Error(`manifest missing expected URL slot: ${slot.source}`);
slot.lastIndex = 0;
// The template URL already carries ?m=<tag> — append with & not a second ?
xml = xml.replace(slot, (_, pre, url, post) => pre + url + (url.includes("?") ? "&amp;" : "?") + qs + post);
xml = xml.replace(slot, (_, pre, url, post) => pre + appendParams(url, split) + post);
}
writeFileSync(out, xml);