The Directory → "Anthropic & Partners" tab has a non-obvious
install flow that caused a structural bug (#396) on older
versions. Key insight: the renderer that populates
pluginContext.mode and pluginContext.pluginSource is served
remotely from claude.ai in a BrowserView, not bundled locally.
Static source inspection only sees the main-process gate; its
inputs originate in server-rendered JS outside the asar.
The main window is https://claude.ai/task/new loaded in a
BrowserView. Only ~288 KB of JS lives locally under
.vite/renderer/main_window/assets/; neither installPlugin nor
pluginContext appears there.
When the user clicks install on a plugin:
- Remote web UI calls
CustomPlugins.installPlugin(pluginId, egressAllowedDomains, pluginContext)via IPC (preload bridge → main process). - Main-process IPC handler validates
pluginContextviaQg()(runtime type check):{ mode: string, workspacePath?, settingsLevel?, pluginSource?, marketplaceScope?, telemetryAttempt? }. - Main-process
installPluginapplies the gate, optionally calls the Anthropic API, and falls back to theclaudeCLI if the remote path is skipped or fails.
The values of mode and pluginSource are decided remotely
by claude.ai based on which UI surface called install. The
desktop app has no control over them; it only enforces the gate.
Location: index.js:490853 inside the minified app.asar.
const a = s?.pluginSource === "local"; // user-uploaded .zip
const c = s?.pluginSource === "remote"; // remote marketplace install
if (!a && (c || s?.mode === "cowork") && (await A0())) {
// remote API: /api/organizations/{orgId}/plugins/...
} else {
// skip, log reason: "local-sourced" |
// "not-cowork-not-remote" |
// "sparkplug-disabled"
}
// always falls through to CLI install on failureA0()(index.js:489947) = GrowthBook flag"2340532315"viaisFeatureEnabled(), cached locally. Server-controlled.- On CLI fallback for a non-local marketplace like
knowledge-work-plugins, install fails withPlugin "X" not found in marketplace "knowledge-work-plugins".
Four places in 1.3109.0 gate on A0():
| Line | Function | If flag off |
|---|---|---|
| 490342 | syncRemotePlugins |
{newlyInstalled: []} |
| 490355 | getDownloadedRemotePlugins |
[] |
| 491026 | listAvailablePlugins |
local plugins only |
| 491060 | listRemotePluginsPage |
{plugins: [], hasMore: false} |
If A0() is false, the Anthropic & Partners tab is empty.
Users whose account doesn't have the flag enabled server-side
never see these plugins at all.
All served from https://claude.ai (base URL from Jr() =
main-window URL). Main-process net.fetch adds identity headers
via an onBeforeSendHeaders interceptor at index.js:504876:
| Header | Value |
|---|---|
anthropic-client-platform |
"desktop_app" (constant) |
anthropic-client-app |
"com.anthropic.claudefordesktop" |
anthropic-client-version |
app.getVersion() |
anthropic-client-os-platform |
process.platform — "linux" / "darwin" / "win32" |
anthropic-client-os-version |
process.getSystemVersion() |
anthropic-desktop-topbar |
"1" |
Key endpoints:
| Purpose | URL | Source line |
|---|---|---|
| GrowthBook flags | GET /api/desktop/features |
190336 |
| Default marketplaces (Directory source) | GET /api/organizations/{orgId}/marketplaces/list-default-marketplaces |
— |
| Account-attached marketplaces (user-added) | GET /api/organizations/{orgId}/marketplaces/list-account-marketplaces |
— |
| Directory feed | GET /api/organizations/{orgId}/plugins/list-plugins?installation_preference=... |
246164 |
| Plugin by-id | GET /api/organizations/{orgId}/plugins/{id} |
246212 |
| Plugin by-name | GET /api/organizations/{orgId}/plugins/by-name/{name}?marketplace_name=... |
246221 |
| Plugin download | GET /api/organizations/{orgId}/plugins/{id}/download |
246229 |
Auth is via the sessionKey cookie. orgId is read from the
lastActiveOrg cookie by an() at index.js:191235. No orgId →
fetchers return null → install falls back to CLI.
Filed on Claude Desktop 1.1.7714. That version had:
Install gate (index.js:230901 in 1.1.7714):
if (!c && (a?.mode) === "cowork" && (await Tg())) {
// remote API
}
// reasons: "local-sourced" | "not-cowork" | "sparkplug-disabled"Listing filter (index.js:231032):
if ((s?.mode) !== "cowork" || !(await Tg())) return o; // local only
// else merge remotelistRemotePluginsPage (index.js:231066):
if (!(await Tg())) return { plugins: [], hasMore: !1 };
// else fetch and returnlistRemotePluginsPage gated only on Tg(), not on cowork mode,
so the Directory showed remote plugins whenever the sparkplug
flag was on. But the install gate required mode === "cowork"
specifically. Users browsing the Directory outside a cowork
session received pluginContext without mode: "cowork" from
the renderer → install gate failed → reason=not-cowork → CLI
fallback → "marketplace not found."
Structural bug: plugins visible but uninstallable unless the user was actively inside a cowork session.
Fixed upstream in 1.3109.0 via two coordinated Anthropic-side changes:
- Install gate relaxed to accept
pluginSource === "remote"as equivalent tomode === "cowork". - claude.ai renderer updated to send
pluginSource: "remote"for installs from the Anthropic & Partners Directory regardless of cowork session state.
PR #435 proposed a client-side Linux-specific short-circuit
(process.platform === "linux" || ...). Correct strategy for the
bug as it existed; obsolete after upstream fix. Closed as
obsolete.
To debug plugin-flow bugs on a running client:
echo '{"allowDevTools": true}' > ~/.config/Claude/developer_settings.jsonThen fully quit and relaunch the app. Open the (now visible)
Enable Main Process Debugger menu item (under Help when dev
tools are enabled) — this starts a Node inspector on
127.0.0.1:9229. Connect via chrome://inspect in any Chromium
browser and click inspect on the Node target.
Source refs:
allowDevToolsschema:index.js:299085developer_settings.jsonpath:index.js:299089- Debugger menu:
index.js:494282
require('electron').webContents.getAllWebContents()
.map(w => ({ id: w.id, type: w.getType(), url: w.getURL() }))Typically three: the find-in-page overlay, the claude.ai
BrowserView (id 2), and the main window shell (id 1). The
claude.ai one is where the plugin directory UI lives; open its
DevTools separately via webContents.fromId(n).openDevTools() to
inspect the renderer-side code.
(async () => {
const res = await require('electron').net.fetch(
'https://claude.ai/api/desktop/features');
const body = await res.json();
console.log(body.features['2340532315']);
})();Expected for users with the force rule:
{value: true, source: "force", ruleId: "fr_..."}. If it's
{value: false, source: "defaultValue", ruleId: null}, the user
won't see any remote plugins — listAvailablePlugins and
listRemotePluginsPage filter them out.
Electron only allows one onBeforeSendHeaders listener at a
time. Registering a test listener replaces the app's injector
(index.js:504876), so the harness re-implements the baseline
injection and adds a per-test override layer:
const { app, session, net } = require('electron');
const APP_HEADERS = {
'anthropic-client-platform': 'desktop_app',
'anthropic-client-app': 'com.anthropic.claudefordesktop',
'anthropic-client-version': app.getVersion(),
'anthropic-client-os-platform': process.platform,
'anthropic-client-os-version': process.getSystemVersion(),
'anthropic-desktop-topbar': '1',
};
globalThis.__testOverrides = {};
globalThis.__testRemove = new Set();
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://claude.ai/*', 'https://claude.com/*'] },
(d, cb) => {
const h = { ...d.requestHeaders, ...APP_HEADERS,
...globalThis.__testOverrides };
for (const k of globalThis.__testRemove) delete h[k];
cb({ requestHeaders: h });
}
);
async function runTest(label, { set = {}, remove = [] } = {},
url = 'https://claude.ai/api/desktop/features') {
globalThis.__testOverrides = set;
globalThis.__testRemove = new Set(remove);
const res = await net.fetch(url);
const ct = res.headers.get('content-type') || '';
const body = ct.includes('json') ? await res.json()
: await res.text();
globalThis.__testOverrides = {};
globalThis.__testRemove = new Set();
return { label, status: res.status, body };
}Example: test whether flag depends on OS claim:
(async () => {
const r = await runTest('darwin', {
set: { 'anthropic-client-os-platform': 'darwin',
'anthropic-client-os-version': '15.0' } });
console.log(r.body.features['2340532315']);
})();If the flag value changes when you spoof OS, the server is platform-gating; if not, the gate lives at a different layer (account-scoped rule, tier, cohort, or the remote renderer's local JS gating).
In main-process DevTools Sources: Ctrl+P → index.js →
Ctrl+F → search installPlugin: attempting remote API install.
Click the line number to set a breakpoint. Trigger an install in
the app. When it breaks, inspect s (the pluginContext) and
evaluate await A0() in a watch expression.
The companion breakpoint on installPlugin: skipping remote API path tells you which reason the gate chose if it failed.
The repo's releases include reference-source.tar.gz
(~6.5 MB) — beautified asar contents of the exact Claude Desktop
build that was packaged. Much smaller than the AppImage (~133 MB)
and sufficient for code diffing between versions.
gh release download "v1.3.23+claude1.1.7714" \
-R aaddrick/claude-desktop-debian \
-p 'reference-source.tar.gz' \
-D /tmp/old-version --clobber
tar -xzf /tmp/old-version/reference-source.tar.gz -C /tmp/old-version
# Compare with current: /tmp/old-version/app-extracted/.vite/build/index.jsThis is how #396's post-mortem was done — side-by-side comparison
of installPlugin (230901 old vs 490853 current) and
listAvailablePlugins (231032 old vs 491026 current) revealed
both the structural bug and the upstream fix.
scripts/patches/cowork.sh—patch_cowork_linux()applies the cowork patches to the asar. Patches 1–10 handle cowork mode infrastructure on Linux.scripts/cowork-vm-service.js— Linux cowork VM daemon (separate subsystem, seecowork-vm-daemon.md).- Minified install flow in the running app:
app.asar.contents/.vite/build/index.jsaround line 490853 on 1.3109.0 (subject to minifier drift — anchor on the log string[CustomPlugins] installPlugin: attempting remote API installwhen writing patches).