@vanillagreen/pi-claude-bridge 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.6.2",
3
+ "version": "1.8.0",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -142,8 +142,8 @@
142
142
  }
143
143
  },
144
144
  "dependencies": {
145
- "@anthropic-ai/claude-agent-sdk": "0.3.158",
146
- "@anthropic-ai/sdk": "0.93.0",
145
+ "@anthropic-ai/claude-agent-sdk": "^0.3.215",
146
+ "@anthropic-ai/sdk": "^0.112.4",
147
147
  "cc-session-io": "^0.3.1",
148
148
  "change-case": "^5.4.4"
149
149
  },
@@ -152,8 +152,8 @@
152
152
  "@earendil-works/pi-coding-agent": "*"
153
153
  },
154
154
  "devDependencies": {
155
- "@earendil-works/pi-ai": "^0.75.0",
156
- "@earendil-works/pi-coding-agent": "^0.75.0",
155
+ "@earendil-works/pi-ai": "^0.80.10",
156
+ "@earendil-works/pi-coding-agent": "^0.80.10",
157
157
  "@types/node": "^24.3.0",
158
158
  "esbuild": "^0.28.0",
159
159
  "tsx": "^4.21.0",
package/src/agents-md.ts CHANGED
@@ -2,20 +2,30 @@
2
2
  //
3
3
  // Pi uses AGENTS.md for long-lived instructions; Claude Code reads the same
4
4
  // content under "# CLAUDE.md". We walk up from cwd looking for AGENTS.md,
5
- // fall back to ~/.pi/agent/AGENTS.md, and rewrite pi-specific references
5
+ // fall back to <piUserDir>/AGENTS.md (~/.pi/agent/AGENTS.md unless
6
+ // PI_CODING_AGENT_DIR points elsewhere), and rewrite pi-specific references
6
7
  // (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
7
8
  // references in the file still resolve inside the CC subprocess.
9
+ //
10
+ // In isolated mode (CLAUDE_BRIDGE_ISOLATED=1) the cwd walk is disabled: only
11
+ // the piUserDir file is consulted, so a host app that owns the agent dir owns
12
+ // the full instruction surface.
8
13
 
9
14
  import { existsSync, readFileSync } from "fs";
10
- import { homedir } from "os";
11
15
  import { dirname, join, resolve } from "path";
16
+ import { isolatedFromEnv, piUserDir } from "./config.js";
12
17
 
13
- const GLOBAL_AGENTS_PATH = join(homedir(), ".pi", "agent", "AGENTS.md");
18
+ function globalAgentsPath(): string {
19
+ return join(piUserDir(), "AGENTS.md");
20
+ }
14
21
 
15
22
  export function resolveAgentsMdPath(): string | undefined {
16
- const fromCwd = findAgentsMdInParents(process.cwd());
17
- if (fromCwd) return fromCwd;
18
- if (existsSync(GLOBAL_AGENTS_PATH)) return GLOBAL_AGENTS_PATH;
23
+ if (!isolatedFromEnv()) {
24
+ const fromCwd = findAgentsMdInParents(process.cwd());
25
+ if (fromCwd) return fromCwd;
26
+ }
27
+ const globalPath = globalAgentsPath();
28
+ if (existsSync(globalPath)) return globalPath;
19
29
  return undefined;
20
30
  }
21
31
 
@@ -0,0 +1,158 @@
1
+ // --- Claude credential presence (availability honesty) ---
2
+ //
3
+ // The bridge may only advertise claude-bridge models when the machine actually
4
+ // has Claude credentials the Claude Agent SDK can authenticate with. Otherwise
5
+ // pi's ModelRegistry.hasConfiguredAuth() would treat the dummy `apiKey:
6
+ // "not-used"` as "configured" and the provider would look connected while every
7
+ // request fails at spawn time.
8
+ //
9
+ // This module answers two pure questions used to gate registration:
10
+ // 1. hasClaudeCredentials() — are real credentials present RIGHT NOW?
11
+ // 2. decideRegistration() — given credential presence + the primary-instance
12
+ // / stream-guard tokens, should we register / unregister / do nothing?
13
+ //
14
+ // SECURITY: this module only ever checks for the EXISTENCE of credentials — a
15
+ // file's presence, an env var being non-empty, a settings key being a non-empty
16
+ // string. It NEVER opens or logs `.credentials.json`, and where it must parse
17
+ // `settings.json` (for apiKeyHelper) it reads only whether the key is a
18
+ // non-empty string and never logs its value. Credential CONTENTS are never read
19
+ // or logged.
20
+
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { homedir, platform as osPlatform } from "os";
23
+ import { join } from "path";
24
+
25
+ /**
26
+ * Resolve the Claude config directory the same way the bundled cc-session-io
27
+ * (getClaudeDir) and Claude Code itself resolve it: an explicit
28
+ * CLAUDE_CONFIG_DIR wins, otherwise ~/.claude.
29
+ *
30
+ * Deliberate divergence from cc-session-io's plain `env ?? default`: we treat a
31
+ * SET-BUT-EMPTY/whitespace CLAUDE_CONFIG_DIR as unset and fall back to ~/.claude
32
+ * (an empty string would otherwise resolve credential probes to the process cwd
33
+ * root). The returned value is trimmed so downstream joins never carry stray
34
+ * whitespace.
35
+ */
36
+ export function resolveClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
37
+ const configured = env.CLAUDE_CONFIG_DIR;
38
+ if (typeof configured === "string" && configured.trim().length > 0) return configured.trim();
39
+ return join(homedir(), ".claude");
40
+ }
41
+
42
+ function nonEmptyEnv(value: string | undefined): boolean {
43
+ return typeof value === "string" && value.trim().length > 0;
44
+ }
45
+
46
+ // Matches how Claude Code interprets its boolean provider-routing env flags:
47
+ // only "1" / "true" (case-insensitive) enable them.
48
+ function envTruthy(value: string | undefined): boolean {
49
+ const v = value?.trim().toLowerCase();
50
+ return v === "1" || v === "true";
51
+ }
52
+
53
+ /**
54
+ * True when `${configDir}/settings.json` exists, parses as JSON, and carries a
55
+ * non-empty `apiKeyHelper` string (an enterprise/custom auth command that
56
+ * produces a key). Parse/read errors are tolerated as "not present". The helper
57
+ * VALUE is never logged — only its presence/non-emptiness is used.
58
+ */
59
+ function hasApiKeyHelper(configDir: string): boolean {
60
+ try {
61
+ const settingsPath = join(configDir, "settings.json");
62
+ if (!existsSync(settingsPath)) return false;
63
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { apiKeyHelper?: unknown };
64
+ return typeof parsed?.apiKeyHelper === "string" && parsed.apiKeyHelper.trim().length > 0;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * True when real Claude credentials are present in any location the Claude Agent
72
+ * SDK would authenticate from, WITHOUT reading credential contents. Checked in
73
+ * cheap-first order:
74
+ * - env: non-empty CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY /
75
+ * ANTHROPIC_AUTH_TOKEN, or any truthy cloud-provider routing flag the Claude
76
+ * Code SDK recognizes — CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY /
77
+ * _ANTHROPIC_AWS / _MANTLE (such routing needs no local key file);
78
+ * - `.credentials.json` in the resolved config dir (existence only — the file
79
+ * is never opened; written mode 0600 by `claude login`);
80
+ * - `settings.json` apiKeyHelper (presence only — see hasApiKeyHelper).
81
+ *
82
+ * PLATFORM ASYMMETRY: on macOS the `claude` CLI stores OAuth tokens in the login
83
+ * Keychain, NOT in `.credentials.json`, so file-absence is NOT evidence of
84
+ * logged-out and we cannot cheaply/safely probe the Keychain here. On darwin,
85
+ * when no other signal is present, we default to credentialed=true — preserving
86
+ * the pre-fix "always available" behavior for Mac subscription users. Honesty
87
+ * enforcement therefore applies on Linux/Windows, where `.credentials.json`
88
+ * existence is an observable, truthful proxy (empirically, `claude auth logout`
89
+ * unlinks it).
90
+ */
91
+ export function hasClaudeCredentials(
92
+ env: NodeJS.ProcessEnv = process.env,
93
+ platform: NodeJS.Platform = osPlatform(),
94
+ ): boolean {
95
+ if (nonEmptyEnv(env.CLAUDE_CODE_OAUTH_TOKEN)) return true;
96
+ if (nonEmptyEnv(env.ANTHROPIC_API_KEY)) return true;
97
+ if (nonEmptyEnv(env.ANTHROPIC_AUTH_TOKEN)) return true;
98
+ if (envTruthy(env.CLAUDE_CODE_USE_BEDROCK)) return true;
99
+ if (envTruthy(env.CLAUDE_CODE_USE_VERTEX)) return true;
100
+ if (envTruthy(env.CLAUDE_CODE_USE_FOUNDRY)) return true;
101
+ if (envTruthy(env.CLAUDE_CODE_USE_ANTHROPIC_AWS)) return true;
102
+ if (envTruthy(env.CLAUDE_CODE_USE_MANTLE)) return true;
103
+
104
+ const configDir = resolveClaudeConfigDir(env);
105
+ if (existsSync(join(configDir, ".credentials.json"))) return true;
106
+ if (hasApiKeyHelper(configDir)) return true;
107
+
108
+ if (platform === "darwin") return true;
109
+
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Snapshot of the inputs to a registration decision.
115
+ *
116
+ * The bridge keeps two process-global tokens (Symbol.for): a PRIMARY-instance
117
+ * token, claimed unconditionally by the first-loaded module instance, and the
118
+ * stream-guard token holding the registered instance's streamSimple. ONLY the
119
+ * primary instance may ever register/unregister or claim the stream guard — this
120
+ * prevents a subagent module reload (a fresh, non-primary instance) from
121
+ * stealing ownership and registering ITS streamSimple, which would split-brain
122
+ * the shared session/ctx and break tool-result delivery.
123
+ */
124
+ export interface RegistrationState {
125
+ /** Does the machine have Claude credentials right now? */
126
+ credentialed: boolean;
127
+ /** Is THIS module instance the primary (first-loaded) instance? */
128
+ isPrimary: boolean;
129
+ /** Has this instance already registered (owns the stream guard)? */
130
+ registered: boolean;
131
+ }
132
+
133
+ export type RegistrationDecision = "register" | "unregister" | "noop";
134
+
135
+ /**
136
+ * Pure decision for extension load, every session_start re-check, and the
137
+ * pre-spawn fail-fast path.
138
+ *
139
+ * Rules:
140
+ * - Not the primary instance → NOOP (never touch registration).
141
+ * - Primary + credentialed + not registered → REGISTER (claim guard + register).
142
+ * - Primary + credentialed + already registered → NOOP.
143
+ * - Primary + uncredentialed → UNREGISTER (defensive).
144
+ *
145
+ * The uncredentialed primary always returns UNREGISTER rather than NOOP:
146
+ * pi.unregisterProvider is idempotent ("Has no effect if the provider was never
147
+ * registered"), and a defensive call is the ONLY way to retract a registration
148
+ * that survived a /reload — the ModelRegistry's registeredProviders is a
149
+ * process-lifetime Map and module reload does NOT clear it. (At extension-load
150
+ * time this defensive unregister only filters the pending-registration queue and
151
+ * cannot mutate the persistent registry; the authoritative retraction happens on
152
+ * the post-load session_start re-check — see applyProviderRegistration.)
153
+ */
154
+ export function decideRegistration(state: RegistrationState): RegistrationDecision {
155
+ if (!state.isPrimary) return "noop";
156
+ if (state.credentialed) return state.registered ? "noop" : "register";
157
+ return "unregister";
158
+ }
package/src/config.ts CHANGED
@@ -13,6 +13,14 @@ export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
13
13
 
14
14
  const VALID_EFFORT_LEVELS = new Set<BridgeEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
15
15
 
16
+ /**
17
+ * Per-session control over claude.ai connector WRITE tools when connectors are
18
+ * enabled. `deny` (default) hides Gmail/Calendar/Drive mutating tools so
19
+ * connector chat sessions are read-only; `allow` exposes them (used only by the
20
+ * one-shot approved-write executor). Reads are always available.
21
+ */
22
+ export type ConnectorWriteMode = "deny" | "allow";
23
+
16
24
  export interface Config {
17
25
  enabled?: boolean;
18
26
  /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
@@ -28,6 +36,27 @@ export interface Config {
28
36
  settingSources?: SettingSource[];
29
37
  strictMcpConfig?: boolean;
30
38
  pathToClaudeCodeExecutable?: string;
39
+ /**
40
+ * Expose the authenticated Claude account's claude.ai cloud MCP
41
+ * connectors (Gmail / Google Calendar / Google Drive, etc.) to the model.
42
+ * Off by default so Pi owns tool execution and tokens stay lean. Also
43
+ * settable via the CLAUDE_BRIDGE_ENABLE_CONNECTORS env var (env OR config
44
+ * enables it). See docs/plans/claude-bridge-google-connectors.md.
45
+ */
46
+ enableConnectors?: boolean;
47
+ /**
48
+ * When connectors are enabled, whether their WRITE tools
49
+ * (create/update/delete/label/etc.) are exposed. Defaults to `deny`
50
+ * (read-only), enforced two ways: known write tools are removed from the
51
+ * model's context (disallowedTools by exact id), and a PreToolUse hook
52
+ * blocks any connector write tool by name prefix at call time (covers
53
+ * future write tools). `allow` disables both — intended ONLY for a
54
+ * one-shot approved-write executor process. Also settable via
55
+ * CLAUDE_BRIDGE_CONNECTOR_WRITE=deny|allow (env wins over config). Any
56
+ * value but exact `allow` is treated as `deny`. Ignored when connectors
57
+ * are disabled.
58
+ */
59
+ connectorWriteMode?: ConnectorWriteMode;
31
60
  };
32
61
  /** Extra Pi context forwarded to Claude Code on top of AGENTS.md + skills. */
33
62
  promptContext?: {
@@ -46,10 +75,29 @@ function expandHome(input: string): string {
46
75
  return input;
47
76
  }
48
77
 
49
- function piUserDir(): string {
78
+ /**
79
+ * The Pi agent config dir: `PI_CODING_AGENT_DIR` when set, else `~/.pi/agent`.
80
+ * Every bridge default that used to hardcode `~/.pi/agent` routes through this
81
+ * so a host app that owns the agent dir owns those paths too.
82
+ */
83
+ export function piUserDir(): string {
50
84
  return resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
51
85
  }
52
86
 
87
+ /**
88
+ * Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
89
+ * declares that nothing outside its explicitly configured dirs may be read.
90
+ * Disables every cwd/home discovery fallback — the cwd AGENTS.md walk, project
91
+ * `.pi/` settings + claude-bridge.json, project APPEND_SYSTEM.md, and the
92
+ * `$PATH` claude executable search. Reads stay confined to `piUserDir()` (i.e.
93
+ * `PI_CODING_AGENT_DIR`) and the explicitly configured executable path.
94
+ * Default (unset) behavior for normal pi CLI users is unchanged.
95
+ */
96
+ export function isolatedFromEnv(): boolean {
97
+ const v = (process.env.CLAUDE_BRIDGE_ISOLATED ?? "").trim().toLowerCase();
98
+ return v === "1" || v === "true" || v === "yes" || v === "on";
99
+ }
100
+
53
101
  function asRecord(value: unknown): SettingsRecord | undefined {
54
102
  return value && typeof value === "object" && !Array.isArray(value) ? value as SettingsRecord : undefined;
55
103
  }
@@ -93,6 +141,10 @@ function projectTrustRegistry(): ProjectTrustRegistry {
93
141
 
94
142
  export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
95
143
  if (!ctx.cwd) return;
144
+ // Isolated mode never reads project config, so recording trust would only
145
+ // run the cwd-ancestor `.pi/settings.json` walk (a filesystem probe outside
146
+ // the host-owned dirs) for a result nothing consumes. Skip it entirely.
147
+ if (isolatedFromEnv()) return;
96
148
  let trusted = true;
97
149
  try {
98
150
  trusted = ctx.isProjectTrusted?.() === true;
@@ -111,6 +163,7 @@ function projectSettingsTrusted(settingsPath: string): boolean {
111
163
 
112
164
  function settingsPaths(cwd: string): string[] {
113
165
  const user = join(piUserDir(), "settings.json");
166
+ if (isolatedFromEnv()) return [user];
114
167
  const project = projectSettingsPath(cwd);
115
168
  return projectSettingsTrusted(project) ? [user, project] : [user];
116
169
  }
@@ -155,6 +208,13 @@ function hasOwn(raw: SettingsRecord, key: string): boolean {
155
208
  return Object.prototype.hasOwnProperty.call(raw, key);
156
209
  }
157
210
 
211
+ export function normalizeConnectorWriteMode(value: unknown): ConnectorWriteMode | undefined {
212
+ if (typeof value !== "string") return undefined;
213
+ const normalized = value.trim().toLowerCase();
214
+ if (normalized === "deny" || normalized === "allow") return normalized;
215
+ return undefined;
216
+ }
217
+
158
218
  export function normalizeEffortLevel(value: unknown): BridgeEffortLevel | undefined {
159
219
  if (typeof value !== "string") return undefined;
160
220
  const normalized = value.trim().toLowerCase();
@@ -195,6 +255,13 @@ function normalizeProviderConfig(provider: Config["provider"] | undefined): Conf
195
255
  const modelEffortOverrides = normalizeModelEffortOverrides(raw.modelEffortOverrides);
196
256
  if (modelEffortOverrides) out.modelEffortOverrides = modelEffortOverrides;
197
257
  else delete out.modelEffortOverrides;
258
+ // Fail closed: legacy config files are merged raw, so an unvalidated
259
+ // connectorWriteMode (e.g. "Deny", "read-only", true) must not slip through as
260
+ // a truthy non-"allow" value. Drop anything that isn't exactly deny/allow so
261
+ // the resolver falls back to the default deny.
262
+ const connectorWriteMode = normalizeConnectorWriteMode(raw.connectorWriteMode);
263
+ if (connectorWriteMode) out.connectorWriteMode = connectorWriteMode;
264
+ else delete out.connectorWriteMode;
198
265
  return out;
199
266
  }
200
267
 
@@ -216,6 +283,10 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
216
283
  }
217
284
  const strictMcpConfig = boolFrom(raw, "strictMcpConfig");
218
285
  if (strictMcpConfig !== undefined) provider.strictMcpConfig = strictMcpConfig;
286
+ const enableConnectors = boolFrom(raw, "enableConnectors");
287
+ if (enableConnectors !== undefined) provider.enableConnectors = enableConnectors;
288
+ const connectorWriteMode = normalizeConnectorWriteMode(raw.connectorWriteMode);
289
+ if (connectorWriteMode) provider.connectorWriteMode = connectorWriteMode;
219
290
  const claudePath = stringFrom(raw, "pathToClaudeCodeExecutable");
220
291
  if (claudePath) provider.pathToClaudeCodeExecutable = claudePath;
221
292
 
@@ -237,8 +308,9 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
237
308
 
238
309
  export function loadConfig(cwd: string): Config {
239
310
  const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
240
- const projectSettings = projectSettingsPath(cwd);
241
- const trustedProject = projectSettingsTrusted(projectSettings);
311
+ const isolated = isolatedFromEnv();
312
+ const projectSettings = isolated ? undefined : projectSettingsPath(cwd);
313
+ const trustedProject = projectSettings !== undefined && projectSettingsTrusted(projectSettings);
242
314
  const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
243
315
  const manager = managerToConfig(readManagerConfig(cwd));
244
316
  const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });