@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/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). See docs/plans/claude-bridge-google-connectors.md.
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 issues elsewhere.
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 global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
317
- const isolated = isolatedFromEnv();
318
- const projectSettings = isolated ? undefined : projectSettingsPath(cwd);
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 ?? project.enabled ?? global.enabled ?? true,
410
+ enabled: manager.enabled ?? legacy.enabled ?? true,
325
411
  provider,
326
- promptContext: { ...global.promptContext, ...project.promptContext, ...manager.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
+ }