@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.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 +43 -125
- package/bundle/connector-inventory.js +16 -3
- package/bundle/index.js +3743 -1810
- package/package.json +14 -23
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +472 -66
- package/src/auth-presence.ts +6 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +200 -15
- package/src/config.ts +170 -20
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +148 -0
- package/src/connector-inventory.ts +66 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +406 -19
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +20 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +901 -676
- package/src/models.ts +0 -7
- package/src/native-provider.ts +94 -0
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +490 -25
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +48 -13
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +370 -50
- package/src/tool-pairing-audit.ts +117 -0
- package/src/typebox-to-zod.ts +9 -3
package/src/config.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
import type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
8
8
|
import { existsSync, readFileSync } from "fs";
|
|
9
9
|
import { homedir } from "os";
|
|
10
|
-
import { dirname, join, resolve } from "path";
|
|
10
|
+
import { dirname, join, resolve, sep } from "path";
|
|
11
|
+
import { debug } from "./debug.js";
|
|
11
12
|
|
|
12
13
|
export const PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Registry the vstack extension manager reads to display the value an
|
|
17
|
+
* extension actually resolves for a manifest key, for extensions whose config
|
|
18
|
+
* has channels the manager does not own. Keyed by package id.
|
|
19
|
+
*/
|
|
20
|
+
const EXTERNAL_CONFIG_RESOLVER_SYMBOL = Symbol.for("vstack.pi.extension-config-resolver");
|
|
21
|
+
|
|
14
22
|
export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
|
15
23
|
|
|
16
24
|
const VALID_EFFORT_LEVELS = new Set<BridgeEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
|
|
@@ -28,13 +36,20 @@ export interface Config {
|
|
|
28
36
|
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
29
37
|
provider?: {
|
|
30
38
|
appendSystemPrompt?: boolean;
|
|
31
|
-
allowExtraUsage?: boolean;
|
|
32
39
|
/** Enable Claude Code fast mode for bridge requests. */
|
|
33
40
|
fastMode?: boolean;
|
|
34
41
|
/** Force this Claude Code effort level for every bridge request. */
|
|
35
42
|
forceEffort?: BridgeEffortLevel;
|
|
36
43
|
/** Per-model Claude Code effort overrides keyed by model id (e.g. claude-opus-4-8). */
|
|
37
44
|
modelEffortOverrides?: Record<string, BridgeEffortLevel>;
|
|
45
|
+
/**
|
|
46
|
+
* Verbatim override for the child's filesystem setting sources.
|
|
47
|
+
* Defaults (see settingSourcesForQuery in connectors.ts): connectors
|
|
48
|
+
* mode uses ["user"] only, so repo-controlled `.claude/settings.json`
|
|
49
|
+
* (project/local scope) cannot inject `env`/`apiKeyHelper` into the
|
|
50
|
+
* child. Listing "project"/"local" here reopens that surface — only do
|
|
51
|
+
* so for checkouts you trust.
|
|
52
|
+
*/
|
|
38
53
|
settingSources?: SettingSource[];
|
|
39
54
|
strictMcpConfig?: boolean;
|
|
40
55
|
pathToClaudeCodeExecutable?: string;
|
|
@@ -43,7 +58,9 @@ export interface Config {
|
|
|
43
58
|
* connectors (Gmail / Google Calendar / Google Drive, etc.) to the model.
|
|
44
59
|
* Off by default so Pi owns tool execution and tokens stay lean. Also
|
|
45
60
|
* settable via the CLAUDE_BRIDGE_ENABLE_CONNECTORS env var (env OR config
|
|
46
|
-
* enables it).
|
|
61
|
+
* enables it). Resolved from USER-scope config and env only — a project's
|
|
62
|
+
* checked-in settings cannot enable it (see USER_SCOPE_ONLY_PROVIDER_KEYS).
|
|
63
|
+
* See the Connectors section of this package's README.
|
|
47
64
|
*/
|
|
48
65
|
enableConnectors?: boolean;
|
|
49
66
|
/**
|
|
@@ -56,7 +73,8 @@ export interface Config {
|
|
|
56
73
|
* one-shot approved-write executor process. Also settable via
|
|
57
74
|
* CLAUDE_BRIDGE_CONNECTOR_WRITE=deny|allow (env wins over config). Any
|
|
58
75
|
* value but exact `allow` is treated as `deny`. Ignored when connectors
|
|
59
|
-
* are disabled.
|
|
76
|
+
* are disabled. Like enableConnectors, resolved from USER-scope config
|
|
77
|
+
* and env only (see USER_SCOPE_ONLY_PROVIDER_KEYS).
|
|
60
78
|
*/
|
|
61
79
|
connectorWriteMode?: ConnectorWriteMode;
|
|
62
80
|
};
|
|
@@ -178,29 +196,57 @@ export function tryParseJson(path: string): Partial<Config> {
|
|
|
178
196
|
if (!existsSync(path)) return {};
|
|
179
197
|
try {
|
|
180
198
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
181
|
-
} catch {
|
|
199
|
+
} catch (error) {
|
|
182
200
|
// Malformed optional config should not write raw terminal diagnostics;
|
|
183
|
-
// stdout/stderr output can corrupt active Pi TUI widgets.
|
|
201
|
+
// stdout/stderr output can corrupt active Pi TUI widgets. The debug log is
|
|
202
|
+
// the one place a silently-ignored file explains itself.
|
|
203
|
+
debug(`config: ignoring malformed ${path}:`, error instanceof Error ? error.message : String(error));
|
|
184
204
|
return {};
|
|
185
205
|
}
|
|
186
206
|
}
|
|
187
207
|
|
|
188
208
|
function readManagerConfig(cwd: string): SettingsRecord {
|
|
189
209
|
const merged: SettingsRecord = {};
|
|
210
|
+
const userPath = join(piUserDir(), "settings.json");
|
|
190
211
|
for (const path of settingsPaths(cwd)) {
|
|
191
212
|
if (!existsSync(path)) continue;
|
|
192
213
|
try {
|
|
193
214
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
194
215
|
const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
|
|
195
216
|
const config = asRecord(configRoot?.[PACKAGE_ID]);
|
|
196
|
-
if (config) mergeDeep(merged, config);
|
|
197
|
-
} catch {
|
|
198
|
-
// Ignore malformed optional manager config; Pi will surface settings
|
|
217
|
+
if (config) mergeDeep(merged, path === userPath ? config : withoutUserScopeOnlyKeys(config));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// Ignore malformed optional manager config; Pi will surface settings
|
|
220
|
+
// issues elsewhere. Still say so in the debug log.
|
|
221
|
+
debug(`config: ignoring malformed manager config ${path}:`, error instanceof Error ? error.message : String(error));
|
|
199
222
|
}
|
|
200
223
|
}
|
|
201
224
|
return merged;
|
|
202
225
|
}
|
|
203
226
|
|
|
227
|
+
// Connector enablement and write mode decide whether the child claude gains
|
|
228
|
+
// access to the account's live connectors (mail, calendar, files) and whether
|
|
229
|
+
// their WRITE tools are exposed. A repo-controlled channel (a checkout's
|
|
230
|
+
// `.pi/settings.json` or `.pi/claude-bridge.json`, even when the project is
|
|
231
|
+
// trusted for ordinary options) must not be able to flip them: these two keys
|
|
232
|
+
// resolve from USER scope and the env vars only, mirroring the
|
|
233
|
+
// settingSourcesForQuery rationale — whoever writes user scope already owns
|
|
234
|
+
// the process.
|
|
235
|
+
const USER_SCOPE_ONLY_PROVIDER_KEYS = ["enableConnectors", "connectorWriteMode"] as const;
|
|
236
|
+
|
|
237
|
+
function withoutUserScopeOnlyKeys(raw: SettingsRecord): SettingsRecord {
|
|
238
|
+
const out = { ...raw };
|
|
239
|
+
for (const key of USER_SCOPE_ONLY_PROVIDER_KEYS) delete out[key];
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stripUserScopeOnlyProviderKeys(config: Partial<Config>): Partial<Config> {
|
|
244
|
+
if (!config.provider) return config;
|
|
245
|
+
const provider = { ...config.provider };
|
|
246
|
+
for (const key of USER_SCOPE_ONLY_PROVIDER_KEYS) delete provider[key];
|
|
247
|
+
return { ...config, provider };
|
|
248
|
+
}
|
|
249
|
+
|
|
204
250
|
function boolFrom(raw: SettingsRecord, key: string): boolean | undefined {
|
|
205
251
|
return typeof raw[key] === "boolean" ? raw[key] as boolean : undefined;
|
|
206
252
|
}
|
|
@@ -277,8 +323,6 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
|
|
|
277
323
|
|
|
278
324
|
const appendSystemPrompt = boolFrom(raw, "appendSystemPrompt");
|
|
279
325
|
if (appendSystemPrompt !== undefined) provider.appendSystemPrompt = appendSystemPrompt;
|
|
280
|
-
const allowExtraUsage = boolFrom(raw, "allowExtraUsage");
|
|
281
|
-
if (allowExtraUsage !== undefined) provider.allowExtraUsage = allowExtraUsage;
|
|
282
326
|
const fastMode = boolFrom(raw, "fastMode");
|
|
283
327
|
if (fastMode !== undefined) provider.fastMode = fastMode;
|
|
284
328
|
if (hasOwn(raw, "forceEffort")) {
|
|
@@ -312,17 +356,123 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
|
|
|
312
356
|
};
|
|
313
357
|
}
|
|
314
358
|
|
|
359
|
+
/**
|
|
360
|
+
* A `claude-bridge.json` file. Legacy files nest provider options under
|
|
361
|
+
* `provider` and prompt-context flags under `promptContext`; the manifest's flat
|
|
362
|
+
* key shape is accepted too, so a file written either way resolves the same.
|
|
363
|
+
* Nested wins when a file carries both shapes for one key.
|
|
364
|
+
*/
|
|
365
|
+
function legacyFileConfig(path: string): Partial<Config> {
|
|
366
|
+
const raw = asRecord(tryParseJson(path)) ?? {};
|
|
367
|
+
const flat = managerToConfig(raw);
|
|
368
|
+
const provider = { ...flat.provider, ...asRecord(raw.provider) } as Config["provider"];
|
|
369
|
+
const promptContext = { ...flat.promptContext, ...asRecord(raw.promptContext) } as Config["promptContext"];
|
|
370
|
+
return {
|
|
371
|
+
...(flat.enabled !== undefined ? { enabled: flat.enabled } : {}),
|
|
372
|
+
...(Object.keys(provider ?? {}).length ? { provider } : {}),
|
|
373
|
+
...(Object.keys(promptContext ?? {}).length ? { promptContext } : {}),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface LegacyLayer {
|
|
378
|
+
path: string;
|
|
379
|
+
config: Partial<Config>;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Legacy config layers, lowest precedence first. */
|
|
383
|
+
function legacyLayers(cwd: string): LegacyLayer[] {
|
|
384
|
+
const globalPath = join(piUserDir(), "claude-bridge.json");
|
|
385
|
+
const layers: LegacyLayer[] = [{ path: globalPath, config: legacyFileConfig(globalPath) }];
|
|
386
|
+
if (isolatedFromEnv()) return layers;
|
|
387
|
+
const projectSettings = projectSettingsPath(cwd);
|
|
388
|
+
if (!projectSettingsTrusted(projectSettings)) return layers;
|
|
389
|
+
const projectPath = join(dirname(projectSettings), "claude-bridge.json");
|
|
390
|
+
// Project trust covers ordinary options only; the connector keys stay
|
|
391
|
+
// user-scope/env (see USER_SCOPE_ONLY_PROVIDER_KEYS).
|
|
392
|
+
return [...layers, { path: projectPath, config: stripUserScopeOnlyProviderKeys(legacyFileConfig(projectPath)) }];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function mergeLayers(layers: LegacyLayer[]): Partial<Config> {
|
|
396
|
+
const merged: Partial<Config> = { provider: {}, promptContext: {} };
|
|
397
|
+
for (const layer of layers) {
|
|
398
|
+
if (layer.config.enabled !== undefined) merged.enabled = layer.config.enabled;
|
|
399
|
+
merged.provider = { ...merged.provider, ...layer.config.provider };
|
|
400
|
+
merged.promptContext = { ...merged.promptContext, ...layer.config.promptContext };
|
|
401
|
+
}
|
|
402
|
+
return merged;
|
|
403
|
+
}
|
|
404
|
+
|
|
315
405
|
export function loadConfig(cwd: string): Config {
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
const trustedProject = projectSettings !== undefined && projectSettingsTrusted(projectSettings);
|
|
320
|
-
const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
|
|
321
|
-
const manager: Partial<Config> = isolated ? {} : managerToConfig(readManagerConfig(cwd));
|
|
322
|
-
const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
|
|
406
|
+
const legacy = mergeLayers(legacyLayers(cwd));
|
|
407
|
+
const manager: Partial<Config> = isolatedFromEnv() ? {} : managerToConfig(readManagerConfig(cwd));
|
|
408
|
+
const provider = normalizeProviderConfig({ ...legacy.provider, ...manager.provider });
|
|
323
409
|
return {
|
|
324
|
-
enabled: manager.enabled ??
|
|
410
|
+
enabled: manager.enabled ?? legacy.enabled ?? true,
|
|
325
411
|
provider,
|
|
326
|
-
promptContext: { ...
|
|
412
|
+
promptContext: { ...legacy.promptContext, ...manager.promptContext },
|
|
327
413
|
};
|
|
328
414
|
}
|
|
415
|
+
|
|
416
|
+
const PROVIDER_KEYS = new Set([
|
|
417
|
+
"appendSystemPrompt",
|
|
418
|
+
"connectorWriteMode",
|
|
419
|
+
"enableConnectors",
|
|
420
|
+
"fastMode",
|
|
421
|
+
"forceEffort",
|
|
422
|
+
"modelEffortOverrides",
|
|
423
|
+
"pathToClaudeCodeExecutable",
|
|
424
|
+
"strictMcpConfig",
|
|
425
|
+
]);
|
|
426
|
+
|
|
427
|
+
const PROMPT_CONTEXT_KEYS = new Set([
|
|
428
|
+
"includeAppendSystemPromptMd",
|
|
429
|
+
"includeCavemanHook",
|
|
430
|
+
"includeProjectAgentsHook",
|
|
431
|
+
"includeTaskPanelHook",
|
|
432
|
+
]);
|
|
433
|
+
|
|
434
|
+
function configValueForKey(config: Partial<Config>, key: string): unknown {
|
|
435
|
+
if (key === "enabled") return config.enabled;
|
|
436
|
+
if (PROVIDER_KEYS.has(key)) return normalizeProviderConfig(config.provider)?.[key as keyof NonNullable<Config["provider"]>];
|
|
437
|
+
if (PROMPT_CONTEXT_KEYS.has(key)) return config.promptContext?.[key as keyof NonNullable<Config["promptContext"]>];
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Home-relative when possible — for user-facing path mentions (what to edit,
|
|
442
|
+
* what to paste into an issue) where an absolute path would leak the username. */
|
|
443
|
+
export function displayPath(path: string): string {
|
|
444
|
+
const home = homedir();
|
|
445
|
+
return home && path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface ExternalConfigResolution {
|
|
449
|
+
explicit: boolean;
|
|
450
|
+
value: unknown;
|
|
451
|
+
source?: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The effective value of a manifest key from the config channels the extension
|
|
456
|
+
* manager does not own — the legacy `claude-bridge.json` files. Manager config
|
|
457
|
+
* outranks these, so the manager consults this only when neither of its own
|
|
458
|
+
* scopes holds the key.
|
|
459
|
+
*/
|
|
460
|
+
export function resolveExternalConfigValue(key: string, cwd: string): ExternalConfigResolution {
|
|
461
|
+
const layers = legacyLayers(cwd);
|
|
462
|
+
const value = configValueForKey(mergeLayers(layers), key);
|
|
463
|
+
if (value === undefined) return { explicit: false, value: undefined };
|
|
464
|
+
const source = [...layers].reverse().find((layer) => configValueForKey(layer.config, key) !== undefined)?.path;
|
|
465
|
+
return { explicit: true, value, ...(source ? { source: displayPath(source) } : {}) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Publish the resolver before any early return, so a bridge disabled by a
|
|
470
|
+
* legacy file still explains itself in the settings editor.
|
|
471
|
+
*/
|
|
472
|
+
export function registerExternalConfigResolver(): void {
|
|
473
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
474
|
+
const existing = asRecord(host[EXTERNAL_CONFIG_RESOLVER_SYMBOL]);
|
|
475
|
+
const registry = existing ?? {};
|
|
476
|
+
if (!existing) host[EXTERNAL_CONFIG_RESOLVER_SYMBOL] = registry;
|
|
477
|
+
registry[PACKAGE_ID] = (key: string, cwd: string) => resolveExternalConfigValue(key, cwd);
|
|
478
|
+
}
|
|
@@ -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,148 @@
|
|
|
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}` where `scope` is the FULL
|
|
26
|
+
// sha256 hex of the scope key (version 2; version 1 stored the raw
|
|
27
|
+
// CLAUDE_CONFIG_DIR path, which is account-identifying and does not belong in
|
|
28
|
+
// a state file), 7-day max age — as the "is this connector installed" half of
|
|
29
|
+
// its write gate.
|
|
30
|
+
//
|
|
31
|
+
// That coupling fails OPEN on drift by design, so a format change degrades them
|
|
32
|
+
// from two gates to one rather than breaking them. It is still worth making the
|
|
33
|
+
// change knowingly: bump CACHE_VERSION so their staleness check rejects rather
|
|
34
|
+
// than misreads, and say so in the changelog. `unit-connector-cache.mjs` pins
|
|
35
|
+
// the path shape and payload keys.
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
import { piUserDir } from "./config.js";
|
|
40
|
+
import { debug } from "./debug.js";
|
|
41
|
+
import type { ConnectorEntry } from "./connector-inventory.js";
|
|
42
|
+
|
|
43
|
+
const CACHE_VERSION = 2;
|
|
44
|
+
/** Long enough to be useful across a machine's lifetime, short enough that a
|
|
45
|
+
* removed connector stops being declared without needing a manual purge. A
|
|
46
|
+
* stale entry is not dangerous — a connector that no longer resolves simply
|
|
47
|
+
* fails to connect, which is the fail-open path — so this is hygiene, not a
|
|
48
|
+
* correctness boundary. */
|
|
49
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* THE canonical credential-scope key: a trimmed CLAUDE_CONFIG_DIR, or the
|
|
53
|
+
* `"<default>"` sentinel for the default account. The on-disk cache format has
|
|
54
|
+
* an external reader (see the module header), and the in-memory prime cache in
|
|
55
|
+
* connector-runtime.ts keys the same scopes — both MUST agree on this rule, so
|
|
56
|
+
* there is exactly one implementation.
|
|
57
|
+
*/
|
|
58
|
+
export function scopeKeyFor(claudeConfigDir?: string): string {
|
|
59
|
+
return claudeConfigDir?.trim() || "<default>";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function connectorCacheScopeKey(env: NodeJS.ProcessEnv = process.env): string {
|
|
63
|
+
return scopeKeyFor(env.CLAUDE_CONFIG_DIR);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Full sha256 hex of a scope key. The filename keeps only the first 16 chars;
|
|
67
|
+
// the payload stores the whole digest so a truncated-hash collision (or a
|
|
68
|
+
// hand-copied file) is still caught by the scope check in readCachedConnectors.
|
|
69
|
+
function connectorCacheScopeDigest(scopeKey: string): string {
|
|
70
|
+
return createHash("sha256").update(scopeKey).digest("hex");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Our own state directory, not the Claude config dir. The credential directory
|
|
75
|
+
* belongs to the CLI; the scope is encoded in the filename instead so we never
|
|
76
|
+
* write into someone else's tree. Hashed rather than escaped because a config
|
|
77
|
+
* dir is an arbitrary absolute path.
|
|
78
|
+
*/
|
|
79
|
+
export function connectorCachePath(scopeKey: string = connectorCacheScopeKey()): string {
|
|
80
|
+
const digest = connectorCacheScopeDigest(scopeKey).slice(0, 16);
|
|
81
|
+
return join(piUserDir(), "connector-cache", `${digest}.json`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Synchronous by design. The query path has no await boundary to hang a read on
|
|
86
|
+
* — `streamClaudeAgentSdk` returns its stream and claims the SDK query handle in
|
|
87
|
+
* the same tick — which is the whole reason the in-memory prime loses the race.
|
|
88
|
+
* A single small `readFileSync` is what makes turn 1 reachable at all.
|
|
89
|
+
*/
|
|
90
|
+
export function readCachedConnectors(
|
|
91
|
+
scopeKey: string = connectorCacheScopeKey(),
|
|
92
|
+
now: number = Date.now(),
|
|
93
|
+
): ConnectorEntry[] | undefined {
|
|
94
|
+
let raw: string;
|
|
95
|
+
try {
|
|
96
|
+
raw = readFileSync(connectorCachePath(scopeKey), "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
let parsed: any;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(raw);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// A corrupt cache degrades to the no-cache path by contract, but silently
|
|
105
|
+
// doing so on every turn is how a bad file hides forever.
|
|
106
|
+
debug(`connector-cache: corrupt cache ${connectorCachePath(scopeKey)}:`, error instanceof Error ? error.message : String(error));
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
if (parsed?.version !== CACHE_VERSION) return undefined;
|
|
110
|
+
// The scope digest is stored as well as hashed into the path: a truncated-
|
|
111
|
+
// hash collision or a hand-copied file would otherwise hand one account
|
|
112
|
+
// another's connectors, which is the exact failure the token-scoping note in
|
|
113
|
+
// connector-inventory.ts warns about. The payload carries the digest, never
|
|
114
|
+
// the raw scope key — a config-dir path is account-identifying and the
|
|
115
|
+
// filename is already hashed for the same reason.
|
|
116
|
+
if (parsed?.scope !== connectorCacheScopeDigest(scopeKey)) return undefined;
|
|
117
|
+
const savedAt = typeof parsed?.savedAt === "number" ? parsed.savedAt : 0;
|
|
118
|
+
if (!savedAt || now - savedAt > MAX_AGE_MS || savedAt > now) return undefined;
|
|
119
|
+
if (!Array.isArray(parsed?.connectors)) return undefined;
|
|
120
|
+
const connectors = parsed.connectors.filter(
|
|
121
|
+
(entry: any) => entry && typeof entry.name === "string" && entry.name.trim(),
|
|
122
|
+
);
|
|
123
|
+
return connectors.length > 0 ? (connectors as ConnectorEntry[]) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Best-effort write; a failure here must never affect the turn. */
|
|
127
|
+
export function writeCachedConnectors(
|
|
128
|
+
connectors: ConnectorEntry[],
|
|
129
|
+
scopeKey: string = connectorCacheScopeKey(),
|
|
130
|
+
now: number = Date.now(),
|
|
131
|
+
): boolean {
|
|
132
|
+
if (!Array.isArray(connectors) || connectors.length === 0) return false;
|
|
133
|
+
const path = connectorCachePath(scopeKey);
|
|
134
|
+
try {
|
|
135
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
136
|
+
writeFileSync(
|
|
137
|
+
path,
|
|
138
|
+
JSON.stringify({ version: CACHE_VERSION, scope: connectorCacheScopeDigest(scopeKey), savedAt: now, connectors }),
|
|
139
|
+
{ mode: 0o600 },
|
|
140
|
+
);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// Best-effort by contract, but a persistent write failure means every cold
|
|
144
|
+
// process re-loses the turn-1 race this cache exists to win — say so.
|
|
145
|
+
debug(`connector-cache: write failed ${path}:`, error instanceof Error ? error.message : String(error));
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|