@vanillagreen/pi-claude-bridge 4.0.0 → 4.0.2
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 +41 -90
- package/bundle/connector-inventory.js +8 -7
- package/bundle/index.js +34086 -25259
- package/package.json +5 -3
- package/src/account-router.ts +3 -3
- package/src/assistant-stream.ts +95 -79
- package/src/auth-presence.ts +1 -2
- package/src/billing-identity.ts +116 -0
- package/src/bridge-commands.ts +1 -1
- package/src/bridge-state.ts +14 -12
- package/src/config.ts +14 -6
- package/src/connector-audit.ts +16 -16
- package/src/connector-cache.ts +6 -7
- package/src/connector-inventory.ts +13 -15
- package/src/connector-runtime.ts +8 -8
- package/src/connectors.ts +35 -35
- package/src/consume-query.ts +20 -5
- package/src/convert.ts +1 -1
- package/src/debug.ts +2 -2
- package/src/index.ts +238 -71
- package/src/models.ts +5 -5
- package/src/native-provider.ts +3 -1
- package/src/query-options.ts +4 -4
- package/src/query-state.ts +102 -54
- package/src/query-teardown.ts +19 -3
- package/src/rate-limit.ts +1 -1
- package/src/session-persistence.ts +13 -13
- package/src/session-verify.ts +5 -5
- package/src/tool-mapping.ts +41 -8
- package/src/tool-pairing-audit.ts +7 -7
package/src/config.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface Config {
|
|
|
44
44
|
modelEffortOverrides?: Record<string, BridgeEffortLevel>;
|
|
45
45
|
/**
|
|
46
46
|
* Verbatim override for the child's filesystem setting sources.
|
|
47
|
-
* Defaults (see settingSourcesForQuery in connectors.ts): connectors
|
|
47
|
+
* Defaults (see settingSourcesForQuery in connectors.ts) : connectors
|
|
48
48
|
* mode uses ["user"] only, so repo-controlled `.claude/settings.json`
|
|
49
49
|
* (project/local scope) cannot inject `env`/`apiKeyHelper` into the
|
|
50
50
|
* child. Listing "project"/"local" here reopens that surface — only do
|
|
@@ -95,17 +95,25 @@ function expandHome(input: string): string {
|
|
|
95
95
|
return input;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
|
|
99
|
+
* means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
|
|
100
|
+
* where the renderer does not, putting the two on different roots. Hoisted, so
|
|
101
|
+
* a circular import cannot reach it inside a temporal dead zone. */
|
|
102
|
+
function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
|
|
103
|
+
|
|
98
104
|
/**
|
|
99
|
-
* The Pi agent config dir: `PI_CODING_AGENT_DIR` when
|
|
100
|
-
*
|
|
101
|
-
* so a host app that owns the agent dir owns
|
|
105
|
+
* The Pi agent config dir: `PI_CODING_AGENT_DIR` when it names a root-anchored
|
|
106
|
+
* path, else `~/.pi/agent`. Every bridge default routes through this function
|
|
107
|
+
* so a host app that owns the agent dir owns
|
|
108
|
+
* those paths too.
|
|
102
109
|
*/
|
|
103
110
|
export function piUserDir(): string {
|
|
104
|
-
|
|
111
|
+
const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
|
|
112
|
+
return resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
/**
|
|
108
|
-
* Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
|
|
116
|
+
* Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`) : a host app embedding the bridge
|
|
109
117
|
* declares that nothing outside its explicitly configured dirs may be read.
|
|
110
118
|
* Disables every cwd/home discovery fallback — all AGENTS.md discovery,
|
|
111
119
|
* extension-manager settings, project `.pi/` settings + claude-bridge.json,
|
package/src/connector-audit.ts
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A claude.ai connector tool runs INSIDE the child, on the child's own MCP
|
|
4
4
|
// servers, and is deliberately never mirrored into the Pi stream (see
|
|
5
|
-
// isChildExecutedTool).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the call happened at all, so "did it really look that up?" could only be
|
|
9
|
-
// answered from the child's own transcript.
|
|
5
|
+
// isChildExecutedTool). Mirroring would write `Tool <name> not found` into the
|
|
6
|
+
// transcript for a successful call. Not mirroring leaves the Pi session with no
|
|
7
|
+
// record that the call happened, so the audit entry records it without dispatch.
|
|
10
8
|
//
|
|
11
9
|
// A pi `CustomEntry` closes that gap without reintroducing the bug: it is
|
|
12
10
|
// persisted in the session file, is NOT a content block, and is documented as
|
|
@@ -91,17 +89,15 @@ let auditSink: ConnectorCallAuditSink | undefined;
|
|
|
91
89
|
* Install (or clear, with `undefined`) a host sink for connector-call records.
|
|
92
90
|
*
|
|
93
91
|
* **The sink ADDS a destination, it never replaces `appendEntry`.** A host that
|
|
94
|
-
* drives real `AgentSession`s gets
|
|
95
|
-
* replacing sink would
|
|
96
|
-
*
|
|
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
|
|
92
|
+
* drives real `AgentSession`s gets transcript-local entries automatically. A
|
|
93
|
+
* replacing sink would remove those entries and make the session record
|
|
94
|
+
* incomplete. A host with both a session and a sink has
|
|
99
95
|
* asked for both and gets both.
|
|
100
96
|
*
|
|
101
97
|
* It exists because the OTHER embedding shape gets nothing at all: drovr loads
|
|
102
98
|
* the bundle through a throwaway resource loader over
|
|
103
99
|
* `createAgentSessionServices` with no session, so `extensionApi` is undefined
|
|
104
|
-
* and every record it appended went nowhere (
|
|
100
|
+
* and every record it appended went nowhere (measured live). The
|
|
105
101
|
* sink is the seam such a host can reach without one.
|
|
106
102
|
*
|
|
107
103
|
* A callback rather than another `Symbol.for` global on purpose: the bundle
|
|
@@ -164,10 +160,12 @@ export function recordConnectorCallResult(
|
|
|
164
160
|
isError: boolean,
|
|
165
161
|
byteSize: number | undefined,
|
|
166
162
|
): boolean {
|
|
167
|
-
const pending = queryCtx.
|
|
163
|
+
const pending = queryCtx.childSideCalls.get(toolUseId);
|
|
168
164
|
if (pending?.recorded) return false;
|
|
169
165
|
const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
|
|
170
|
-
|
|
166
|
+
// Reached only for a child-executed connector result, so an entry this call
|
|
167
|
+
// never saw noted is a connector's.
|
|
168
|
+
queryCtx.childSideCalls.set(toolUseId, { ...pending, kind: pending?.kind ?? "connector", name, childSessionId, recorded: true });
|
|
171
169
|
return appendConnectorCallAudit({
|
|
172
170
|
name,
|
|
173
171
|
toolUseId,
|
|
@@ -187,9 +185,11 @@ export function recordConnectorCallResult(
|
|
|
187
185
|
*/
|
|
188
186
|
export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
|
|
189
187
|
let appended = 0;
|
|
190
|
-
for (const [toolUseId, state] of queryCtx.
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
for (const [toolUseId, state] of queryCtx.childSideCalls) {
|
|
189
|
+
// Foreign MCP calls share the map but not the trail: its entries are
|
|
190
|
+
// `claude-bridge-connector-call` records naming a claude.ai connector.
|
|
191
|
+
if (state.recorded || state.kind !== "connector") continue;
|
|
192
|
+
queryCtx.childSideCalls.set(toolUseId, { ...state, recorded: true });
|
|
193
193
|
const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
|
|
194
194
|
if (appendConnectorCallAudit({
|
|
195
195
|
name: state.name,
|
package/src/connector-cache.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
// Cross-PROCESS cache of the connector inventory
|
|
1
|
+
// Cross-PROCESS cache of the connector inventory.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// primes the inventory at provider registration, but the fetch takes ~1.5s
|
|
4
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
|
|
5
|
+
// out with no declarations and preserves the failure this cache prevents.
|
|
6
6
|
//
|
|
7
7
|
// An in-process cache cannot help the consumer that needs it most. drovr builds
|
|
8
8
|
// a sidecar lazily on the first bridge round and, since their sidecars are
|
|
9
|
-
// per-SESSION,
|
|
10
|
-
// once per chat, indefinitely, and every one of those is a cold process. The
|
|
9
|
+
// per-SESSION, each chat starts a separate cold process. The
|
|
11
10
|
// cache therefore has to survive process boundaries.
|
|
12
11
|
//
|
|
13
12
|
// Keyed by credential scope, because that is what selects the account: the org
|
|
@@ -18,7 +17,7 @@
|
|
|
18
17
|
// wrong-version cache returns undefined and the caller falls back to today's
|
|
19
18
|
// behaviour — the same fail-open contract as the inventory call itself.
|
|
20
19
|
//
|
|
21
|
-
// The ON-DISK FORMAT HAS AN EXTERNAL READER
|
|
20
|
+
// The ON-DISK FORMAT HAS AN EXTERNAL READER. drovr quarantines this
|
|
22
21
|
// bundle to its sidecar process, so rather than calling `listAccountConnectors`
|
|
23
22
|
// in-process it re-implements the reader half — path
|
|
24
23
|
// `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
|
|
@@ -43,7 +42,7 @@ import type { ConnectorEntry } from "./connector-inventory.js";
|
|
|
43
42
|
const CACHE_VERSION = 2;
|
|
44
43
|
/** Long enough to be useful across a machine's lifetime, short enough that a
|
|
45
44
|
* removed connector stops being declared without needing a manual purge. A
|
|
46
|
-
* stale entry is not dangerous — a connector that
|
|
45
|
+
* stale entry is not dangerous — a connector that does not resolve simply
|
|
47
46
|
* fails to connect, which is the fail-open path — so this is hygiene, not a
|
|
48
47
|
* correctness boundary. */
|
|
49
48
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// nothing in the result distinguishes "these are the connectors" from "these are
|
|
6
6
|
// the connectors the search happened to return this time". Downstream then stored
|
|
7
7
|
// that lower bound as authoritative, so an account with Slack attached could
|
|
8
|
-
// report an inventory without Slack and no failure signal
|
|
8
|
+
// report an inventory without Slack and no failure signal.
|
|
9
9
|
//
|
|
10
10
|
// This module asks the account instead of the model. Verified live against a
|
|
11
11
|
// personal claude_max org: the endpoint is POST (a GET returns 405) and each
|
|
@@ -18,12 +18,11 @@
|
|
|
18
18
|
// installed. It says nothing about whether a given connector's MCP server has
|
|
19
19
|
// finished attaching inside the `claude` child that is about to run a turn —
|
|
20
20
|
// this is a plain HTTPS call and does not consult that process at all. The two
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// conservatively — it under-reported, which fails safe). They are now separately
|
|
21
|
+
// are separate: the ToolSearch probe can report only what is attached, so an
|
|
22
|
+
// inventory must not imply availability. They are
|
|
24
23
|
// observable and can legitimately disagree: a correct `complete: true` inventory
|
|
25
24
|
// can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
|
|
26
|
-
// process
|
|
25
|
+
// process. Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
|
|
27
26
|
// availability and keep an attach-time check on the call path; do not derive
|
|
28
27
|
// "can I call this tool right now" from this result.
|
|
29
28
|
//
|
|
@@ -47,8 +46,7 @@ export type ConnectorEntry = {
|
|
|
47
46
|
/**
|
|
48
47
|
* Account-side install state. `"connected"` marks the connectors the CLI
|
|
49
48
|
* actually attempts; everything else it never gives a `Starting connection`
|
|
50
|
-
* line at all.
|
|
51
|
-
* app account, and the CLI connected exactly those 7.
|
|
49
|
+
* line at all. The CLI attempts exactly the entries marked `connected`.
|
|
52
50
|
*/
|
|
53
51
|
installState?: string;
|
|
54
52
|
description?: string;
|
|
@@ -246,8 +244,8 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
246
244
|
// fetch/proxy layer is free to put the request headers — and therefore the
|
|
247
245
|
// bearer token — into the message it throws, and that message would otherwise
|
|
248
246
|
// land in a reason string that callers log.
|
|
249
|
-
const fail = (reason: string): ConnectorInventory =>
|
|
250
|
-
({ ok: false, complete: false, reason: redactSecret(reason
|
|
247
|
+
const fail = (key: string, value: string | number, reason: string): ConnectorInventory =>
|
|
248
|
+
({ ok: false, complete: false, reason: redactSecret(`${key}=${JSON.stringify(value)}\n${reason}`, credentials.accessToken) });
|
|
251
249
|
|
|
252
250
|
let response: Response;
|
|
253
251
|
try {
|
|
@@ -262,32 +260,32 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
262
260
|
signal,
|
|
263
261
|
});
|
|
264
262
|
} catch (error) {
|
|
265
|
-
return fail(`connector list request failed: ${errorText(error)}`);
|
|
263
|
+
return fail("connector-request", "transport", `connector list request failed: ${errorText(error)}`);
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
let bodyText: string;
|
|
269
267
|
try {
|
|
270
268
|
bodyText = await response.text();
|
|
271
269
|
} catch (error) {
|
|
272
|
-
return fail(`connector list response unreadable: ${errorText(error)}`);
|
|
270
|
+
return fail("connector-response", "unreadable", `connector list response unreadable: ${errorText(error)}`);
|
|
273
271
|
}
|
|
274
272
|
|
|
275
273
|
if (!response.ok) {
|
|
276
|
-
return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
274
|
+
return fail("connector-http", response.status, `connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
277
275
|
}
|
|
278
276
|
|
|
279
277
|
let parsed: Json;
|
|
280
278
|
try {
|
|
281
279
|
parsed = JSON.parse(bodyText) as Json;
|
|
282
280
|
} catch {
|
|
283
|
-
return fail("connector list returned a non-JSON body");
|
|
281
|
+
return fail("connector-json", "invalid", "connector list returned a non-JSON body");
|
|
284
282
|
}
|
|
285
283
|
|
|
286
284
|
// A missing/!Array `results` is a protocol change, not an empty account. Treat
|
|
287
285
|
// it as failure — reporting "no connectors" here would recreate exactly the
|
|
288
286
|
// silent-wrong-answer failure this module exists to remove.
|
|
289
287
|
if (!Array.isArray(parsed?.results)) {
|
|
290
|
-
return fail("connector list response had no results array");
|
|
288
|
+
return fail("connector-results", "not-array", "connector list response had no results array");
|
|
291
289
|
}
|
|
292
290
|
|
|
293
291
|
const connectors: ConnectorEntry[] = [];
|
|
@@ -298,7 +296,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
298
296
|
// so silently keeping it would understate the inventory in a way the
|
|
299
297
|
// caller could not detect. Fail instead.
|
|
300
298
|
if (!name) {
|
|
301
|
-
return fail("connector list contained an entry with no name");
|
|
299
|
+
return fail("connector-name", connectors.length, "connector list contained an entry with no name");
|
|
302
300
|
}
|
|
303
301
|
connectors.push({
|
|
304
302
|
name,
|
package/src/connector-runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Connector prime/snapshot host: the per-credential-scope inventory cache the
|
|
2
|
-
// query path reads synchronously
|
|
2
|
+
// query path reads synchronously. Extracted from index.ts —
|
|
3
3
|
// this is process-lifetime runtime state, not provider streaming logic.
|
|
4
4
|
//
|
|
5
5
|
// Connector declarations for the query path, cached per credential scope. The
|
|
@@ -32,15 +32,15 @@ export function readCredentialFile(path: string): string | undefined {
|
|
|
32
32
|
const connectorServerCache = new Map<string, Record<string, unknown>>();
|
|
33
33
|
const connectorServerPending = new Set<string>();
|
|
34
34
|
// Epoch ms of the last FAILED inventory attempt per scope, so a persistently
|
|
35
|
-
// failing account cools down instead of issuing one
|
|
36
|
-
//
|
|
35
|
+
// failing account cools down instead of issuing one HTTPS request per turn
|
|
36
|
+
// Missing credentials are exempt: that check is a local file read
|
|
37
37
|
// with no request to bound, and a just-completed `claude login` must take
|
|
38
38
|
// effect on the next turn.
|
|
39
39
|
const connectorServerFailureAt = new Map<string, number>();
|
|
40
40
|
|
|
41
41
|
// Deadline on the inventory round trip. Without one, a hung claude.ai request
|
|
42
42
|
// held the pending flag forever — same budget as the account-host probe's
|
|
43
|
-
// ACCOUNT_PROBE_DEADLINE_MS
|
|
43
|
+
// ACCOUNT_PROBE_DEADLINE_MS.
|
|
44
44
|
const CONNECTOR_PRIME_TIMEOUT_MS = 10_000;
|
|
45
45
|
const CONNECTOR_PRIME_FAILURE_COOLDOWN_MS = 60_000;
|
|
46
46
|
|
|
@@ -81,12 +81,12 @@ export function connectorCredentialEnv(claudeConfigDir: string | undefined = pro
|
|
|
81
81
|
//
|
|
82
82
|
// FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
|
|
83
83
|
// all resolve to "declare nothing" rather than breaking the turn. Failures are
|
|
84
|
-
// NOT cached
|
|
84
|
+
// NOT cached. Caching a transient registration failure would pin `{}` for the
|
|
85
85
|
// process lifetime, keeping every later turn undeclared and the disk-cache
|
|
86
86
|
// fallback unreachable. Leaving the key unset makes the next snapshot retry;
|
|
87
87
|
// the pending set dedupes concurrent fetches, and a failed inventory attempt
|
|
88
88
|
// stamps a per-scope cooldown so a persistently failing account backs off
|
|
89
|
-
// instead of re-priming on every turn
|
|
89
|
+
// instead of re-priming on every turn.
|
|
90
90
|
export function primeConnectorServers(claudeConfigDir?: string, overrides: PrimeConnectorOverrides = {}): void {
|
|
91
91
|
const key = connectorScopeKey(claudeConfigDir);
|
|
92
92
|
if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
|
|
@@ -124,7 +124,7 @@ export function primeConnectorServers(claudeConfigDir?: string, overrides: Prime
|
|
|
124
124
|
connectorServerFailureAt.delete(key);
|
|
125
125
|
// Persist so the NEXT cold process has this synchronously. Priming always
|
|
126
126
|
// loses the race against turn 1 in its own process; a cache written by an
|
|
127
|
-
// earlier run is the only thing turn 1 can read in time
|
|
127
|
+
// earlier run is the only thing turn 1 can read in time.
|
|
128
128
|
if (writeCachedConnectors(inventory.connectors, key)) {
|
|
129
129
|
debug(`connectors: cached ${inventory.connectors.length} entries`);
|
|
130
130
|
}
|
|
@@ -148,7 +148,7 @@ export function connectorServersSnapshot(claudeConfigDir?: string): Record<strin
|
|
|
148
148
|
primeConnectorServers(claudeConfigDir);
|
|
149
149
|
// Fall back to the previous run's inventory, read synchronously. This is the
|
|
150
150
|
// only thing that can populate turn 1 of a cold process, because priming
|
|
151
|
-
// cannot finish before the first query is built
|
|
151
|
+
// cannot finish before the first query is built.
|
|
152
152
|
const cached = readCachedConnectors(key);
|
|
153
153
|
if (!cached) return {};
|
|
154
154
|
const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
|
package/src/connectors.ts
CHANGED
|
@@ -57,7 +57,7 @@ export function connectorsEnabledFor(config?: Config): boolean {
|
|
|
57
57
|
// isolation (no sources), which drops the connectors even with
|
|
58
58
|
// ENABLE_CLAUDEAI_MCP_SERVERS=1. So connectors mode must pass SOME source list.
|
|
59
59
|
//
|
|
60
|
-
// It must be `["user"]` and nothing more
|
|
60
|
+
// It must be `["user"]` and nothing more. Connector state lives in
|
|
61
61
|
// USER scope — the account's config dir (CLAUDE_CONFIG_DIR for managed router
|
|
62
62
|
// profiles) — so user scope is sufficient for connectors to surface. Claude
|
|
63
63
|
// Code settings files can also carry an `env` map and `apiKeyHelper`; including
|
|
@@ -98,7 +98,7 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
|
|
|
98
98
|
"mcp__claude_ai_Atlassian__*",
|
|
99
99
|
];
|
|
100
100
|
|
|
101
|
-
// --- The SDK's two-name trap for built-in tools
|
|
101
|
+
// --- The SDK's two-name trap for built-in tools ---
|
|
102
102
|
//
|
|
103
103
|
// The CLI gives some built-ins TWO spellings, and which one you see depends on
|
|
104
104
|
// which SURFACE the name crosses:
|
|
@@ -118,9 +118,9 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
|
|
|
118
118
|
// This map declares each alias pair ONCE; every delivered-side membership set
|
|
119
119
|
// derives its spellings from it instead of hand-copying names. Both prior
|
|
120
120
|
// instances of the trap were exactly that hand-copy: CHILD_INTERNAL_TOOLS held
|
|
121
|
-
// request-side spellings that no stream name ever matched
|
|
121
|
+
// request-side spellings that no stream name ever matched, and
|
|
122
122
|
// the connectors allowlist hook DENIED the two discovery tools it exists to
|
|
123
|
-
// permit
|
|
123
|
+
// permit. Delivered-side sets accept BOTH spellings, so a CLI
|
|
124
124
|
// version that drops the aliasing cannot reintroduce the bug in either
|
|
125
125
|
// direction.
|
|
126
126
|
const SDK_TOOL_ALIASES: Record<string, string> = {
|
|
@@ -144,14 +144,18 @@ function deliveredSpellings(name: string): string[] {
|
|
|
144
144
|
// REQUEST-side spellings: this list feeds the SDK option surface (the
|
|
145
145
|
// disallowedTools filter in toolIsolationForQuery) and is the exported public
|
|
146
146
|
// name. Delivered-side checks use CONNECTOR_DISCOVERY_TOOL_NAMES below.
|
|
147
|
-
|
|
147
|
+
const MCP_RESOURCE_TOOLS = ["ListMcpResources", "ReadMcpResource"];
|
|
148
|
+
export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", ...MCP_RESOURCE_TOOLS];
|
|
148
149
|
|
|
149
|
-
// Delivered-side membership
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// request-side list directly made the fail-closed allowlist hook DENY the two
|
|
153
|
-
// resource tools it exists to permit (kendex#1011).
|
|
150
|
+
// Delivered-side membership sets carry both spellings, derived from the one
|
|
151
|
+
// alias declaration above. The resource-only set keeps those audit calls
|
|
152
|
+
// dispatchable without making ToolSearch a Pi call.
|
|
154
153
|
const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
|
|
154
|
+
const MCP_RESOURCE_TOOL_NAMES = new Set(MCP_RESOURCE_TOOLS.flatMap(deliveredSpellings));
|
|
155
|
+
|
|
156
|
+
export function isMcpResourceTool(name: string): boolean {
|
|
157
|
+
return MCP_RESOURCE_TOOL_NAMES.has(name);
|
|
158
|
+
}
|
|
155
159
|
|
|
156
160
|
// --- Connector WRITE tool control (read-inline / write-by-approval) ---
|
|
157
161
|
//
|
|
@@ -243,12 +247,12 @@ function connectorNameWords(segment: string): string[] {
|
|
|
243
247
|
// exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
|
|
244
248
|
//
|
|
245
249
|
// PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
|
|
246
|
-
// dependents that gate real user-facing approvals on them
|
|
250
|
+
// dependents that gate real user-facing approvals on them:
|
|
247
251
|
//
|
|
248
252
|
// memsira routes connector writes through its own gated approval flow
|
|
249
253
|
// drovr keeps its chat sidecar permanently write-`deny` and runs an
|
|
250
254
|
// approved write as a separate one-shot `claude -p` scoped by
|
|
251
|
-
// `--allowedTools` to exactly one connector tool
|
|
255
|
+
// `--allowedTools` to exactly one connector tool
|
|
252
256
|
//
|
|
253
257
|
// Both pin the actions they expose against this classification, because "the
|
|
254
258
|
// sidecar structurally cannot do this itself" is THIS module's claim, not
|
|
@@ -329,23 +333,21 @@ export function isConnectorTool(name: string | undefined): boolean {
|
|
|
329
333
|
// `content_block_start` / assistant-message blocks, the exact fields
|
|
330
334
|
// processStreamEvent/processAssistantMessage read. Stream names are a
|
|
331
335
|
// DELIVERED surface (see SDK_TOOL_ALIASES): the two MCP-resource built-ins'
|
|
332
|
-
// request-side spellings
|
|
333
|
-
//
|
|
334
|
-
// via deliveredSpellings rather than hand-copying them.
|
|
336
|
+
// request-side spellings do not belong in this set because they match nothing.
|
|
337
|
+
// Derive every aliased name via deliveredSpellings rather than hand-copying it.
|
|
335
338
|
//
|
|
336
|
-
// The MCP-resource tools are
|
|
339
|
+
// The MCP-resource tools are EXCLUDED under BOTH spellings:
|
|
337
340
|
// a resource read is a real account-surface access, and both consumer hosts
|
|
338
341
|
// audit it through the Pi mirror (an out-of-process sidecar has no view of the
|
|
339
342
|
// bridge's own connector-call entries or the child transcript), so it stays
|
|
340
|
-
// mirrored into Pi. Do not
|
|
341
|
-
//
|
|
342
|
-
// all — see isAllowlistedConnectorSessionTool (kendex#1011).
|
|
343
|
+
// mirrored into Pi. Do not add either spelling to this set. The allowlist hook
|
|
344
|
+
// is what lets those calls run; see isAllowlistedConnectorSessionTool.
|
|
343
345
|
const CHILD_INTERNAL_TOOLS = new Set(["ToolSearch", "ScheduleWakeup"]);
|
|
344
346
|
|
|
345
347
|
/**
|
|
346
348
|
* True for a Claude Code built-in meta-tool the child resolves in-process.
|
|
347
349
|
*
|
|
348
|
-
* Mirroring one into the Pi stream
|
|
350
|
+
* Mirroring one into the Pi stream would make Pi's agent loop dispatch a
|
|
349
351
|
* tool it does not have and deliver an error result for an id no MCP handler
|
|
350
352
|
* ever claimed. The result queued in `pendingResults` until the reaper dropped
|
|
351
353
|
* it — one "dropped 1 tool result(s) whose handler never matched (ToolSearch)"
|
|
@@ -375,13 +377,13 @@ export function isChildInternalTool(name: string | undefined): boolean {
|
|
|
375
377
|
* miss, and write a synthetic `Tool <name> not found` error result into the
|
|
376
378
|
* transcript — while the child went on and executed the real call. The Pi
|
|
377
379
|
* transcript then RECORDED A FAILURE FOR A CALL THAT SUCCEEDED, next to an
|
|
378
|
-
* answer built from the real payload, so the model's correct answer
|
|
379
|
-
*
|
|
380
|
+
* answer built from the real payload, so the model's correct answer appears
|
|
381
|
+
* fabricated. The false result is also projected
|
|
380
382
|
* back into the child's session on a rebuild (`syncSharedSession`), which is
|
|
381
383
|
* how a lie in a mirror becomes a lie in the conversation of record.
|
|
382
384
|
*
|
|
383
385
|
* 2. Claude Code's own in-process meta-tools (`isChildInternalTool`), which the
|
|
384
|
-
* child resolves without any dispatcher at all
|
|
386
|
+
* child resolves without any dispatcher at all.
|
|
385
387
|
*
|
|
386
388
|
* Takes the RAW SDK tool name, before `mapToolName` — child-executed names have
|
|
387
389
|
* no Pi-side counterpart, so mapping them is meaningless. Accepts a missing
|
|
@@ -487,7 +489,7 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
487
489
|
// the tool call proceed (fail OPEN) — so any exception in this body
|
|
488
490
|
// must convert to a deny, never an allow. Today's body is pure string
|
|
489
491
|
// checks on schema-validated input; the catch pins that invariant for
|
|
490
|
-
//
|
|
492
|
+
// subsequent changes to this body.
|
|
491
493
|
try {
|
|
492
494
|
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
493
495
|
// A non-string tool name cannot be classified, and this hook fails
|
|
@@ -507,7 +509,7 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
507
509
|
// stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
|
|
508
510
|
// Naming one host told a different app's model to use a product it has never
|
|
509
511
|
// heard of, which is confusing at exactly the moment someone is debugging a
|
|
510
|
-
// refused write
|
|
512
|
+
// refused write. Each host describes its own approval flow in its
|
|
511
513
|
// own prompt; this string only has to say that one exists.
|
|
512
514
|
function connectorWriteDenyOutput(toolName: string) {
|
|
513
515
|
return {
|
|
@@ -515,6 +517,7 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
515
517
|
hookEventName: "PreToolUse" as const,
|
|
516
518
|
permissionDecision: "deny" as const,
|
|
517
519
|
permissionDecisionReason:
|
|
520
|
+
`connector-write-denied=${JSON.stringify(toolName)}\n` +
|
|
518
521
|
`Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
|
|
519
522
|
`Connector writes must go through the host application's gated approval flow.`,
|
|
520
523
|
},
|
|
@@ -533,8 +536,8 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
533
536
|
//
|
|
534
537
|
// This is a DELIVERED-side check — `name` is a hook's `input.tool_name`, which
|
|
535
538
|
// carries the canonical spelling — so membership is tested against
|
|
536
|
-
// CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list
|
|
537
|
-
//
|
|
539
|
+
// CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list.
|
|
540
|
+
// The allowlist hook also carries the mirroring rule: the
|
|
538
541
|
// MCP-resource tools are deliberately NOT child-internal so every resource
|
|
539
542
|
// read mirrors into Pi as the consumers' audit surface — a mirror that can
|
|
540
543
|
// only exist if this allowlist lets the call execute. Denying the canonical
|
|
@@ -669,14 +672,12 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
|
|
|
669
672
|
/**
|
|
670
673
|
* Explicit `mcpServers` declarations for the account's CONNECTED connectors.
|
|
671
674
|
*
|
|
672
|
-
* Why this exists
|
|
675
|
+
* Why this exists: claude.ai connectors load async and non-blocking,
|
|
673
676
|
* and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
|
|
674
677
|
* CLI has even fetched the connector list. The model therefore composes its first
|
|
675
678
|
* answer against a manifest containing no connectors and says it has no access,
|
|
676
|
-
* while the connector attaches
|
|
677
|
-
*
|
|
678
|
-
* 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
|
|
679
|
-
* went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
|
|
679
|
+
* while the connector attaches later and is never asked. The declaration must
|
|
680
|
+
* be available before the first query. Confirmed at seven
|
|
680
681
|
* declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
|
|
681
682
|
*
|
|
682
683
|
* It is also FASTER, which is the opposite of what the startup barrier suggests.
|
|
@@ -736,9 +737,8 @@ export function connectorMcpServers(inventory: ConnectorInventory): Record<strin
|
|
|
736
737
|
|
|
737
738
|
/**
|
|
738
739
|
* `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
|
|
739
|
-
* connector declarations while leaving connectors themselves enabled.
|
|
740
|
-
*
|
|
741
|
-
* manifest again.
|
|
740
|
+
* connector declarations while leaving connectors themselves enabled. Without
|
|
741
|
+
* declarations, connector loading races the turn-1 manifest.
|
|
742
742
|
*/
|
|
743
743
|
export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
744
744
|
const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
|
package/src/consume-query.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// generator and pushes events into the query's captured Pi stream.
|
|
4
4
|
|
|
5
5
|
import { type Model } from "@earendil-works/pi-ai";
|
|
6
|
-
import { type query } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import { type AccountInfo, type query } from "@anthropic-ai/claude-agent-sdk";
|
|
7
7
|
import {
|
|
8
8
|
classifyClaudeFailure,
|
|
9
9
|
rateLimitResetFromInfo,
|
|
@@ -88,6 +88,7 @@ export async function consumeQuery(
|
|
|
88
88
|
model: Model<any>,
|
|
89
89
|
bridgeConfig: Config,
|
|
90
90
|
wasAborted: () => boolean,
|
|
91
|
+
recordBillingIdentity: (info: AccountInfo) => void,
|
|
91
92
|
account?: ClaudeAccountRoute,
|
|
92
93
|
router?: ClaudeAccountRouterV1,
|
|
93
94
|
// Mirror of the held failure for the caller's .catch: the SDK iterator can
|
|
@@ -100,6 +101,7 @@ export async function consumeQuery(
|
|
|
100
101
|
let capturedSessionId: string | undefined;
|
|
101
102
|
let failure: ClaudeAttemptFailure | undefined;
|
|
102
103
|
let accountProbe: Promise<void> | undefined;
|
|
104
|
+
let accountInfoProbe: Promise<AccountInfo> | undefined;
|
|
103
105
|
const holdFailure = (next: ClaudeAttemptFailure | undefined): void => {
|
|
104
106
|
failure = next;
|
|
105
107
|
if (attemptFailureBox) attemptFailureBox.failure = next;
|
|
@@ -111,7 +113,7 @@ export async function consumeQuery(
|
|
|
111
113
|
if (account) {
|
|
112
114
|
// Thunk, not a value: this runs once per SDK message — including one
|
|
113
115
|
// stream_event per streamed token — and debug() only evaluates function
|
|
114
|
-
// args after its DEBUG early return
|
|
116
|
+
// args after its DEBUG early return.
|
|
115
117
|
debug("consumeQuery: managed message", () => JSON.stringify({
|
|
116
118
|
type: message.type,
|
|
117
119
|
subtype: (message as any).subtype,
|
|
@@ -203,8 +205,8 @@ export async function consumeQuery(
|
|
|
203
205
|
}
|
|
204
206
|
// Other non-success subtypes (error_max_turns,
|
|
205
207
|
// error_during_execution) surface at completion via the held
|
|
206
|
-
// failure
|
|
207
|
-
//
|
|
208
|
+
// failure. These turns require an explicit error event instead of
|
|
209
|
+
// silent completion. Session persistence and deferred replay still run.
|
|
208
210
|
}
|
|
209
211
|
break;
|
|
210
212
|
case "system":
|
|
@@ -216,9 +218,22 @@ export async function consumeQuery(
|
|
|
216
218
|
// from the teardown flush, which runs outside this function's scope.
|
|
217
219
|
queryCtx.childSessionId = capturedSessionId;
|
|
218
220
|
noteFastModeDisabledReason(message, bridgeConfig);
|
|
221
|
+
// Which login this child authenticated as is published for other
|
|
222
|
+
// extensions, for every child rather than only a routed one:
|
|
223
|
+
// an unrouted child is the common case and its identity is
|
|
224
|
+
// just as unknowable from outside the SDK. Nothing waits for
|
|
225
|
+
// it, so it stays off the turn's critical path. The call sits
|
|
226
|
+
// in an async IIFE so a synchronous throw arrives as a
|
|
227
|
+
// rejection the debug line below names.
|
|
228
|
+
if (!accountInfoProbe) {
|
|
229
|
+
accountInfoProbe = (async () => sdkQuery.accountInfo())();
|
|
230
|
+
void accountInfoProbe
|
|
231
|
+
.then((info) => recordBillingIdentity(info))
|
|
232
|
+
.catch((error) => debug("consumeQuery: billing identity probe rejected:", error));
|
|
233
|
+
}
|
|
219
234
|
if (account && router && !accountProbe) {
|
|
220
235
|
accountProbe = Promise.allSettled([
|
|
221
|
-
|
|
236
|
+
accountInfoProbe.then((info) => router.recordIdentity(account.profileId, {
|
|
222
237
|
email: info.email,
|
|
223
238
|
organization: info.organization,
|
|
224
239
|
subscriptionType: info.subscriptionType,
|
package/src/convert.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<strin
|
|
|
28
28
|
// `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
|
|
29
29
|
// projected history, so the model imitated it on the next turn and got a
|
|
30
30
|
// real `Tool ... not found` from the MCP dispatcher before retrying the
|
|
31
|
-
// canonical name — one wasted round-trip per affected call
|
|
31
|
+
// canonical name — one wasted round-trip per affected call.
|
|
32
32
|
//
|
|
33
33
|
// Connector names stopped reaching this function at all once they stopped
|
|
34
34
|
// being mirrored as Pi tool calls (isChildExecutedTool), so this is the
|
package/src/debug.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function debug(...args: unknown[]) {
|
|
|
50
50
|
if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
51
51
|
// A function argument is a lazy payload: hot-path call sites (per-token
|
|
52
52
|
// stream events) pass a thunk so the expensive formatting only runs when
|
|
53
|
-
// DEBUG is on — fmt is only reached past the early return
|
|
53
|
+
// DEBUG is on — fmt is only reached past the early return.
|
|
54
54
|
if (typeof a === "function") return fmt((a as () => unknown)());
|
|
55
55
|
return JSON.stringify(a);
|
|
56
56
|
};
|
|
@@ -121,7 +121,7 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
|
|
|
121
121
|
/** Diagnostic dump — for "should never happen" paths. Gated on the same
|
|
122
122
|
* CLAUDE_BRIDGE_DEBUG flag as debug(): the entries carry session metadata
|
|
123
123
|
* and land in a log outside any host app's retention/cleanup boundary, so
|
|
124
|
-
* a host that has not opted into debugging must get no disk write
|
|
124
|
+
* a host that has not opted into debugging must get no disk write. */
|
|
125
125
|
export function diagDump(label: string, data: Record<string, unknown>) {
|
|
126
126
|
if (!DEBUG) return;
|
|
127
127
|
try {
|