@vanillagreen/pi-claude-bridge 1.9.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.
- package/README.md +24 -11
- package/bundle/connector-inventory.js +10 -0
- package/bundle/index.js +2418 -1794
- package/package.json +6 -6
- package/src/assistant-stream.ts +313 -46
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +43 -1
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +52 -0
- package/src/connectors.ts +142 -1
- package/src/convert.ts +14 -0
- package/src/index.ts +330 -172
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +218 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +42 -10
- package/src/session-persistence.ts +7 -2
- package/src/tool-pairing-audit.ts +48 -0
- package/src/typebox-to-zod.ts +9 -3
|
@@ -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
|
+
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
|
|
35
35
|
const DEFAULT_API_BASE = "https://api.anthropic.com";
|
|
36
|
+
const DEFAULT_PROXY_BASE = "https://mcp-proxy.anthropic.com/v1/mcp";
|
|
36
37
|
// OAuth-token requests to the Anthropic API require this beta header; without it
|
|
37
38
|
// endpoints reject the bearer credential.
|
|
38
39
|
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
|
@@ -43,6 +44,13 @@ export type ConnectorEntry = {
|
|
|
43
44
|
installedServerId?: string;
|
|
44
45
|
/** Catalog identity, shared across accounts that install the same connector. */
|
|
45
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;
|
|
46
54
|
description?: string;
|
|
47
55
|
isAuthless?: boolean;
|
|
48
56
|
};
|
|
@@ -77,6 +85,49 @@ export type ClaudeOAuthCredentials = {
|
|
|
77
85
|
|
|
78
86
|
type Json = Record<string, any>;
|
|
79
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
|
+
|
|
80
131
|
/**
|
|
81
132
|
* Tool-namespace prefix for a connector, e.g. `Google Calendar` →
|
|
82
133
|
* `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
|
|
@@ -246,6 +297,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
246
297
|
name,
|
|
247
298
|
installedServerId: nonEmptyString(entry?.installedServerId),
|
|
248
299
|
directoryUuid: nonEmptyString(entry?.directoryUuid),
|
|
300
|
+
installState: nonEmptyString(entry?.installState),
|
|
249
301
|
description: nonEmptyString(entry?.description),
|
|
250
302
|
isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
|
|
251
303
|
});
|
package/src/connectors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type HookCallback, type query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { normalizeConnectorWriteMode, type Config, type ConnectorWriteMode } from "./config.js";
|
|
3
3
|
import { MCP_SERVER_NAME } from "./skills.js";
|
|
4
|
+
import { connectorProxyUrl, connectorServerName, type ConnectorInventory } from "./connector-inventory.js";
|
|
4
5
|
|
|
5
6
|
// Disable Claude Code built-ins in the provider path. Pi owns tool execution;
|
|
6
7
|
// Claude reaches Pi tools through the bridged MCP server instead.
|
|
@@ -158,6 +159,22 @@ function connectorNameWords(segment: string): string[] {
|
|
|
158
159
|
// Explicit known write tool names (current claude.ai connectors). Passed to the
|
|
159
160
|
// SDK disallowedTools so today's writes are removed from the model's context by
|
|
160
161
|
// exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
|
|
162
|
+
//
|
|
163
|
+
// PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
|
|
164
|
+
// dependents that gate real user-facing approvals on them (vstack#892):
|
|
165
|
+
//
|
|
166
|
+
// memsira routes connector writes through its own gated approval flow
|
|
167
|
+
// drovr keeps its chat sidecar permanently write-`deny` and runs an
|
|
168
|
+
// approved write as a separate one-shot `claude -p` scoped by
|
|
169
|
+
// `--allowedTools` to exactly one connector tool (drovr#288)
|
|
170
|
+
//
|
|
171
|
+
// Both pin the actions they expose against this classification, because "the
|
|
172
|
+
// sidecar structurally cannot do this itself" is THIS module's claim, not
|
|
173
|
+
// theirs. RECLASSIFYING AN ENTRY HERE AS A READ WOULD MAKE A CONSUMER'S
|
|
174
|
+
// CONFIRMATION CARD BYPASSABLE. Additions are safe and expected; removals and
|
|
175
|
+
// read-verb reclassifications are breaking — coordinate first, see
|
|
176
|
+
// docs/cross-repo.md. `unit-connectors.mjs` pins the set so a change has to be
|
|
177
|
+
// deliberate.
|
|
161
178
|
export const CONNECTOR_WRITE_TOOLS = [
|
|
162
179
|
`${CONNECTOR_NS_GMAIL}create_draft`,
|
|
163
180
|
`${CONNECTOR_NS_GMAIL}create_label`,
|
|
@@ -198,6 +215,45 @@ export const CONNECTOR_WRITE_TOOLS = [
|
|
|
198
215
|
`${CONNECTOR_NS_ATLASSIAN}createCompassCustomFieldDefinition`,
|
|
199
216
|
];
|
|
200
217
|
|
|
218
|
+
/**
|
|
219
|
+
* True for a tool that the `claude` CHILD executes itself, so Pi must never be
|
|
220
|
+
* asked to dispatch it.
|
|
221
|
+
*
|
|
222
|
+
* WHY THIS EXISTS. Every other tool the model calls in a bridge turn is a PI
|
|
223
|
+
* tool: Pi hands its tool set to the bridge, the bridge re-offers it to the
|
|
224
|
+
* child through the in-process MCP server, and a `tool_use` coming back is the
|
|
225
|
+
* child asking PI to run something. The bridge is built around that direction —
|
|
226
|
+
* it mirrors the call into the Pi stream, ends the Pi turn with `toolUse`, and
|
|
227
|
+
* the MCP handler blocks until Pi delivers the result.
|
|
228
|
+
*
|
|
229
|
+
* claude.ai connectors run the other way. They are the CHILD's own MCP servers,
|
|
230
|
+
* attached to the authenticated account and reachable only from inside that
|
|
231
|
+
* process. Pi has never heard of them. Mirroring one into the Pi stream anyway
|
|
232
|
+
* made Pi's agent loop look the name up in `context.tools`, miss, and write a
|
|
233
|
+
* synthetic `Tool <name> not found` error result into the transcript — while the
|
|
234
|
+
* child went on and executed the real call. The Pi transcript then RECORDED A
|
|
235
|
+
* FAILURE FOR A CALL THAT SUCCEEDED, next to an answer built from the real
|
|
236
|
+
* payload, so the model's correct answer read as a fabrication (drovr#311,
|
|
237
|
+
* memsira#320). The false result is also projected back into the child's session
|
|
238
|
+
* on a rebuild (`syncSharedSession`), which is how a lie in a mirror becomes a
|
|
239
|
+
* lie in the conversation of record.
|
|
240
|
+
*
|
|
241
|
+
* The test is the NAMESPACE, deliberately, not "does this name resolve to a Pi
|
|
242
|
+
* tool". Every claude.ai connector server lives under `mcp__claude_ai_`, and
|
|
243
|
+
* that is the only tool class the bridge knowingly delegates. A broader
|
|
244
|
+
* "unresolvable ⇒ delegated" rule would also swallow a genuine tool-name
|
|
245
|
+
* mismatch between Pi and the child, which SHOULD still surface as a loud
|
|
246
|
+
* dispatcher error.
|
|
247
|
+
*
|
|
248
|
+
* Takes the RAW SDK tool name, before `mapToolName` — connector names have no
|
|
249
|
+
* Pi-side counterpart, so mapping them is meaningless. Accepts a missing name
|
|
250
|
+
* rather than asserting one: this decides whether Pi is allowed to dispatch a
|
|
251
|
+
* block, and a nameless block is not a connector, so it answers `false`.
|
|
252
|
+
*/
|
|
253
|
+
export function isChildExecutedTool(name: string | undefined): boolean {
|
|
254
|
+
return typeof name === "string" && name.startsWith(CONNECTOR_NS_PREFIX);
|
|
255
|
+
}
|
|
256
|
+
|
|
201
257
|
// Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED, twice:
|
|
202
258
|
//
|
|
203
259
|
// 1. Namespace: the whole `mcp__claude_ai_<Server>__` space counts, not just the
|
|
@@ -306,6 +362,12 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
306
362
|
};
|
|
307
363
|
}
|
|
308
364
|
|
|
365
|
+
// The deny reason is handed verbatim to the `claude` child's model, so it must
|
|
366
|
+
// stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
|
|
367
|
+
// Naming one host told a different app's model to use a product it has never
|
|
368
|
+
// heard of, which is confusing at exactly the moment someone is debugging a
|
|
369
|
+
// refused write (vstack#892). Each host describes its own approval flow in its
|
|
370
|
+
// own prompt; this string only has to say that one exists.
|
|
309
371
|
function connectorWriteDenyOutput(toolName: string) {
|
|
310
372
|
return {
|
|
311
373
|
hookSpecificOutput: {
|
|
@@ -313,7 +375,7 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
313
375
|
permissionDecision: "deny" as const,
|
|
314
376
|
permissionDecisionReason:
|
|
315
377
|
`Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
|
|
316
|
-
`Connector writes must go through
|
|
378
|
+
`Connector writes must go through the host application's gated approval flow.`,
|
|
317
379
|
},
|
|
318
380
|
};
|
|
319
381
|
}
|
|
@@ -357,3 +419,82 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
|
|
|
357
419
|
allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
|
|
358
420
|
};
|
|
359
421
|
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Explicit `mcpServers` declarations for the account's CONNECTED connectors.
|
|
425
|
+
*
|
|
426
|
+
* Why this exists (vstack#832): claude.ai connectors load async and non-blocking,
|
|
427
|
+
* and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
|
|
428
|
+
* CLI has even fetched the connector list. The model therefore composes its first
|
|
429
|
+
* answer against a manifest containing no connectors and says it has no access,
|
|
430
|
+
* while the connector attaches ~1s later and is never asked. Measured end to end
|
|
431
|
+
* on 40 cold sidecars (memsira, 2026-07-26): a connector tool call happened in
|
|
432
|
+
* 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
|
|
433
|
+
* went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
|
|
434
|
+
* declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
|
|
435
|
+
*
|
|
436
|
+
* It is also FASTER, which is the opposite of what the startup barrier suggests.
|
|
437
|
+
* The barrier is real but small and sub-linear — manifest build 490ms none /
|
|
438
|
+
* 1996ms one / 2574ms seven, so seven costs +578ms over one, not 7x. Meanwhile
|
|
439
|
+
* first token drops from a 9840ms median (worst 35.7s) to 6887ms (worst 7.8s),
|
|
440
|
+
* because declaring removes the model's speculative ToolSearch and dead ends.
|
|
441
|
+
* The barrier buys back more than it spends.
|
|
442
|
+
*
|
|
443
|
+
* `alwaysLoad` is the mechanism: it blocks startup until the server is connected
|
|
444
|
+
* (5s cap) precisely "since the tools must be present when the turn-1 prompt is
|
|
445
|
+
* built". It is a field on the server config, so the connector has to be a server
|
|
446
|
+
* WE declare — the CLI's own loader never applies it.
|
|
447
|
+
*
|
|
448
|
+
* Two things here are load-bearing and were established by measurement, not
|
|
449
|
+
* inference:
|
|
450
|
+
*
|
|
451
|
+
* 1. The key MUST be the CLI's own server name (`connectorServerName`). The key
|
|
452
|
+
* is the tool namespace, so any other key yields the connector twice under two
|
|
453
|
+
* namespaces. Consumers that pin fully-qualified tool names rather than
|
|
454
|
+
* globbing a namespace then break.
|
|
455
|
+
* 2. Only `installState === "connected"` connectors are declared. The rest are
|
|
456
|
+
* never attempted by the CLI either, and declaring them would mean asking
|
|
457
|
+
* `alwaysLoad` to block startup on servers that cannot connect.
|
|
458
|
+
*
|
|
459
|
+
* Deliberately NOT typed against the SDK's `McpServerConfig`: that exported union
|
|
460
|
+
* omits the `claudeai-proxy` variant entirely, and its `McpClaudeAIProxyServerConfig`
|
|
461
|
+
* has no `alwaysLoad` field — while the runtime zod schema in the shipped CLI does.
|
|
462
|
+
* Typings and runtime disagree; the runtime honours `alwaysLoad` (verified live),
|
|
463
|
+
* so this builds the object the runtime accepts and casts once, here, with the
|
|
464
|
+
* reason recorded rather than spread across call sites.
|
|
465
|
+
*/
|
|
466
|
+
export function connectorMcpServers(inventory: ConnectorInventory): Record<string, unknown> {
|
|
467
|
+
if (!inventory.ok) return {};
|
|
468
|
+
// Escape hatch. `alwaysLoad` holds startup until each declared server
|
|
469
|
+
// connects, and the bound on that wait is NOT established: the SDK doc
|
|
470
|
+
// comment says a 5s cap while the CLI logs `timeout of 30000ms`, and four
|
|
471
|
+
// attempts to force a genuine mid-handshake hang each failed fast for a
|
|
472
|
+
// different reason, so the worst case was never observed. An account with
|
|
473
|
+
// slow or numerous connectors therefore has an unquantified turn-1 delay,
|
|
474
|
+
// and this switch turns declarations off without giving up connectors.
|
|
475
|
+
if (connectorDeclarationsDisabled()) return {};
|
|
476
|
+
const servers: Record<string, unknown> = {};
|
|
477
|
+
for (const entry of inventory.connectors) {
|
|
478
|
+
if (entry.installState !== "connected") continue;
|
|
479
|
+
if (!entry.installedServerId) continue;
|
|
480
|
+
servers[connectorServerName(entry.name)] = {
|
|
481
|
+
type: "claudeai-proxy",
|
|
482
|
+
url: connectorProxyUrl(entry.installedServerId),
|
|
483
|
+
id: entry.installedServerId,
|
|
484
|
+
alwaysLoad: true,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return servers;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
|
|
493
|
+
* connector declarations while leaving connectors themselves enabled. Falls back
|
|
494
|
+
* to the pre-#832 behaviour: connectors still load, they just race the turn-1
|
|
495
|
+
* manifest again.
|
|
496
|
+
*/
|
|
497
|
+
export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
498
|
+
const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
|
|
499
|
+
return v === "off" || v === "0" || v === "false" || v === "no";
|
|
500
|
+
}
|
package/src/convert.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { Message as PiMessage } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { ContentBlock, Message as SessionMessage } from "cc-session-io";
|
|
6
6
|
import { pascalCase } from "change-case";
|
|
7
|
+
import { isChildExecutedTool } from "./connectors.js";
|
|
7
8
|
|
|
8
9
|
export const PROVIDER_ID = "claude-bridge";
|
|
9
10
|
|
|
@@ -21,6 +22,19 @@ export function sanitizeToolId(id: string, cache: Map<string, string>): string {
|
|
|
21
22
|
|
|
22
23
|
export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
|
|
23
24
|
if (!name) return "";
|
|
25
|
+
// A claude.ai connector name is ALREADY the child's own tool id — the child
|
|
26
|
+
// owns that namespace natively. PascalCasing it invented an alias
|
|
27
|
+
// (`mcp__claude_ai_Slack__slack_search_channels` →
|
|
28
|
+
// `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
|
|
29
|
+
// projected history, so the model imitated it on the next turn and got a
|
|
30
|
+
// real `Tool ... not found` from the MCP dispatcher before retrying the
|
|
31
|
+
// canonical name — one wasted round-trip per affected call (memsira#320).
|
|
32
|
+
//
|
|
33
|
+
// Connector names stopped reaching this function at all once they stopped
|
|
34
|
+
// being mirrored as Pi tool calls (isChildExecutedTool), so this is the
|
|
35
|
+
// belt to that braces: LEGACY Pi history recorded before that fix still
|
|
36
|
+
// carries them, and a rebuild would still project the alias.
|
|
37
|
+
if (isChildExecutedTool(name)) return name;
|
|
24
38
|
const normalized = name.toLowerCase();
|
|
25
39
|
if (customToolNameToSdk) {
|
|
26
40
|
const mapped = customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized);
|