@vanillagreen/pi-claude-bridge 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -10
- package/bundle/connector-inventory.js +137 -0
- package/bundle/index.js +8682 -8287
- package/package.json +6 -2
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +307 -0
- package/src/bridge-state.ts +136 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-inventory.ts +281 -0
- package/src/connectors.ts +359 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +109 -1579
- package/src/models.ts +22 -1
- package/src/query-state.ts +42 -0
- package/src/rate-limit.ts +63 -0
- package/src/session-persistence.ts +329 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { spawn as spawnProcess } from "child_process";
|
|
3
|
+
import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from "fs";
|
|
4
|
+
import { delimiter, join } from "path";
|
|
5
|
+
import { isolatedFromEnv } from "./config.js";
|
|
6
|
+
import { DEBUG, debug } from "./debug.js";
|
|
7
|
+
|
|
8
|
+
function executableFromPath(name: string): string | undefined {
|
|
9
|
+
const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
10
|
+
for (const dir of paths) {
|
|
11
|
+
const candidate = join(dir, name);
|
|
12
|
+
try {
|
|
13
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
14
|
+
return candidate;
|
|
15
|
+
} catch {
|
|
16
|
+
// keep searching
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveClaudeExecutable(configured?: string): string | undefined {
|
|
23
|
+
const trimmed = configured?.trim();
|
|
24
|
+
if (trimmed) return trimmed;
|
|
25
|
+
// Isolated mode: never run whatever `claude` happens to be on $PATH — the
|
|
26
|
+
// host app either pins an executable in config or gets the SDK's bundled
|
|
27
|
+
// default, which ships inside the host bundle.
|
|
28
|
+
if (isolatedFromEnv()) return undefined;
|
|
29
|
+
return executableFromPath("claude") ?? executableFromPath("claude-code");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ClaudeExecutableFileType = "elf" | "mach-o" | "pe" | "shebang-script" | "empty" | "unknown";
|
|
33
|
+
|
|
34
|
+
export interface ClaudeExecutablePreflightResult {
|
|
35
|
+
path: string;
|
|
36
|
+
realPath: string;
|
|
37
|
+
cwd: string;
|
|
38
|
+
realCwd: string;
|
|
39
|
+
fileType: ClaudeExecutableFileType;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errnoValue(err: unknown): string | number | undefined {
|
|
43
|
+
return typeof (err as NodeJS.ErrnoException)?.errno === "number" ? (err as NodeJS.ErrnoException).errno : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function syscallValue(err: unknown): string | undefined {
|
|
47
|
+
return typeof (err as NodeJS.ErrnoException)?.syscall === "string" ? (err as NodeJS.ErrnoException).syscall : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pathValue(err: unknown): string | undefined {
|
|
51
|
+
const value = (err as NodeJS.ErrnoException)?.path;
|
|
52
|
+
return typeof value === "string" ? value : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function codeValue(err: unknown, fallback: string): string {
|
|
56
|
+
const value = (err as NodeJS.ErrnoException)?.code;
|
|
57
|
+
return typeof value === "string" ? value : fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function displayValue(value: unknown): string {
|
|
61
|
+
return value === undefined || value === null || value === "" ? "<none>" : String(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeClaudePreflightError(
|
|
65
|
+
summary: string,
|
|
66
|
+
details: { code: string; errno?: string | number; syscall?: string; path: string; cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string; cause?: unknown },
|
|
67
|
+
): Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string } {
|
|
68
|
+
const detail = [
|
|
69
|
+
`code=${details.code}`,
|
|
70
|
+
`errno=${displayValue(details.errno)}`,
|
|
71
|
+
`syscall=${displayValue(details.syscall)}`,
|
|
72
|
+
`path=${details.path}`,
|
|
73
|
+
`cwd=${details.cwd}`,
|
|
74
|
+
...(details.fileType ? [`fileType=${details.fileType}`] : []),
|
|
75
|
+
...(details.realPath ? [`realPath=${details.realPath}`] : []),
|
|
76
|
+
].join(" ");
|
|
77
|
+
const error = new Error(`${summary} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string };
|
|
78
|
+
error.name = "ClaudeExecutablePreflightError";
|
|
79
|
+
error.code = details.code;
|
|
80
|
+
if (details.errno !== undefined) error.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
|
|
81
|
+
if (details.syscall) error.syscall = details.syscall;
|
|
82
|
+
error.path = details.path;
|
|
83
|
+
error.cwd = details.cwd;
|
|
84
|
+
if (details.fileType) error.fileType = details.fileType;
|
|
85
|
+
if (details.realPath) error.realPath = details.realPath;
|
|
86
|
+
if (details.cause !== undefined) (error as Error & { cause?: unknown }).cause = details.cause;
|
|
87
|
+
return error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function classifyClaudeExecutableBytes(bytes: Uint8Array): ClaudeExecutableFileType {
|
|
91
|
+
if (bytes.length === 0) return "empty";
|
|
92
|
+
if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) return "shebang-script";
|
|
93
|
+
if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return "elf";
|
|
94
|
+
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return "pe";
|
|
95
|
+
if (bytes.length >= 4) {
|
|
96
|
+
const magic = bytes[0] * 0x1000000 + bytes[1] * 0x10000 + bytes[2] * 0x100 + bytes[3];
|
|
97
|
+
if (
|
|
98
|
+
magic === 0xfeedface ||
|
|
99
|
+
magic === 0xfeedfacf ||
|
|
100
|
+
magic === 0xcefaedfe ||
|
|
101
|
+
magic === 0xcffaedfe ||
|
|
102
|
+
magic === 0xcafebabe ||
|
|
103
|
+
magic === 0xbebafeca
|
|
104
|
+
) return "mach-o";
|
|
105
|
+
}
|
|
106
|
+
return "unknown";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function preflightClaudeExecutable(path: string, cwd: string): ClaudeExecutablePreflightResult {
|
|
110
|
+
let realCwd: string;
|
|
111
|
+
try {
|
|
112
|
+
const cwdStat = statSync(cwd);
|
|
113
|
+
if (!cwdStat.isDirectory()) {
|
|
114
|
+
throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
|
|
115
|
+
code: "ENOTDIR",
|
|
116
|
+
syscall: "chdir",
|
|
117
|
+
path: cwd,
|
|
118
|
+
cwd,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
accessSync(cwd, fsConstants.X_OK);
|
|
122
|
+
realCwd = realpathSync(cwd);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
|
|
125
|
+
throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
|
|
126
|
+
code: codeValue(err, "EACCES"),
|
|
127
|
+
errno: errnoValue(err),
|
|
128
|
+
syscall: syscallValue(err),
|
|
129
|
+
path: pathValue(err) ?? cwd,
|
|
130
|
+
cwd,
|
|
131
|
+
cause: err,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let realPath: string;
|
|
136
|
+
try {
|
|
137
|
+
const stat = statSync(path);
|
|
138
|
+
if (!stat.isFile()) {
|
|
139
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
|
|
140
|
+
code: "EACCES",
|
|
141
|
+
syscall: "exec",
|
|
142
|
+
path,
|
|
143
|
+
cwd,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
accessSync(path, fsConstants.X_OK);
|
|
147
|
+
realPath = realpathSync(path);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
|
|
150
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
|
|
151
|
+
code: codeValue(err, "ENOENT"),
|
|
152
|
+
errno: errnoValue(err),
|
|
153
|
+
syscall: syscallValue(err),
|
|
154
|
+
path: pathValue(err) ?? path,
|
|
155
|
+
cwd,
|
|
156
|
+
cause: err,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let fileType: ClaudeExecutableFileType;
|
|
161
|
+
try {
|
|
162
|
+
fileType = classifyClaudeExecutableBytes(readFileSync(realPath).subarray(0, 16));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
|
|
165
|
+
code: codeValue(err, "EACCES"),
|
|
166
|
+
errno: errnoValue(err),
|
|
167
|
+
syscall: syscallValue(err),
|
|
168
|
+
path: pathValue(err) ?? realPath,
|
|
169
|
+
cwd,
|
|
170
|
+
realPath,
|
|
171
|
+
cause: err,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
|
|
176
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
|
|
177
|
+
code: "ENOEXEC",
|
|
178
|
+
syscall: "exec",
|
|
179
|
+
path,
|
|
180
|
+
cwd,
|
|
181
|
+
fileType,
|
|
182
|
+
realPath,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { path, realPath, cwd, realCwd, fileType };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function envFlagEnabled(value: string | undefined): boolean {
|
|
190
|
+
return value === "1" || value?.toLowerCase() === "true";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function wrapClaudeSpawnErrorForSdk(err: Error, options: SpawnOptions): Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string } {
|
|
194
|
+
const originalCode = codeValue(err, "SPAWN_ERROR");
|
|
195
|
+
const originalMessage = err.message;
|
|
196
|
+
const spawnPath = pathValue(err) ?? options.command;
|
|
197
|
+
const cwd = options.cwd ?? process.cwd();
|
|
198
|
+
const detail = [
|
|
199
|
+
`code=${originalCode}`,
|
|
200
|
+
`errno=${displayValue(errnoValue(err))}`,
|
|
201
|
+
`syscall=${displayValue(syscallValue(err))}`,
|
|
202
|
+
`path=${spawnPath}`,
|
|
203
|
+
`cwd=${cwd}`,
|
|
204
|
+
`command=${options.command}`,
|
|
205
|
+
].join(" ");
|
|
206
|
+
const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string };
|
|
207
|
+
wrapped.name = "ClaudeSpawnDiagnosticError";
|
|
208
|
+
// The SDK special-cases code === ENOENT and replaces the message with its
|
|
209
|
+
// generic "native binary not found" text. Preserve the original code in the
|
|
210
|
+
// message/originalCode while using a bridge code so the SDK surfaces context.
|
|
211
|
+
wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
|
|
212
|
+
wrapped.originalCode = originalCode;
|
|
213
|
+
wrapped.originalMessage = originalMessage;
|
|
214
|
+
const errno = errnoValue(err);
|
|
215
|
+
if (errno !== undefined) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
|
|
216
|
+
const syscall = syscallValue(err);
|
|
217
|
+
if (syscall) wrapped.syscall = syscall;
|
|
218
|
+
wrapped.path = spawnPath;
|
|
219
|
+
wrapped.cwd = cwd;
|
|
220
|
+
// Do not set `cause` here: the listener copies these structured fields back
|
|
221
|
+
// onto the original Error. A cause reference to that same object would become
|
|
222
|
+
// `err.cause === err`, making JSON.stringify throw on a circular structure.
|
|
223
|
+
// originalMessage plus code/errno/syscall/path/cwd preserve the useful data.
|
|
224
|
+
return wrapped;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function spawnClaudeCodeWithDiagnostics(options: SpawnOptions): SpawnedProcess {
|
|
228
|
+
const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
|
|
229
|
+
const child = spawnProcess(options.command, options.args, {
|
|
230
|
+
cwd: options.cwd,
|
|
231
|
+
env: options.env,
|
|
232
|
+
signal: options.signal,
|
|
233
|
+
stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
});
|
|
236
|
+
if (pipeStderr) {
|
|
237
|
+
child.stderr?.on("data", (data) => {
|
|
238
|
+
for (const line of data.toString().split(/\r?\n/)) {
|
|
239
|
+
if (line) debug(`[cli-stderr spawn] ${line}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
child.prependListener("error", (err) => {
|
|
244
|
+
const originalStack = err.stack;
|
|
245
|
+
const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
|
|
246
|
+
Object.assign(err, wrapped);
|
|
247
|
+
err.name = wrapped.name;
|
|
248
|
+
err.message = wrapped.message;
|
|
249
|
+
// Keep V8's stack from the actual Node spawn failure, not the wrapper
|
|
250
|
+
// construction site. Diagnostic fields above remain enumerable and
|
|
251
|
+
// JSON-serializable; stack stays the spawn-time breadcrumb for operators.
|
|
252
|
+
if (originalStack) err.stack = originalStack;
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
stdin: child.stdin,
|
|
256
|
+
stdout: child.stdout,
|
|
257
|
+
get killed() { return child.killed; },
|
|
258
|
+
get exitCode() { return child.exitCode; },
|
|
259
|
+
kill: child.kill.bind(child),
|
|
260
|
+
on: child.on.bind(child),
|
|
261
|
+
once: child.once.bind(child),
|
|
262
|
+
off: child.off.bind(child),
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// User-facing extension config. Legacy config is loaded from
|
|
2
2
|
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json. vstack extension
|
|
3
|
-
// manager config is loaded from settings.json and overrides legacy files
|
|
3
|
+
// manager config is loaded from settings.json and overrides legacy files in
|
|
4
|
+
// normal Pi sessions. Isolated embedding hosts consume only the authoritative
|
|
5
|
+
// user claude-bridge.json and never read extension-manager settings.
|
|
4
6
|
|
|
5
7
|
import type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
6
8
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -87,10 +89,11 @@ export function piUserDir(): string {
|
|
|
87
89
|
/**
|
|
88
90
|
* Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
|
|
89
91
|
* declares that nothing outside its explicitly configured dirs may be read.
|
|
90
|
-
* Disables every cwd/home discovery fallback —
|
|
91
|
-
* `.pi/` settings + claude-bridge.json,
|
|
92
|
-
* `$PATH` claude executable search.
|
|
93
|
-
*
|
|
92
|
+
* Disables every cwd/home discovery fallback — all AGENTS.md discovery,
|
|
93
|
+
* extension-manager settings, project `.pi/` settings + claude-bridge.json,
|
|
94
|
+
* project APPEND_SYSTEM.md, and the `$PATH` claude executable search. Bridge
|
|
95
|
+
* configuration comes only from `piUserDir()/claude-bridge.json` and any
|
|
96
|
+
* explicitly configured executable path.
|
|
94
97
|
* Default (unset) behavior for normal pi CLI users is unchanged.
|
|
95
98
|
*/
|
|
96
99
|
export function isolatedFromEnv(): boolean {
|
|
@@ -163,7 +166,10 @@ function projectSettingsTrusted(settingsPath: string): boolean {
|
|
|
163
166
|
|
|
164
167
|
function settingsPaths(cwd: string): string[] {
|
|
165
168
|
const user = join(piUserDir(), "settings.json");
|
|
166
|
-
|
|
169
|
+
// An embedding host may have to share PI_CODING_AGENT_DIR with an in-process
|
|
170
|
+
// Pi SDK. In isolated mode, settings.json is therefore not authoritative and
|
|
171
|
+
// must not be consulted even at user scope.
|
|
172
|
+
if (isolatedFromEnv()) return [];
|
|
167
173
|
const project = projectSettingsPath(cwd);
|
|
168
174
|
return projectSettingsTrusted(project) ? [user, project] : [user];
|
|
169
175
|
}
|
|
@@ -312,7 +318,7 @@ export function loadConfig(cwd: string): Config {
|
|
|
312
318
|
const projectSettings = isolated ? undefined : projectSettingsPath(cwd);
|
|
313
319
|
const trustedProject = projectSettings !== undefined && projectSettingsTrusted(projectSettings);
|
|
314
320
|
const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
|
|
315
|
-
const manager = managerToConfig(readManagerConfig(cwd));
|
|
321
|
+
const manager: Partial<Config> = isolated ? {} : managerToConfig(readManagerConfig(cwd));
|
|
316
322
|
const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
|
|
317
323
|
return {
|
|
318
324
|
enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Deterministic enumeration of a Claude account's installed claude.ai connectors.
|
|
2
|
+
//
|
|
3
|
+
// The capability probe this replaces asked the MODEL to enumerate connectors via
|
|
4
|
+
// ToolSearch. A search returns what the search surfaced, which is a LOWER BOUND —
|
|
5
|
+
// nothing in the result distinguishes "these are the connectors" from "these are
|
|
6
|
+
// the connectors the search happened to return this time". Downstream then stored
|
|
7
|
+
// that lower bound as authoritative, so an account with Slack attached could
|
|
8
|
+
// report an inventory without Slack and no failure signal (vstack#838).
|
|
9
|
+
//
|
|
10
|
+
// This module asks the account instead of the model. Verified live against a
|
|
11
|
+
// personal claude_max org: the endpoint is POST (a GET returns 405) and each
|
|
12
|
+
// result carries BOTH `directoryUuid` (the catalog identity) and
|
|
13
|
+
// `installedServerId` (this account's installed instance). A marketplace catalog
|
|
14
|
+
// entry has no installed-server id, which is what establishes this as the
|
|
15
|
+
// INSTALLED set rather than the registry listing.
|
|
16
|
+
//
|
|
17
|
+
// INSTALLED IS NOT ATTACHED. This endpoint reports what the ACCOUNT has
|
|
18
|
+
// installed. It says nothing about whether a given connector's MCP server has
|
|
19
|
+
// finished attaching inside the `claude` child that is about to run a turn —
|
|
20
|
+
// this is a plain HTTPS call and does not consult that process at all. The two
|
|
21
|
+
// were previously conflated by accident: the ToolSearch probe could only report
|
|
22
|
+
// what was already attached, so an inventory implied availability (wrongly, but
|
|
23
|
+
// conservatively — it under-reported, which fails safe). They are now separately
|
|
24
|
+
// observable and can legitimately disagree: a correct `complete: true` inventory
|
|
25
|
+
// can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
|
|
26
|
+
// process (vstack#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
|
|
27
|
+
// availability and keep an attach-time check on the call path; do not derive
|
|
28
|
+
// "can I call this tool right now" from this result.
|
|
29
|
+
//
|
|
30
|
+
// Because the answer comes from the account rather than a model turn, the result
|
|
31
|
+
// is complete by construction — hence `complete: true` on success, and no
|
|
32
|
+
// "partial" state. A failure is a failure, never an empty-but-successful list.
|
|
33
|
+
|
|
34
|
+
const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
|
|
35
|
+
const DEFAULT_API_BASE = "https://api.anthropic.com";
|
|
36
|
+
// OAuth-token requests to the Anthropic API require this beta header; without it
|
|
37
|
+
// endpoints reject the bearer credential.
|
|
38
|
+
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
|
39
|
+
|
|
40
|
+
export type ConnectorEntry = {
|
|
41
|
+
name: string;
|
|
42
|
+
/** This account's installed instance. Present on every live result observed. */
|
|
43
|
+
installedServerId?: string;
|
|
44
|
+
/** Catalog identity, shared across accounts that install the same connector. */
|
|
45
|
+
directoryUuid?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
isAuthless?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Discriminated so a caller cannot read `connectors` without having checked `ok`.
|
|
51
|
+
// `complete` is carried explicitly rather than implied: the whole defect this
|
|
52
|
+
// fixes was a result that looked authoritative while being a lower bound.
|
|
53
|
+
// The absent side of each variant is declared as `?: undefined` rather than
|
|
54
|
+
// omitted: this package compiles with `strict: false`, where narrowing a union
|
|
55
|
+
// by a boolean discriminant does not reliably filter members, so a bare
|
|
56
|
+
// `{ok:true}|{ok:false}` pair makes `inventory.reason` a compile error at every
|
|
57
|
+
// call site. Spelling both sides keeps the union discriminated AND readable
|
|
58
|
+
// without depending on strictNullChecks-era narrowing.
|
|
59
|
+
export type ConnectorInventory =
|
|
60
|
+
| { ok: true; complete: true; connectors: ConnectorEntry[]; reason?: undefined }
|
|
61
|
+
| { ok: false; complete: false; connectors?: undefined; reason: string };
|
|
62
|
+
|
|
63
|
+
// SCOPING IS BY TOKEN, NOT BY ORG. Verified live: the org UUID in the path is
|
|
64
|
+
// ignored — an all-zero UUID and the literal string "not-a-uuid" both returned
|
|
65
|
+
// the bearer token's own account, identically to the real org. A multi-account
|
|
66
|
+
// host therefore CANNOT select an account by passing its organizationUuid; the
|
|
67
|
+
// only thing that selects an account is which credential the token came from
|
|
68
|
+
// (i.e. which CLAUDE_CONFIG_DIR was read). Getting that wrong yields a
|
|
69
|
+
// confident, well-formed answer for the WRONG account.
|
|
70
|
+
//
|
|
71
|
+
// The real UUID is still sent rather than a placeholder, so the call keeps
|
|
72
|
+
// working if the API starts enforcing it.
|
|
73
|
+
export type ClaudeOAuthCredentials = {
|
|
74
|
+
accessToken: string;
|
|
75
|
+
organizationUuid: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type Json = Record<string, any>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tool-namespace prefix for a connector, e.g. `Google Calendar` →
|
|
82
|
+
* `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
|
|
83
|
+
* connector with whitespace replaced by underscores; corroborated against the
|
|
84
|
+
* independently-authored CLAUDE_AI_CONNECTOR_TOOL_PATTERNS in connectors.ts,
|
|
85
|
+
* which was built from a live tool enumeration rather than from this rule.
|
|
86
|
+
*/
|
|
87
|
+
export function connectorServerNamespace(connectorName: string): string {
|
|
88
|
+
return `${CONNECTOR_NS_PREFIX}${connectorName.trim().replace(/\s+/g, "_")}__`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
|
|
92
|
+
// per-account by hosts that run one sidecar per Claude account, so it must win
|
|
93
|
+
// over the home-directory default or a multi-account host reads the wrong
|
|
94
|
+
// account's connectors. Both file names are probed under each root because the
|
|
95
|
+
// token and the org UUID do not reliably live in the same file across versions.
|
|
96
|
+
export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
97
|
+
const roots: string[] = [];
|
|
98
|
+
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
99
|
+
if (configDir) roots.push(configDir);
|
|
100
|
+
const home = env.HOME?.trim();
|
|
101
|
+
if (home) roots.push(`${home}/.claude`, home);
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
const paths: string[] = [];
|
|
104
|
+
for (const root of roots) {
|
|
105
|
+
for (const name of [".credentials.json", ".claude.json"]) {
|
|
106
|
+
const p = `${root}/${name}`;
|
|
107
|
+
if (!seen.has(p)) { seen.add(p); paths.push(p); }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return paths;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pull the OAuth access token and organization UUID out of the Claude config.
|
|
115
|
+
* They are scanned independently across all candidate files because they are not
|
|
116
|
+
* guaranteed to co-locate: on the machine this was verified against, the token
|
|
117
|
+
* lives in `.credentials.json` and the org UUID in `.claude.json`.
|
|
118
|
+
*
|
|
119
|
+
* `readFile` returns undefined for a missing/unreadable path. Parse failures are
|
|
120
|
+
* skipped rather than thrown — a corrupt file must not mask a good one later in
|
|
121
|
+
* the list.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveClaudeOAuth(
|
|
124
|
+
readFile: (path: string) => string | undefined,
|
|
125
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
126
|
+
): ClaudeOAuthCredentials | undefined {
|
|
127
|
+
let accessToken: string | undefined;
|
|
128
|
+
let organizationUuid: string | undefined;
|
|
129
|
+
|
|
130
|
+
for (const path of credentialCandidatePaths(env)) {
|
|
131
|
+
const raw = readFile(path);
|
|
132
|
+
if (!raw) continue;
|
|
133
|
+
let parsed: Json;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw) as Json;
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
accessToken ??= nonEmptyString(parsed?.claudeAiOauth?.accessToken);
|
|
140
|
+
organizationUuid ??= nonEmptyString(parsed?.oauthAccount?.organizationUuid);
|
|
141
|
+
if (accessToken && organizationUuid) break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!accessToken || !organizationUuid) return undefined;
|
|
145
|
+
return { accessToken, organizationUuid };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
149
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function connectorsListUrl(organizationUuid: string, apiBase: string = DEFAULT_API_BASE): string {
|
|
153
|
+
return `${trimTrailingSlashes(apiBase)}/api/oauth/organizations/${encodeURIComponent(organizationUuid)}/mcp/connectors/list`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Linear-time trailing-slash trim. This was `apiBase.replace(/\/+$/, "")`, which
|
|
157
|
+
// CodeQL correctly flags as a polynomial regex on uncontrolled input: `apiBase`
|
|
158
|
+
// is a caller-supplied parameter, and an anchored `+` backtracks on a long run
|
|
159
|
+
// of slashes. It only became reachable as library input once this module gained
|
|
160
|
+
// a real export surface, which is exactly the exposure the export was for.
|
|
161
|
+
function trimTrailingSlashes(value: string): string {
|
|
162
|
+
let end = value.length;
|
|
163
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) end--;
|
|
164
|
+
return value.slice(0, end);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type ListConnectorsDeps = {
|
|
168
|
+
credentials: ClaudeOAuthCredentials;
|
|
169
|
+
fetchImpl?: typeof fetch;
|
|
170
|
+
apiBase?: string;
|
|
171
|
+
signal?: AbortSignal;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Enumerate the account's installed connectors. Never throws: transport and
|
|
176
|
+
* protocol failures come back as `{ ok: false }` with a reason, so a caller can
|
|
177
|
+
* distinguish "this account has no connectors" (ok, empty list) from "we could
|
|
178
|
+
* not find out" — the distinction the search-driven probe could not express.
|
|
179
|
+
*
|
|
180
|
+
* The reason string is built only from the HTTP status and the API's own error
|
|
181
|
+
* message; the bearer token is never interpolated into it or logged.
|
|
182
|
+
*/
|
|
183
|
+
export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<ConnectorInventory> {
|
|
184
|
+
const { credentials, apiBase, signal } = deps;
|
|
185
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
186
|
+
const url = connectorsListUrl(credentials.organizationUuid, apiBase);
|
|
187
|
+
// Every failure return goes through this. Transport errors are the risk: a
|
|
188
|
+
// fetch/proxy layer is free to put the request headers — and therefore the
|
|
189
|
+
// bearer token — into the message it throws, and that message would otherwise
|
|
190
|
+
// land in a reason string that callers log.
|
|
191
|
+
const fail = (reason: string): ConnectorInventory =>
|
|
192
|
+
({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
|
|
193
|
+
|
|
194
|
+
let response: Response;
|
|
195
|
+
try {
|
|
196
|
+
response = await fetchImpl(url, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
"Authorization": `Bearer ${credentials.accessToken}`,
|
|
200
|
+
"anthropic-beta": OAUTH_BETA_HEADER,
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
},
|
|
203
|
+
body: "{}",
|
|
204
|
+
signal,
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return fail(`connector list request failed: ${errorText(error)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let bodyText: string;
|
|
211
|
+
try {
|
|
212
|
+
bodyText = await response.text();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return fail(`connector list response unreadable: ${errorText(error)}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let parsed: Json;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(bodyText) as Json;
|
|
224
|
+
} catch {
|
|
225
|
+
return fail("connector list returned a non-JSON body");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A missing/!Array `results` is a protocol change, not an empty account. Treat
|
|
229
|
+
// it as failure — reporting "no connectors" here would recreate exactly the
|
|
230
|
+
// silent-wrong-answer failure this module exists to remove.
|
|
231
|
+
if (!Array.isArray(parsed?.results)) {
|
|
232
|
+
return fail("connector list response had no results array");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const connectors: ConnectorEntry[] = [];
|
|
236
|
+
for (const raw of parsed.results as unknown[]) {
|
|
237
|
+
const entry = raw as Json;
|
|
238
|
+
const name = nonEmptyString(entry?.name);
|
|
239
|
+
// An unnamed entry cannot be matched to a tool namespace by any consumer,
|
|
240
|
+
// so silently keeping it would understate the inventory in a way the
|
|
241
|
+
// caller could not detect. Fail instead.
|
|
242
|
+
if (!name) {
|
|
243
|
+
return fail("connector list contained an entry with no name");
|
|
244
|
+
}
|
|
245
|
+
connectors.push({
|
|
246
|
+
name,
|
|
247
|
+
installedServerId: nonEmptyString(entry?.installedServerId),
|
|
248
|
+
directoryUuid: nonEmptyString(entry?.directoryUuid),
|
|
249
|
+
description: nonEmptyString(entry?.description),
|
|
250
|
+
isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { ok: true, complete: true, connectors };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function apiErrorSuffix(bodyText: string): string {
|
|
258
|
+
try {
|
|
259
|
+
const message = (JSON.parse(bodyText) as Json)?.error?.message;
|
|
260
|
+
return typeof message === "string" && message.trim() ? ` (${message.trim()})` : "";
|
|
261
|
+
} catch {
|
|
262
|
+
return "";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Replace the bearer token wherever it appears in text headed for a caller.
|
|
267
|
+
// Also covers a URL-encoded rendering, since some transports encode headers into
|
|
268
|
+
// an error's message. Short/empty tokens are not substituted — an over-eager
|
|
269
|
+
// match would corrupt unrelated text.
|
|
270
|
+
function redactSecret(text: string, secret: string): string {
|
|
271
|
+
if (!secret || secret.length < 8) return text;
|
|
272
|
+
let out = text;
|
|
273
|
+
for (const form of new Set([secret, encodeURIComponent(secret)])) {
|
|
274
|
+
out = out.split(form).join("[redacted]");
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function errorText(error: unknown): string {
|
|
280
|
+
return error instanceof Error ? error.message : String(error);
|
|
281
|
+
}
|