@vanillagreen/pi-claude-bridge 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ // Audit trail for connector calls the `claude` child executes itself.
2
+ //
3
+ // A claude.ai connector tool runs INSIDE the child, on the child's own MCP
4
+ // servers, and is deliberately never mirrored into the Pi stream (see
5
+ // isChildExecutedTool). That is the honest behaviour — mirroring wrote
6
+ // `Tool <name> not found` into the transcript for calls that had SUCCEEDED
7
+ // (drovr#311 / memsira#320) — but it leaves the Pi session with no record that
8
+ // the call happened at all, so "did it really look that up?" could only be
9
+ // answered from the child's own transcript.
10
+ //
11
+ // A pi `CustomEntry` closes that gap without reintroducing the bug: it is
12
+ // persisted in the session file, is NOT a content block, and is documented as
13
+ // "ignored by buildSessionContext", so Pi's agent loop can never dispatch it and
14
+ // `convertPiMessages` (which reads messages, not entries) can never project it
15
+ // back into the child's session. `CustomMessageEntry` is the sibling type that
16
+ // DOES enter context — using it here would recreate the whole problem.
17
+ //
18
+ // The payload is never recorded. A connector result is live account data (mail,
19
+ // messages, documents); the audit answers whether a call happened and what came
20
+ // back, not what it said.
21
+
22
+ import { extensionApi } from "./bridge-state.js";
23
+ import { debug } from "./debug.js";
24
+ import type { QueryContext, ToolCallDrainCause } from "./query-state.js";
25
+
26
+ export const CONNECTOR_CALL_CUSTOM_TYPE = "claude-bridge-connector-call";
27
+
28
+ /**
29
+ * What the bridge observed of a connector call.
30
+ *
31
+ * `unobserved` is the load-bearing one: the call was issued and the query ended
32
+ * before its result came back. Recording nothing for it would leave an answer in
33
+ * the transcript with no trace of the call behind it — indistinguishable from a
34
+ * turn where no call was ever made. Same reasoning as `interruptedToolCallResult`
35
+ * for Pi-side tools: a call that did not complete says so.
36
+ */
37
+ export type ConnectorCallOutcome = "ok" | "error" | "unobserved";
38
+
39
+ export interface ConnectorCallAuditData {
40
+ /** Raw connector tool name as the child invoked it (`mcp__claude_ai_<Server>__<tool>`). */
41
+ name: string;
42
+ /** The child's own `tool_use` id — the join key to its transcript. */
43
+ toolUseId: string;
44
+ outcome: ConnectorCallOutcome;
45
+ /** UTF-8 byte size of the observed result payload. Absent when the result was
46
+ * never observed, or could not be measured — never a confident 0 for
47
+ * something that was not sized. */
48
+ byteSize?: number;
49
+ /** Claude Code session that executed the call. Absent when the SDK never
50
+ * reported one for this query. */
51
+ childSessionId?: string;
52
+ /** Why an `unobserved` call ended. Absent for observed ones. */
53
+ reason?: ToolCallDrainCause;
54
+ }
55
+
56
+ /**
57
+ * Approximate UTF-8 byte size of a child tool result's payload.
58
+ *
59
+ * A string payload is measured directly; any other shape is measured as its JSON
60
+ * serialization, so a structured or image result reports the size it really
61
+ * carried. (The debug line this replaces reported `content.length` for an array
62
+ * payload — a BLOCK count wearing a byte size's name.)
63
+ *
64
+ * Returns undefined when there is nothing to measure or the payload cannot be
65
+ * serialized, so the caller omits the field rather than recording a 0 it did not
66
+ * measure. The payload itself is never returned or logged.
67
+ */
68
+ export function connectorResultByteSize(content: unknown): number | undefined {
69
+ if (content === undefined || content === null) return undefined;
70
+ if (typeof content === "string") return Buffer.byteLength(content, "utf8");
71
+ try {
72
+ const json = JSON.stringify(content);
73
+ return typeof json === "string" ? Buffer.byteLength(json, "utf8") : undefined;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * An ADDITIONAL destination for connector-call records, for a host that embeds
81
+ * the bridge with no pi session to append to.
82
+ *
83
+ * Never throws is the contract on OUR side; a sink that throws anyway is caught
84
+ * and dropped, because an audit record must not be able to fail a turn.
85
+ */
86
+ export type ConnectorCallAuditSink = (data: ConnectorCallAuditData) => void;
87
+
88
+ let auditSink: ConnectorCallAuditSink | undefined;
89
+
90
+ /**
91
+ * Install (or clear, with `undefined`) a host sink for connector-call records.
92
+ *
93
+ * **The sink ADDS a destination, it never replaces `appendEntry`.** A host that
94
+ * drives real `AgentSession`s gets the transcript-local entries for free, and a
95
+ * replacing sink would take those away and reopen the very audit gap this
96
+ * feature closes (memsira, 2026-07-28 — their `apps/sidecar/src/runtime.ts` is
97
+ * session-backed, and 122 of their app-chat session files carry the bridge's
98
+ * `claude-bridge-session` markers). A host with both a session and a sink has
99
+ * asked for both and gets both.
100
+ *
101
+ * It exists because the OTHER embedding shape gets nothing at all: drovr loads
102
+ * the bundle through a throwaway resource loader over
103
+ * `createAgentSessionServices` with no session, so `extensionApi` is undefined
104
+ * and every record it appended went nowhere (drovr #317, measured live). The
105
+ * sink is the seam such a host can reach without one.
106
+ *
107
+ * A callback rather than another `Symbol.for` global on purpose: the bundle
108
+ * already has one (`claude-bridge:activeStreamSimple`) and hosts document that
109
+ * coupling as a re-vendor hazard, so a second would be the wrong direction.
110
+ *
111
+ * Process-global, matching `setExtensionApi`: one bundle instance serves one
112
+ * host. A host that runs several conversations in ONE process must therefore
113
+ * route by `childSessionId` itself, or embed per conversation as drovr does.
114
+ */
115
+ export function setConnectorCallAuditSink(sink: ConnectorCallAuditSink | undefined): void {
116
+ auditSink = sink;
117
+ }
118
+
119
+ /**
120
+ * Append one audit entry to every destination the host installed. Returns
121
+ * whether it reached AT LEAST ONE of them, so a caller can log the truth rather
122
+ * than assume — both are absent whenever the bridge runs outside a pi session
123
+ * and outside a sink-installing host (tests, the connector-inventory entry
124
+ * point).
125
+ *
126
+ * The two destinations are independent: one throwing must not cost the other
127
+ * its record. Never throws — an audit record must not be able to fail a turn.
128
+ */
129
+ export function appendConnectorCallAudit(data: ConnectorCallAuditData): boolean {
130
+ let delivered = false;
131
+ if (extensionApi) {
132
+ try {
133
+ extensionApi.appendEntry(CONNECTOR_CALL_CUSTOM_TYPE, data);
134
+ delivered = true;
135
+ } catch (error) {
136
+ debug("appendConnectorCallAudit failed:", error);
137
+ }
138
+ }
139
+ if (auditSink) {
140
+ try {
141
+ // A COPY, because the sink is host code and `appendEntry` above holds a
142
+ // reference to the same object: a sink that mutated the record would be
143
+ // editing what the session already recorded.
144
+ auditSink({ ...data });
145
+ delivered = true;
146
+ } catch (error) {
147
+ debug("connector call audit sink failed:", error);
148
+ }
149
+ }
150
+ return delivered;
151
+ }
152
+
153
+ /**
154
+ * Record the observed result of a child-executed connector call, once.
155
+ *
156
+ * Keyed on the tool_use id rather than on the call site: the SDK can re-yield a
157
+ * `user` message, and an audit trail that counts one call twice is worse than
158
+ * one that counts it not at all. Returns whether an entry was appended.
159
+ */
160
+ export function recordConnectorCallResult(
161
+ queryCtx: QueryContext,
162
+ toolUseId: string,
163
+ name: string,
164
+ isError: boolean,
165
+ byteSize: number | undefined,
166
+ ): boolean {
167
+ const pending = queryCtx.connectorCallAudit.get(toolUseId);
168
+ if (pending?.recorded) return false;
169
+ const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
170
+ queryCtx.connectorCallAudit.set(toolUseId, { ...pending, name, childSessionId, recorded: true });
171
+ return appendConnectorCallAudit({
172
+ name,
173
+ toolUseId,
174
+ outcome: isError ? "error" : "ok",
175
+ ...(byteSize !== undefined ? { byteSize } : {}),
176
+ ...(childSessionId ? { childSessionId } : {}),
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Record every connector call this query issued whose result never came back,
182
+ * naming the cause. Runs at query teardown, beside the Pi-side tool drain, and is
183
+ * idempotent — a call already recorded is skipped, and one recorded here is
184
+ * marked so a late arrival cannot record it again.
185
+ *
186
+ * Returns how many entries were appended.
187
+ */
188
+ export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
189
+ let appended = 0;
190
+ for (const [toolUseId, state] of queryCtx.connectorCallAudit) {
191
+ if (state.recorded) continue;
192
+ queryCtx.connectorCallAudit.set(toolUseId, { ...state, recorded: true });
193
+ const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
194
+ if (appendConnectorCallAudit({
195
+ name: state.name,
196
+ toolUseId,
197
+ outcome: "unobserved",
198
+ reason,
199
+ ...(childSessionId ? { childSessionId } : {}),
200
+ })) appended++;
201
+ }
202
+ return appended;
203
+ }
@@ -0,0 +1,118 @@
1
+ // Cross-PROCESS cache of the connector inventory (vstack#870).
2
+ //
3
+ // #868 primes the inventory at provider registration, but the fetch takes ~1.5s
4
+ // while the first query is built at ~0.5-0.8s, so turn 1 of a cold sidecar goes
5
+ // out with no declarations and gets exactly the #832 bug it was meant to fix.
6
+ //
7
+ // An in-process cache cannot help the consumer that needs it most. drovr builds
8
+ // a sidecar lazily on the first bridge round and, since their sidecars are
9
+ // per-SESSION, that is a fresh process for every new chat — so their exposure is
10
+ // once per chat, indefinitely, and every one of those is a cold process. The
11
+ // cache therefore has to survive process boundaries.
12
+ //
13
+ // Keyed by credential scope, because that is what selects the account: the org
14
+ // UUID in the inventory request is ignored and only the credential decides whose
15
+ // connectors come back. Two accounts on one host must not share a cache entry.
16
+ //
17
+ // Everything here is best-effort. A missing, unreadable, corrupt, stale, or
18
+ // wrong-version cache returns undefined and the caller falls back to today's
19
+ // behaviour — the same fail-open contract as the inventory call itself.
20
+ //
21
+ // The ON-DISK FORMAT HAS AN EXTERNAL READER (vstack#892). drovr quarantines this
22
+ // bundle to its sidecar process, so rather than calling `listAccountConnectors`
23
+ // in-process it re-implements the reader half — path
24
+ // `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
25
+ // payload `{version, scope, savedAt, connectors}`, 7-day max age — as the
26
+ // "is this connector installed" half of its write gate.
27
+ //
28
+ // That coupling fails OPEN on drift by design, so a format change degrades them
29
+ // from two gates to one rather than breaking them. It is still worth making the
30
+ // change knowingly: bump CACHE_VERSION so their staleness check rejects rather
31
+ // than misreads, and say so in the changelog. `unit-connector-cache.mjs` pins
32
+ // the path shape and payload keys.
33
+ import { createHash } from "node:crypto";
34
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { dirname, join } from "node:path";
36
+ import { piUserDir } from "./config.js";
37
+ import type { ConnectorEntry } from "./connector-inventory.js";
38
+
39
+ const CACHE_VERSION = 1;
40
+ /** Long enough to be useful across a machine's lifetime, short enough that a
41
+ * removed connector stops being declared without needing a manual purge. A
42
+ * stale entry is not dangerous — a connector that no longer resolves simply
43
+ * fails to connect, which is the fail-open path — so this is hygiene, not a
44
+ * correctness boundary. */
45
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
46
+
47
+ export function connectorCacheScopeKey(env: NodeJS.ProcessEnv = process.env): string {
48
+ return env.CLAUDE_CONFIG_DIR?.trim() || "<default>";
49
+ }
50
+
51
+ /**
52
+ * Our own state directory, not the Claude config dir. The credential directory
53
+ * belongs to the CLI; the scope is encoded in the filename instead so we never
54
+ * write into someone else's tree. Hashed rather than escaped because a config
55
+ * dir is an arbitrary absolute path.
56
+ */
57
+ export function connectorCachePath(scopeKey: string = connectorCacheScopeKey()): string {
58
+ const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 16);
59
+ return join(piUserDir(), "connector-cache", `${digest}.json`);
60
+ }
61
+
62
+ /**
63
+ * Synchronous by design. The query path has no await boundary to hang a read on
64
+ * — `streamClaudeAgentSdk` returns its stream and claims the SDK query handle in
65
+ * the same tick — which is the whole reason the in-memory prime loses the race.
66
+ * A single small `readFileSync` is what makes turn 1 reachable at all.
67
+ */
68
+ export function readCachedConnectors(
69
+ scopeKey: string = connectorCacheScopeKey(),
70
+ now: number = Date.now(),
71
+ ): ConnectorEntry[] | undefined {
72
+ let raw: string;
73
+ try {
74
+ raw = readFileSync(connectorCachePath(scopeKey), "utf8");
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ let parsed: any;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ if (parsed?.version !== CACHE_VERSION) return undefined;
85
+ // Scope is stored as well as hashed into the path: a hash collision or a
86
+ // hand-copied file would otherwise hand one account another's connectors,
87
+ // which is the exact failure the token-scoping note in connector-inventory.ts
88
+ // warns about.
89
+ if (parsed?.scope !== scopeKey) return undefined;
90
+ const savedAt = typeof parsed?.savedAt === "number" ? parsed.savedAt : 0;
91
+ if (!savedAt || now - savedAt > MAX_AGE_MS || savedAt > now) return undefined;
92
+ if (!Array.isArray(parsed?.connectors)) return undefined;
93
+ const connectors = parsed.connectors.filter(
94
+ (entry: any) => entry && typeof entry.name === "string" && entry.name.trim(),
95
+ );
96
+ return connectors.length > 0 ? (connectors as ConnectorEntry[]) : undefined;
97
+ }
98
+
99
+ /** Best-effort write; a failure here must never affect the turn. */
100
+ export function writeCachedConnectors(
101
+ connectors: ConnectorEntry[],
102
+ scopeKey: string = connectorCacheScopeKey(),
103
+ now: number = Date.now(),
104
+ ): boolean {
105
+ if (!Array.isArray(connectors) || connectors.length === 0) return false;
106
+ const path = connectorCachePath(scopeKey);
107
+ try {
108
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
109
+ writeFileSync(
110
+ path,
111
+ JSON.stringify({ version: CACHE_VERSION, scope: scopeKey, savedAt: now, connectors }),
112
+ { mode: 0o600 },
113
+ );
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
@@ -0,0 +1,333 @@
1
+ // Deterministic enumeration of a Claude account's installed claude.ai connectors.
2
+ //
3
+ // The capability probe this replaces asked the MODEL to enumerate connectors via
4
+ // ToolSearch. A search returns what the search surfaced, which is a LOWER BOUND —
5
+ // nothing in the result distinguishes "these are the connectors" from "these are
6
+ // the connectors the search happened to return this time". Downstream then stored
7
+ // that lower bound as authoritative, so an account with Slack attached could
8
+ // report an inventory without Slack and no failure signal (vstack#838).
9
+ //
10
+ // This module asks the account instead of the model. Verified live against a
11
+ // personal claude_max org: the endpoint is POST (a GET returns 405) and each
12
+ // result carries BOTH `directoryUuid` (the catalog identity) and
13
+ // `installedServerId` (this account's installed instance). A marketplace catalog
14
+ // entry has no installed-server id, which is what establishes this as the
15
+ // INSTALLED set rather than the registry listing.
16
+ //
17
+ // INSTALLED IS NOT ATTACHED. This endpoint reports what the ACCOUNT has
18
+ // installed. It says nothing about whether a given connector's MCP server has
19
+ // finished attaching inside the `claude` child that is about to run a turn —
20
+ // this is a plain HTTPS call and does not consult that process at all. The two
21
+ // were previously conflated by accident: the ToolSearch probe could only report
22
+ // what was already attached, so an inventory implied availability (wrongly, but
23
+ // conservatively — it under-reported, which fails safe). They are now separately
24
+ // observable and can legitimately disagree: a correct `complete: true` inventory
25
+ // can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
26
+ // process (vstack#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
27
+ // availability and keep an attach-time check on the call path; do not derive
28
+ // "can I call this tool right now" from this result.
29
+ //
30
+ // Because the answer comes from the account rather than a model turn, the result
31
+ // is complete by construction — hence `complete: true` on success, and no
32
+ // "partial" state. A failure is a failure, never an empty-but-successful list.
33
+
34
+ const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
35
+ const DEFAULT_API_BASE = "https://api.anthropic.com";
36
+ const DEFAULT_PROXY_BASE = "https://mcp-proxy.anthropic.com/v1/mcp";
37
+ // OAuth-token requests to the Anthropic API require this beta header; without it
38
+ // endpoints reject the bearer credential.
39
+ const OAUTH_BETA_HEADER = "oauth-2025-04-20";
40
+
41
+ export type ConnectorEntry = {
42
+ name: string;
43
+ /** This account's installed instance. Present on every live result observed. */
44
+ installedServerId?: string;
45
+ /** Catalog identity, shared across accounts that install the same connector. */
46
+ directoryUuid?: string;
47
+ /**
48
+ * Account-side install state. `"connected"` marks the connectors the CLI
49
+ * actually attempts; everything else it never gives a `Starting connection`
50
+ * line at all. Verified live 2026-07-26: 7 `connected` / 20 `unknown` on the
51
+ * app account, and the CLI connected exactly those 7.
52
+ */
53
+ installState?: string;
54
+ description?: string;
55
+ isAuthless?: boolean;
56
+ };
57
+
58
+ // Discriminated so a caller cannot read `connectors` without having checked `ok`.
59
+ // `complete` is carried explicitly rather than implied: the whole defect this
60
+ // fixes was a result that looked authoritative while being a lower bound.
61
+ // The absent side of each variant is declared as `?: undefined` rather than
62
+ // omitted: this package compiles with `strict: false`, where narrowing a union
63
+ // by a boolean discriminant does not reliably filter members, so a bare
64
+ // `{ok:true}|{ok:false}` pair makes `inventory.reason` a compile error at every
65
+ // call site. Spelling both sides keeps the union discriminated AND readable
66
+ // without depending on strictNullChecks-era narrowing.
67
+ export type ConnectorInventory =
68
+ | { ok: true; complete: true; connectors: ConnectorEntry[]; reason?: undefined }
69
+ | { ok: false; complete: false; connectors?: undefined; reason: string };
70
+
71
+ // SCOPING IS BY TOKEN, NOT BY ORG. Verified live: the org UUID in the path is
72
+ // ignored — an all-zero UUID and the literal string "not-a-uuid" both returned
73
+ // the bearer token's own account, identically to the real org. A multi-account
74
+ // host therefore CANNOT select an account by passing its organizationUuid; the
75
+ // only thing that selects an account is which credential the token came from
76
+ // (i.e. which CLAUDE_CONFIG_DIR was read). Getting that wrong yields a
77
+ // confident, well-formed answer for the WRONG account.
78
+ //
79
+ // The real UUID is still sent rather than a placeholder, so the call keeps
80
+ // working if the API starts enforcing it.
81
+ export type ClaudeOAuthCredentials = {
82
+ accessToken: string;
83
+ organizationUuid: string;
84
+ };
85
+
86
+ type Json = Record<string, any>;
87
+
88
+ /**
89
+ * The server name Claude Code itself registers a claude.ai connector under.
90
+ *
91
+ * This is the `mcpServers` KEY to use when declaring a connector explicitly, and
92
+ * it is load-bearing rather than cosmetic: the key IS the tool namespace. Keyed
93
+ * as anything else, the same connector appears twice — once from our
94
+ * declaration and once from the CLI's own loader — and consumers that pin
95
+ * fully-qualified tool names (memsira's executor hard-codes them into
96
+ * `--allowedTools` and its system prompt, and never globs a namespace) would
97
+ * be allowed to call neither copy reliably. Verified live: keyed as the CLI's
98
+ * name there is ONE server entry and one namespace (27 servers); keyed otherwise
99
+ * both appear (28 servers).
100
+ *
101
+ * What merges is the NAMESPACE, not the connection. Under the shared name the
102
+ * declaration and the CLI's own loader each still connect: across 40 cold runs
103
+ * the baseline logged 1 Slack connect (7 proxy connects total) and the declared
104
+ * arm logged 2 Slack connects (8 total), in 20 of 20 runs with no exceptions.
105
+ * Declaring N connectors therefore costs ~2N connections, not N. They run in
106
+ * parallel — slowest-connect per run moved from a 1192ms median to 1278ms, worst
107
+ * 1544ms, well inside the 5s cap — but that was measured with ONE declaration.
108
+ */
109
+ export function connectorServerName(connectorName: string): string {
110
+ return `claude.ai ${connectorName.trim()}`;
111
+ }
112
+
113
+ /**
114
+ * The claude.ai MCP proxy endpoint for one installed connector.
115
+ *
116
+ * The `url` field is REQUIRED by the runtime schema, but the CLI does not
117
+ * connect to it: for `type: "claudeai-proxy"` it derives the endpoint from `id`.
118
+ * Caught live — pointing `url` at a local server that never responds still
119
+ * logged `Using claude.ai proxy at …/mcpsrv_01Wcus…` and connected. So this
120
+ * builds the honest value for a required field; it is `id` that must be right.
121
+ *
122
+ * That also explains why both id forms work: the `mcpsrv_…` id from
123
+ * `GET /v1/mcp_servers` and the `installedServerId` UUID this module returns
124
+ * each connected and served identical tools with the CLI's own connector
125
+ * loading disabled. Both resolve at the proxy; neither depends on `url`.
126
+ */
127
+ export function connectorProxyUrl(installedServerId: string, proxyBase: string = DEFAULT_PROXY_BASE): string {
128
+ return `${trimTrailingSlashes(proxyBase)}/${encodeURIComponent(installedServerId)}`;
129
+ }
130
+
131
+ /**
132
+ * Tool-namespace prefix for a connector, e.g. `Google Calendar` →
133
+ * `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
134
+ * connector with whitespace replaced by underscores; corroborated against the
135
+ * independently-authored CLAUDE_AI_CONNECTOR_TOOL_PATTERNS in connectors.ts,
136
+ * which was built from a live tool enumeration rather than from this rule.
137
+ */
138
+ export function connectorServerNamespace(connectorName: string): string {
139
+ return `${CONNECTOR_NS_PREFIX}${connectorName.trim().replace(/\s+/g, "_")}__`;
140
+ }
141
+
142
+ // Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
143
+ // per-account by hosts that run one sidecar per Claude account, so it must win
144
+ // over the home-directory default or a multi-account host reads the wrong
145
+ // account's connectors. Both file names are probed under each root because the
146
+ // token and the org UUID do not reliably live in the same file across versions.
147
+ export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
148
+ const roots: string[] = [];
149
+ const configDir = env.CLAUDE_CONFIG_DIR?.trim();
150
+ if (configDir) roots.push(configDir);
151
+ const home = env.HOME?.trim();
152
+ if (home) roots.push(`${home}/.claude`, home);
153
+ const seen = new Set<string>();
154
+ const paths: string[] = [];
155
+ for (const root of roots) {
156
+ for (const name of [".credentials.json", ".claude.json"]) {
157
+ const p = `${root}/${name}`;
158
+ if (!seen.has(p)) { seen.add(p); paths.push(p); }
159
+ }
160
+ }
161
+ return paths;
162
+ }
163
+
164
+ /**
165
+ * Pull the OAuth access token and organization UUID out of the Claude config.
166
+ * They are scanned independently across all candidate files because they are not
167
+ * guaranteed to co-locate: on the machine this was verified against, the token
168
+ * lives in `.credentials.json` and the org UUID in `.claude.json`.
169
+ *
170
+ * `readFile` returns undefined for a missing/unreadable path. Parse failures are
171
+ * skipped rather than thrown — a corrupt file must not mask a good one later in
172
+ * the list.
173
+ */
174
+ export function resolveClaudeOAuth(
175
+ readFile: (path: string) => string | undefined,
176
+ env: NodeJS.ProcessEnv = process.env,
177
+ ): ClaudeOAuthCredentials | undefined {
178
+ let accessToken: string | undefined;
179
+ let organizationUuid: string | undefined;
180
+
181
+ for (const path of credentialCandidatePaths(env)) {
182
+ const raw = readFile(path);
183
+ if (!raw) continue;
184
+ let parsed: Json;
185
+ try {
186
+ parsed = JSON.parse(raw) as Json;
187
+ } catch {
188
+ continue;
189
+ }
190
+ accessToken ??= nonEmptyString(parsed?.claudeAiOauth?.accessToken);
191
+ organizationUuid ??= nonEmptyString(parsed?.oauthAccount?.organizationUuid);
192
+ if (accessToken && organizationUuid) break;
193
+ }
194
+
195
+ if (!accessToken || !organizationUuid) return undefined;
196
+ return { accessToken, organizationUuid };
197
+ }
198
+
199
+ function nonEmptyString(value: unknown): string | undefined {
200
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
201
+ }
202
+
203
+ export function connectorsListUrl(organizationUuid: string, apiBase: string = DEFAULT_API_BASE): string {
204
+ return `${trimTrailingSlashes(apiBase)}/api/oauth/organizations/${encodeURIComponent(organizationUuid)}/mcp/connectors/list`;
205
+ }
206
+
207
+ // Linear-time trailing-slash trim. This was `apiBase.replace(/\/+$/, "")`, which
208
+ // CodeQL correctly flags as a polynomial regex on uncontrolled input: `apiBase`
209
+ // is a caller-supplied parameter, and an anchored `+` backtracks on a long run
210
+ // of slashes. It only became reachable as library input once this module gained
211
+ // a real export surface, which is exactly the exposure the export was for.
212
+ function trimTrailingSlashes(value: string): string {
213
+ let end = value.length;
214
+ while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) end--;
215
+ return value.slice(0, end);
216
+ }
217
+
218
+ export type ListConnectorsDeps = {
219
+ credentials: ClaudeOAuthCredentials;
220
+ fetchImpl?: typeof fetch;
221
+ apiBase?: string;
222
+ signal?: AbortSignal;
223
+ };
224
+
225
+ /**
226
+ * Enumerate the account's installed connectors. Never throws: transport and
227
+ * protocol failures come back as `{ ok: false }` with a reason, so a caller can
228
+ * distinguish "this account has no connectors" (ok, empty list) from "we could
229
+ * not find out" — the distinction the search-driven probe could not express.
230
+ *
231
+ * The reason string is built only from the HTTP status and the API's own error
232
+ * message; the bearer token is never interpolated into it or logged.
233
+ */
234
+ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<ConnectorInventory> {
235
+ const { credentials, apiBase, signal } = deps;
236
+ const fetchImpl = deps.fetchImpl ?? fetch;
237
+ const url = connectorsListUrl(credentials.organizationUuid, apiBase);
238
+ // Every failure return goes through this. Transport errors are the risk: a
239
+ // fetch/proxy layer is free to put the request headers — and therefore the
240
+ // bearer token — into the message it throws, and that message would otherwise
241
+ // land in a reason string that callers log.
242
+ const fail = (reason: string): ConnectorInventory =>
243
+ ({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
244
+
245
+ let response: Response;
246
+ try {
247
+ response = await fetchImpl(url, {
248
+ method: "POST",
249
+ headers: {
250
+ "Authorization": `Bearer ${credentials.accessToken}`,
251
+ "anthropic-beta": OAUTH_BETA_HEADER,
252
+ "Content-Type": "application/json",
253
+ },
254
+ body: "{}",
255
+ signal,
256
+ });
257
+ } catch (error) {
258
+ return fail(`connector list request failed: ${errorText(error)}`);
259
+ }
260
+
261
+ let bodyText: string;
262
+ try {
263
+ bodyText = await response.text();
264
+ } catch (error) {
265
+ return fail(`connector list response unreadable: ${errorText(error)}`);
266
+ }
267
+
268
+ if (!response.ok) {
269
+ return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
270
+ }
271
+
272
+ let parsed: Json;
273
+ try {
274
+ parsed = JSON.parse(bodyText) as Json;
275
+ } catch {
276
+ return fail("connector list returned a non-JSON body");
277
+ }
278
+
279
+ // A missing/!Array `results` is a protocol change, not an empty account. Treat
280
+ // it as failure — reporting "no connectors" here would recreate exactly the
281
+ // silent-wrong-answer failure this module exists to remove.
282
+ if (!Array.isArray(parsed?.results)) {
283
+ return fail("connector list response had no results array");
284
+ }
285
+
286
+ const connectors: ConnectorEntry[] = [];
287
+ for (const raw of parsed.results as unknown[]) {
288
+ const entry = raw as Json;
289
+ const name = nonEmptyString(entry?.name);
290
+ // An unnamed entry cannot be matched to a tool namespace by any consumer,
291
+ // so silently keeping it would understate the inventory in a way the
292
+ // caller could not detect. Fail instead.
293
+ if (!name) {
294
+ return fail("connector list contained an entry with no name");
295
+ }
296
+ connectors.push({
297
+ name,
298
+ installedServerId: nonEmptyString(entry?.installedServerId),
299
+ directoryUuid: nonEmptyString(entry?.directoryUuid),
300
+ installState: nonEmptyString(entry?.installState),
301
+ description: nonEmptyString(entry?.description),
302
+ isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
303
+ });
304
+ }
305
+
306
+ return { ok: true, complete: true, connectors };
307
+ }
308
+
309
+ function apiErrorSuffix(bodyText: string): string {
310
+ try {
311
+ const message = (JSON.parse(bodyText) as Json)?.error?.message;
312
+ return typeof message === "string" && message.trim() ? ` (${message.trim()})` : "";
313
+ } catch {
314
+ return "";
315
+ }
316
+ }
317
+
318
+ // Replace the bearer token wherever it appears in text headed for a caller.
319
+ // Also covers a URL-encoded rendering, since some transports encode headers into
320
+ // an error's message. Short/empty tokens are not substituted — an over-eager
321
+ // match would corrupt unrelated text.
322
+ function redactSecret(text: string, secret: string): string {
323
+ if (!secret || secret.length < 8) return text;
324
+ let out = text;
325
+ for (const form of new Set([secret, encodeURIComponent(secret)])) {
326
+ out = out.split(form).join("[redacted]");
327
+ }
328
+ return out;
329
+ }
330
+
331
+ function errorText(error: unknown): string {
332
+ return error instanceof Error ? error.message : String(error);
333
+ }