@vanillagreen/pi-claude-bridge 1.8.0 → 2.0.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 +78 -15
- package/bundle/connector-inventory.js +147 -0
- package/bundle/index.js +9367 -8348
- package/package.json +11 -7
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +574 -0
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +178 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +333 -0
- package/src/connectors.ts +500 -0
- package/src/convert.ts +14 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +424 -1736
- package/src/models.ts +22 -1
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +260 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +95 -0
- package/src/session-persistence.ts +334 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
- package/src/tool-pairing-audit.ts +48 -0
- package/src/typebox-to-zod.ts +9 -3
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { type HookCallback, type query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { normalizeConnectorWriteMode, type Config, type ConnectorWriteMode } from "./config.js";
|
|
3
|
+
import { MCP_SERVER_NAME } from "./skills.js";
|
|
4
|
+
import { connectorProxyUrl, connectorServerName, type ConnectorInventory } from "./connector-inventory.js";
|
|
5
|
+
|
|
6
|
+
// Disable Claude Code built-ins in the provider path. Pi owns tool execution;
|
|
7
|
+
// Claude reaches Pi tools through the bridged MCP server instead.
|
|
8
|
+
//
|
|
9
|
+
// `allowedTools` is a permission auto-allow list in the Claude Agent SDK, not a
|
|
10
|
+
// visibility allowlist. Use `tools: []` to remove the built-in tool set, and keep
|
|
11
|
+
// this disallow list as a belt-and-suspenders guard for SDK/CLI built-ins that may
|
|
12
|
+
// otherwise leak into the model context (e.g. TodoWrite, CronList, SendMessage).
|
|
13
|
+
export const DISALLOWED_BUILTIN_TOOLS = [
|
|
14
|
+
"Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash", "Agent", "Task",
|
|
15
|
+
"NotebookEdit", "EnterWorktree", "ExitWorktree",
|
|
16
|
+
"CronList", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
|
|
17
|
+
"TaskOutput", "TaskStop", "SendMessage", "Skill",
|
|
18
|
+
"TodoRead", "TodoWrite",
|
|
19
|
+
"ListMcpResources", "ReadMcpResource",
|
|
20
|
+
"WebFetch", "WebSearch",
|
|
21
|
+
"AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
|
|
22
|
+
"ToolSearch", "ScheduleWakeup",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
|
|
26
|
+
tools: [] as string[],
|
|
27
|
+
disallowedTools: DISALLOWED_BUILTIN_TOOLS,
|
|
28
|
+
allowedTools: [`mcp__${MCP_SERVER_NAME}__*`],
|
|
29
|
+
} satisfies Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">;
|
|
30
|
+
|
|
31
|
+
// --- Claude account cloud MCP connectors (Gmail / Calendar / Drive) ---
|
|
32
|
+
//
|
|
33
|
+
// By default the bridge suppresses claude.ai cloud MCP servers (see the
|
|
34
|
+
// ENABLE_CLAUDEAI_MCP_SERVERS="0" note near the query builder) so Pi owns tool
|
|
35
|
+
// execution and tokens stay lean. This opt-in flag lets the authenticated
|
|
36
|
+
// Claude account's authorized Google connectors flow through to the model,
|
|
37
|
+
// exposing Gmail/Calendar/Drive tools the account has connected. Gated so the
|
|
38
|
+
// default behavior is unchanged. See
|
|
39
|
+
// docs/plans/claude-bridge-google-connectors.md.
|
|
40
|
+
export function connectorsEnabledFromEnv(): boolean {
|
|
41
|
+
const v = (process.env.CLAUDE_BRIDGE_ENABLE_CONNECTORS ?? "").trim().toLowerCase();
|
|
42
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Connectors are enabled if EITHER the env var is truthy OR the resolved bridge
|
|
46
|
+
// config sets `provider.enableConnectors`. Env is the simplest per-process knob
|
|
47
|
+
// (one sidecar per Claude account sets it in its child env); config lets a host
|
|
48
|
+
// app enable it declaratively via its written settings.json.
|
|
49
|
+
export function connectorsEnabledFor(config?: Config): boolean {
|
|
50
|
+
return connectorsEnabledFromEnv() || config?.provider?.enableConnectors === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cloud MCP connector tool namespaces auto-allowed when connectors are enabled.
|
|
54
|
+
// Names match Claude Code's claude.ai connector servers.
|
|
55
|
+
// Whole-server globs (the only glob shape the CLI matcher honors). Deny rules
|
|
56
|
+
// take precedence, so listing a server here never exposes its writes — the
|
|
57
|
+
// write ids above and the PreToolUse hook still remove them.
|
|
58
|
+
export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
|
|
59
|
+
"mcp__claude_ai_Gmail__*",
|
|
60
|
+
"mcp__claude_ai_Google_Calendar__*",
|
|
61
|
+
"mcp__claude_ai_Google_Drive__*",
|
|
62
|
+
"mcp__claude_ai_Slack__*",
|
|
63
|
+
"mcp__claude_ai_Atlassian__*",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// Claude Code registers a Claude account's cloud connectors as DEFERRED tools
|
|
67
|
+
// that the model must load via ToolSearch (and enumerate via the MCP-resource
|
|
68
|
+
// tools). The default bridge isolation disallows all three so Pi owns tool
|
|
69
|
+
// discovery — but that hides the connectors from the model entirely. When
|
|
70
|
+
// connectors are enabled we must let these through so Gmail/Calendar/Drive are
|
|
71
|
+
// discoverable. Verified: disallowing ToolSearch reliably yields NO_CONNECTORS.
|
|
72
|
+
export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
|
|
73
|
+
|
|
74
|
+
// --- Connector WRITE tool control (read-inline / write-by-approval) ---
|
|
75
|
+
//
|
|
76
|
+
// Connector tools execute INSIDE claude via the bridge, so Memsira's Pi-level
|
|
77
|
+
// ConsentGate never sees them. To keep every connector WRITE explicit + gated,
|
|
78
|
+
// connector chat sessions run read-only (writes denied); the model performs a
|
|
79
|
+
// write only through a gated Pi custom-tool whose app-side dispatcher runs a
|
|
80
|
+
// ONE-SHOT write-enabled bridge query. This block is the bridge lever for that:
|
|
81
|
+
// deny connector write tools by default, allow them only for that executor.
|
|
82
|
+
//
|
|
83
|
+
// Cloud connector server namespaces (the `mcp__<server>__` prefix). Every
|
|
84
|
+
// claude.ai connector server lives under CONNECTOR_NS_PREFIX, so that prefix —
|
|
85
|
+
// not the named trio — is what marks a tool as connector-owned.
|
|
86
|
+
const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
|
|
87
|
+
const CONNECTOR_NS_GMAIL = `${CONNECTOR_NS_PREFIX}Gmail__`;
|
|
88
|
+
const CONNECTOR_NS_CALENDAR = `${CONNECTOR_NS_PREFIX}Google_Calendar__`;
|
|
89
|
+
const CONNECTOR_NS_DRIVE = `${CONNECTOR_NS_PREFIX}Google_Drive__`;
|
|
90
|
+
const CONNECTOR_NS_SLACK = `${CONNECTOR_NS_PREFIX}Slack__`;
|
|
91
|
+
const CONNECTOR_NS_ATLASSIAN = `${CONNECTOR_NS_PREFIX}Atlassian__`;
|
|
92
|
+
|
|
93
|
+
// Read verbs: a connector tool whose tool segment BEGINS with one of these
|
|
94
|
+
// words is a non-mutating READ and stays available. Everything else on a
|
|
95
|
+
// connector namespace is treated as a WRITE. Matching is on WORDS, not on a
|
|
96
|
+
// literal `verb_` prefix, because connector servers do not share a naming
|
|
97
|
+
// convention — verified live against a Claude account with Slack + Atlassian
|
|
98
|
+
// attached:
|
|
99
|
+
//
|
|
100
|
+
// Gmail search_threads, get_message (snake_case)
|
|
101
|
+
// Slack slack_read_channel, slack_search_public
|
|
102
|
+
// (snake_case, server-prefixed)
|
|
103
|
+
// Atlassian getJiraIssue, searchJiraIssuesUsingJql, getConfluencePage
|
|
104
|
+
// (camelCase)
|
|
105
|
+
//
|
|
106
|
+
// A literal-prefix test only matched the Gmail shape, so EVERY Slack and
|
|
107
|
+
// Atlassian tool — reads included — classified as a write and was denied at
|
|
108
|
+
// runtime, making those connectors unusable in a read-only session.
|
|
109
|
+
const CONNECTOR_READ_VERBS = new Set([
|
|
110
|
+
"list", "search", "get", "read", "fetch", "find",
|
|
111
|
+
"download", "describe", "query", "count", "view", "lookup", "whoami",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
// Mutating words. Two jobs, both on the deny side:
|
|
115
|
+
//
|
|
116
|
+
// 1. A name that begins with a read verb but also names a mutation
|
|
117
|
+
// (`fetchAndLock`, `get_incident_and_acknowledge`) is a WRITE — deny wins
|
|
118
|
+
// over the read exemption, so a compound name cannot earn read treatment.
|
|
119
|
+
// 2. A leading word that repeats the server name is NOT skipped when it is a
|
|
120
|
+
// mutation word, so a connector whose SERVER is verb-shaped
|
|
121
|
+
// (`Delete__delete_get_thing`) keeps its real verb.
|
|
122
|
+
//
|
|
123
|
+
// This list is deliberately broad and errs toward over-denial: a read wrongly
|
|
124
|
+
// called a write only blocks a read, whereas the reverse runs an ungated
|
|
125
|
+
// mutation. It is a backstop, not the primary protection — that is the leading
|
|
126
|
+
// verb, which real connector tools put first (`createJiraIssue`,
|
|
127
|
+
// `slack_send_message`, `delete_file`). Nouns that collide with real read names
|
|
128
|
+
// are deliberately excluded (`comment`/`tag`/`flag`/`run`, since
|
|
129
|
+
// `getComment`, `searchByTag`, `getFeatureFlag`, `getWorkflowRun` are reads).
|
|
130
|
+
const CONNECTOR_MUTATION_WORDS = new Set([
|
|
131
|
+
"create", "update", "delete", "remove", "add", "edit", "send", "post",
|
|
132
|
+
"write", "upload", "publish", "schedule", "transition", "archive", "move",
|
|
133
|
+
"copy", "revoke", "assign", "invite", "share", "rename", "replace", "set",
|
|
134
|
+
"merge", "resolve", "lock", "unlock", "acknowledge", "ack", "book",
|
|
135
|
+
"start", "stop", "terminate", "restart", "join", "leave", "star", "unstar",
|
|
136
|
+
"forward", "sync", "approve", "reject", "close", "reopen", "cancel",
|
|
137
|
+
"enable", "disable", "grant", "trigger", "execute", "apply", "submit",
|
|
138
|
+
"pin", "unpin", "mute", "unmute", "subscribe", "unsubscribe", "follow",
|
|
139
|
+
"unfollow", "clear", "purge", "reset", "rotate", "deploy", "install",
|
|
140
|
+
"uninstall", "save", "store", "put", "patch", "insert", "append",
|
|
141
|
+
"prepend", "duplicate", "restore", "revert", "import", "export", "upsert",
|
|
142
|
+
"sign", "complete", "claim", "release", "promote", "demote", "escalate",
|
|
143
|
+
"resend", "retry", "react", "vote",
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Splits a tool (or server) segment into lowercase words, handling snake_case,
|
|
147
|
+
// camelCase, PascalCase, and acronym runs (`getHTTPResponse` → get, http,
|
|
148
|
+
// response). Punctuation-only input yields an empty list, which callers treat
|
|
149
|
+
// as unparseable → write.
|
|
150
|
+
function connectorNameWords(segment: string): string[] {
|
|
151
|
+
return segment
|
|
152
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
153
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
154
|
+
.split(/[^A-Za-z0-9]+/)
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((word) => word.toLowerCase());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Explicit known write tool names (current claude.ai connectors). Passed to the
|
|
160
|
+
// SDK disallowedTools so today's writes are removed from the model's context by
|
|
161
|
+
// exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
|
|
162
|
+
//
|
|
163
|
+
// PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
|
|
164
|
+
// dependents that gate real user-facing approvals on them (vstack#892):
|
|
165
|
+
//
|
|
166
|
+
// memsira routes connector writes through its own gated approval flow
|
|
167
|
+
// drovr keeps its chat sidecar permanently write-`deny` and runs an
|
|
168
|
+
// approved write as a separate one-shot `claude -p` scoped by
|
|
169
|
+
// `--allowedTools` to exactly one connector tool (drovr#288)
|
|
170
|
+
//
|
|
171
|
+
// Both pin the actions they expose against this classification, because "the
|
|
172
|
+
// sidecar structurally cannot do this itself" is THIS module's claim, not
|
|
173
|
+
// theirs. RECLASSIFYING AN ENTRY HERE AS A READ WOULD MAKE A CONSUMER'S
|
|
174
|
+
// CONFIRMATION CARD BYPASSABLE. Additions are safe and expected; removals and
|
|
175
|
+
// read-verb reclassifications are breaking — coordinate first, see
|
|
176
|
+
// docs/cross-repo.md. `unit-connectors.mjs` pins the set so a change has to be
|
|
177
|
+
// deliberate.
|
|
178
|
+
export const CONNECTOR_WRITE_TOOLS = [
|
|
179
|
+
`${CONNECTOR_NS_GMAIL}create_draft`,
|
|
180
|
+
`${CONNECTOR_NS_GMAIL}create_label`,
|
|
181
|
+
`${CONNECTOR_NS_GMAIL}label_message`,
|
|
182
|
+
`${CONNECTOR_NS_GMAIL}label_thread`,
|
|
183
|
+
`${CONNECTOR_NS_GMAIL}unlabel_message`,
|
|
184
|
+
`${CONNECTOR_NS_GMAIL}unlabel_thread`,
|
|
185
|
+
`${CONNECTOR_NS_GMAIL}apply_sensitive_label`,
|
|
186
|
+
`${CONNECTOR_NS_GMAIL}remove_sensitive_label`,
|
|
187
|
+
`${CONNECTOR_NS_CALENDAR}create_event`,
|
|
188
|
+
`${CONNECTOR_NS_CALENDAR}update_event`,
|
|
189
|
+
`${CONNECTOR_NS_CALENDAR}delete_event`,
|
|
190
|
+
`${CONNECTOR_NS_CALENDAR}respond_to_event`,
|
|
191
|
+
`${CONNECTOR_NS_DRIVE}create_file`,
|
|
192
|
+
`${CONNECTOR_NS_DRIVE}copy_file`,
|
|
193
|
+
// Slack + Atlassian writes, taken from a live enumeration of an account with
|
|
194
|
+
// both connectors attached. The PreToolUse hook already denies these by verb;
|
|
195
|
+
// listing them by id also removes them from the model's context in a
|
|
196
|
+
// read-only session (the CLI matcher needs exact ids). Additive only — an id
|
|
197
|
+
// missing here is still denied at call time.
|
|
198
|
+
`${CONNECTOR_NS_SLACK}slack_send_message`,
|
|
199
|
+
`${CONNECTOR_NS_SLACK}slack_send_message_draft`,
|
|
200
|
+
`${CONNECTOR_NS_SLACK}slack_schedule_message`,
|
|
201
|
+
`${CONNECTOR_NS_SLACK}slack_create_canvas`,
|
|
202
|
+
`${CONNECTOR_NS_SLACK}slack_update_canvas`,
|
|
203
|
+
`${CONNECTOR_NS_ATLASSIAN}createJiraIssue`,
|
|
204
|
+
`${CONNECTOR_NS_ATLASSIAN}editJiraIssue`,
|
|
205
|
+
`${CONNECTOR_NS_ATLASSIAN}transitionJiraIssue`,
|
|
206
|
+
`${CONNECTOR_NS_ATLASSIAN}addCommentToJiraIssue`,
|
|
207
|
+
`${CONNECTOR_NS_ATLASSIAN}addWorklogToJiraIssue`,
|
|
208
|
+
`${CONNECTOR_NS_ATLASSIAN}createIssueLink`,
|
|
209
|
+
`${CONNECTOR_NS_ATLASSIAN}createConfluencePage`,
|
|
210
|
+
`${CONNECTOR_NS_ATLASSIAN}updateConfluencePage`,
|
|
211
|
+
`${CONNECTOR_NS_ATLASSIAN}createConfluenceFooterComment`,
|
|
212
|
+
`${CONNECTOR_NS_ATLASSIAN}createConfluenceInlineComment`,
|
|
213
|
+
`${CONNECTOR_NS_ATLASSIAN}createCompassComponent`,
|
|
214
|
+
`${CONNECTOR_NS_ATLASSIAN}createCompassComponentRelationship`,
|
|
215
|
+
`${CONNECTOR_NS_ATLASSIAN}createCompassCustomFieldDefinition`,
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* True for a tool that the `claude` CHILD executes itself, so Pi must never be
|
|
220
|
+
* asked to dispatch it.
|
|
221
|
+
*
|
|
222
|
+
* WHY THIS EXISTS. Every other tool the model calls in a bridge turn is a PI
|
|
223
|
+
* tool: Pi hands its tool set to the bridge, the bridge re-offers it to the
|
|
224
|
+
* child through the in-process MCP server, and a `tool_use` coming back is the
|
|
225
|
+
* child asking PI to run something. The bridge is built around that direction —
|
|
226
|
+
* it mirrors the call into the Pi stream, ends the Pi turn with `toolUse`, and
|
|
227
|
+
* the MCP handler blocks until Pi delivers the result.
|
|
228
|
+
*
|
|
229
|
+
* claude.ai connectors run the other way. They are the CHILD's own MCP servers,
|
|
230
|
+
* attached to the authenticated account and reachable only from inside that
|
|
231
|
+
* process. Pi has never heard of them. Mirroring one into the Pi stream anyway
|
|
232
|
+
* made Pi's agent loop look the name up in `context.tools`, miss, and write a
|
|
233
|
+
* synthetic `Tool <name> not found` error result into the transcript — while the
|
|
234
|
+
* child went on and executed the real call. The Pi transcript then RECORDED A
|
|
235
|
+
* FAILURE FOR A CALL THAT SUCCEEDED, next to an answer built from the real
|
|
236
|
+
* payload, so the model's correct answer read as a fabrication (drovr#311,
|
|
237
|
+
* memsira#320). The false result is also projected back into the child's session
|
|
238
|
+
* on a rebuild (`syncSharedSession`), which is how a lie in a mirror becomes a
|
|
239
|
+
* lie in the conversation of record.
|
|
240
|
+
*
|
|
241
|
+
* The test is the NAMESPACE, deliberately, not "does this name resolve to a Pi
|
|
242
|
+
* tool". Every claude.ai connector server lives under `mcp__claude_ai_`, and
|
|
243
|
+
* that is the only tool class the bridge knowingly delegates. A broader
|
|
244
|
+
* "unresolvable ⇒ delegated" rule would also swallow a genuine tool-name
|
|
245
|
+
* mismatch between Pi and the child, which SHOULD still surface as a loud
|
|
246
|
+
* dispatcher error.
|
|
247
|
+
*
|
|
248
|
+
* Takes the RAW SDK tool name, before `mapToolName` — connector names have no
|
|
249
|
+
* Pi-side counterpart, so mapping them is meaningless. Accepts a missing name
|
|
250
|
+
* rather than asserting one: this decides whether Pi is allowed to dispatch a
|
|
251
|
+
* block, and a nameless block is not a connector, so it answers `false`.
|
|
252
|
+
*/
|
|
253
|
+
export function isChildExecutedTool(name: string | undefined): boolean {
|
|
254
|
+
return typeof name === "string" && name.startsWith(CONNECTOR_NS_PREFIX);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED, twice:
|
|
258
|
+
//
|
|
259
|
+
// 1. Namespace: the whole `mcp__claude_ai_<Server>__` space counts, not just the
|
|
260
|
+
// known Gmail/Calendar/Drive trio. Connectors attach account-wide, so ANY
|
|
261
|
+
// other connector on the account (Slack, Atlassian, Figma, org-custom) shows
|
|
262
|
+
// up in a bridge session — and the connector path deliberately omits
|
|
263
|
+
// `tools: []` (see toolIsolationForQuery), so those tools are discoverable
|
|
264
|
+
// and callable. Keying on the trio meant e.g. a Slack send_message ran
|
|
265
|
+
// ungated inside claude, invisible to Pi's ConsentGate.
|
|
266
|
+
// 2. Verb: a tool on that space is a write UNLESS its tool segment BEGINS with a
|
|
267
|
+
// known read verb, so not-yet-known write tools (e.g. Gmail send_message,
|
|
268
|
+
// Drive delete_file, Calendar add_attendee) are blocked in a read-only
|
|
269
|
+
// session. A name under the connector prefix with no parseable `__<tool>`
|
|
270
|
+
// segment is also a write: it is a connector by construction, so deny wins.
|
|
271
|
+
// The verb is matched as a WORD across snake_case and camelCase, and a
|
|
272
|
+
// leading word that merely repeats the server name is skipped first
|
|
273
|
+
// (`Slack__slack_read_channel` reads as `read channel`), because connector
|
|
274
|
+
// servers name their tools differently from one another.
|
|
275
|
+
//
|
|
276
|
+
// Non-connector tools (Pi custom-tools, ToolSearch, MCP-resource tools, other MCP
|
|
277
|
+
// servers) are never connector writes → false. Used by connectorWriteDenyHook and
|
|
278
|
+
// by callers (e.g. the one-shot write executor) that enumerate live connector tools.
|
|
279
|
+
export function isConnectorWriteTool(name: string): boolean {
|
|
280
|
+
if (!name.startsWith(CONNECTOR_NS_PREFIX)) return false;
|
|
281
|
+
// First `__` after the prefix ends the server segment. First (not last) so a
|
|
282
|
+
// server name containing `__` leaves the extra segment in `tool`, which then
|
|
283
|
+
// fails the read-prefix test — ambiguity resolves to write.
|
|
284
|
+
const sep = name.indexOf("__", CONNECTOR_NS_PREFIX.length);
|
|
285
|
+
// No separator (sep < 0) OR an EMPTY server segment (sep at the prefix, e.g.
|
|
286
|
+
// `mcp__claude_ai___search_messages`) means the name doesn't parse as
|
|
287
|
+
// <server>__<tool> — it never earns the read-prefix exemption.
|
|
288
|
+
if (sep <= CONNECTOR_NS_PREFIX.length) return true;
|
|
289
|
+
const server = name.slice(CONNECTOR_NS_PREFIX.length, sep);
|
|
290
|
+
const words = connectorNameWords(name.slice(sep + "__".length));
|
|
291
|
+
// Skip a leading run of words that just repeats the server name, so a
|
|
292
|
+
// server-prefixed tool (`Slack__slack_read_channel`) is judged on its real
|
|
293
|
+
// verb. Only an exact leading match is skipped — an unrelated first word
|
|
294
|
+
// (`Weird__Server__list_things`) stays and fails the read test. A mutation
|
|
295
|
+
// word is never skipped, so a verb-shaped server name
|
|
296
|
+
// (`Delete__delete_get_thing`, `Sync__sync_get_status`) cannot launder its
|
|
297
|
+
// own tool's verb away.
|
|
298
|
+
const serverWords = connectorNameWords(server);
|
|
299
|
+
let skipped = 0;
|
|
300
|
+
while (
|
|
301
|
+
skipped < serverWords.length
|
|
302
|
+
&& words[skipped] === serverWords[skipped]
|
|
303
|
+
&& !CONNECTOR_MUTATION_WORDS.has(words[skipped])
|
|
304
|
+
) skipped++;
|
|
305
|
+
const rest = words.slice(skipped);
|
|
306
|
+
// Nothing parseable left (empty/punctuation-only tool segment, or a tool
|
|
307
|
+
// named exactly after its server) → write.
|
|
308
|
+
if (rest.length === 0) return true;
|
|
309
|
+
if (!CONNECTOR_READ_VERBS.has(rest[0])) return true;
|
|
310
|
+
// Begins as a read but also names a mutation → deny wins.
|
|
311
|
+
return rest.some((word) => CONNECTOR_MUTATION_WORDS.has(word));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Connector write mode from the env override. `allow` exposes connector write
|
|
315
|
+
// tools; `deny` hides them. Returns undefined when unset so config can decide.
|
|
316
|
+
export function connectorWriteModeFromEnv(): ConnectorWriteMode | undefined {
|
|
317
|
+
const v = (process.env.CLAUDE_BRIDGE_CONNECTOR_WRITE ?? "").trim().toLowerCase();
|
|
318
|
+
if (v === "allow") return "allow";
|
|
319
|
+
if (v === "deny") return "deny";
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Resolve the connector write mode: env wins over config, default `deny`
|
|
324
|
+
// (mirrors connectorsEnabledFor's env-first precedence). Only meaningful when
|
|
325
|
+
// connectors are enabled; connector chat sessions keep the default deny and the
|
|
326
|
+
// one-shot approved-write executor sets allow (env or config).
|
|
327
|
+
//
|
|
328
|
+
// FAIL CLOSED: writes are enabled ONLY by an explicit, validated `allow`. The
|
|
329
|
+
// config value is re-normalized here (defense in depth over normalizeProviderConfig)
|
|
330
|
+
// so a raw legacy-config value like "Deny"/"read-only"/true can never be treated
|
|
331
|
+
// as a truthy non-deny and silently open writes — anything but exact allow → deny.
|
|
332
|
+
export function connectorWriteModeFor(config?: Config): ConnectorWriteMode {
|
|
333
|
+
const resolved = connectorWriteModeFromEnv() ?? normalizeConnectorWriteMode(config?.provider?.connectorWriteMode);
|
|
334
|
+
return resolved === "allow" ? "allow" : "deny";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// PreToolUse hook that hard-blocks connector WRITE tools at call time. Hooks run
|
|
338
|
+
// regardless of permissionMode (we use bypassPermissions), so this — not the
|
|
339
|
+
// static deny lists — is the real prefix-based runtime enforcement of
|
|
340
|
+
// isConnectorWriteTool. disallowedTools removes today's KNOWN writes from model
|
|
341
|
+
// context, but it lists exact ids on the known trio only, so a future write tool
|
|
342
|
+
// (e.g. mcp__claude_ai_Gmail__send_message, ..._Drive__delete_file) or any tool
|
|
343
|
+
// on another connector the account has attached (mcp__claude_ai_Slack__send_message)
|
|
344
|
+
// would otherwise be callable in a read-only session; this hook denies it by prefix.
|
|
345
|
+
export function connectorWriteDenyHook(): HookCallback {
|
|
346
|
+
return async (input) => {
|
|
347
|
+
// The CLI treats a hook error/timeout as an EMPTY hook output and lets
|
|
348
|
+
// the tool call proceed (fail OPEN) — so any exception in this body
|
|
349
|
+
// must convert to a deny, never an allow. Today's body is pure string
|
|
350
|
+
// checks on schema-validated input; the catch pins that invariant for
|
|
351
|
+
// whatever gets added here later.
|
|
352
|
+
try {
|
|
353
|
+
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
354
|
+
if (!isConnectorWriteTool(input.tool_name)) return { continue: true };
|
|
355
|
+
return connectorWriteDenyOutput(String(input.tool_name));
|
|
356
|
+
} catch {
|
|
357
|
+
const toolName = typeof (input as { tool_name?: unknown })?.tool_name === "string"
|
|
358
|
+
? (input as { tool_name: string }).tool_name
|
|
359
|
+
: "<unknown>";
|
|
360
|
+
return connectorWriteDenyOutput(toolName);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// The deny reason is handed verbatim to the `claude` child's model, so it must
|
|
366
|
+
// stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
|
|
367
|
+
// Naming one host told a different app's model to use a product it has never
|
|
368
|
+
// heard of, which is confusing at exactly the moment someone is debugging a
|
|
369
|
+
// refused write (vstack#892). Each host describes its own approval flow in its
|
|
370
|
+
// own prompt; this string only has to say that one exists.
|
|
371
|
+
function connectorWriteDenyOutput(toolName: string) {
|
|
372
|
+
return {
|
|
373
|
+
hookSpecificOutput: {
|
|
374
|
+
hookEventName: "PreToolUse" as const,
|
|
375
|
+
permissionDecision: "deny" as const,
|
|
376
|
+
permissionDecisionReason:
|
|
377
|
+
`Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
|
|
378
|
+
`Connector writes must go through the host application's gated approval flow.`,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Connector query-option fragment: tool isolation (allow/deny lists) plus, when
|
|
384
|
+
// connectors are enabled and writes are denied, the runtime PreToolUse write
|
|
385
|
+
// hook. Spread into the SDK query options; continuation queries inherit it via
|
|
386
|
+
// `{ ...queryOptions }`. Exported so the wiring is unit-testable end to end.
|
|
387
|
+
export function connectorQueryOptions(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools" | "hooks">> {
|
|
388
|
+
const isolation = toolIsolationForQuery(connectorsEnabled, writeMode);
|
|
389
|
+
// Only enforce (and only meaningful) when connectors are on and writes denied.
|
|
390
|
+
if (!connectorsEnabled || writeMode === "allow") return isolation;
|
|
391
|
+
return { ...isolation, hooks: { PreToolUse: [{ hooks: [connectorWriteDenyHook()] }] } };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Tool isolation for a query. When connectors are enabled we still remove
|
|
395
|
+
// Claude Code's filesystem/shell built-ins (via disallowedTools; Pi owns those)
|
|
396
|
+
// and auto-allow the cloud connector tool namespaces so the model can call
|
|
397
|
+
// Gmail/Calendar/Drive.
|
|
398
|
+
//
|
|
399
|
+
// Critically, we must OMIT `tools: []` in the connector path: an empty --tools
|
|
400
|
+
// allowlist strips the claude.ai cloud MCP connector tools from the model's
|
|
401
|
+
// view (verified — Pi's SDK-injected custom-tools survive it, but connectors do
|
|
402
|
+
// not). Dropping `tools` leaves the connectors visible; disallowedTools still
|
|
403
|
+
// hard-denies the built-ins so Pi keeps ownership of file/shell/web tools.
|
|
404
|
+
export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">> {
|
|
405
|
+
if (!connectorsEnabled) return CLAUDE_BRIDGE_TOOL_ISOLATION;
|
|
406
|
+
// Keep ToolSearch + MCP-resource tools available so the model can discover the
|
|
407
|
+
// deferred cloud connector tools; still block file/shell/web built-ins.
|
|
408
|
+
const disallowedTools = DISALLOWED_BUILTIN_TOOLS.filter((t) => !CONNECTOR_DISCOVERY_TOOLS.includes(t));
|
|
409
|
+
// Deny connector WRITE tools unless writes are explicitly allowed (fail
|
|
410
|
+
// closed: any mode but exact "allow" is treated as read-only). This removes
|
|
411
|
+
// today's KNOWN writes from the model's context by exact id; deny rules take
|
|
412
|
+
// precedence over the CLAUDE_AI_CONNECTOR_TOOL_PATTERNS allow rules below, so
|
|
413
|
+
// reads stay available. Runtime enforcement covering future write tools and
|
|
414
|
+
// connector namespaces we don't enumerate here (Slack, Atlassian, org-custom)
|
|
415
|
+
// is done by connectorWriteDenyHook — see connectorQueryOptions.
|
|
416
|
+
if (writeMode !== "allow") disallowedTools.push(...CONNECTOR_WRITE_TOOLS);
|
|
417
|
+
return {
|
|
418
|
+
disallowedTools,
|
|
419
|
+
allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Explicit `mcpServers` declarations for the account's CONNECTED connectors.
|
|
425
|
+
*
|
|
426
|
+
* Why this exists (vstack#832): claude.ai connectors load async and non-blocking,
|
|
427
|
+
* and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
|
|
428
|
+
* CLI has even fetched the connector list. The model therefore composes its first
|
|
429
|
+
* answer against a manifest containing no connectors and says it has no access,
|
|
430
|
+
* while the connector attaches ~1s later and is never asked. Measured end to end
|
|
431
|
+
* on 40 cold sidecars (memsira, 2026-07-26): a connector tool call happened in
|
|
432
|
+
* 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
|
|
433
|
+
* went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
|
|
434
|
+
* declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
|
|
435
|
+
*
|
|
436
|
+
* It is also FASTER, which is the opposite of what the startup barrier suggests.
|
|
437
|
+
* The barrier is real but small and sub-linear — manifest build 490ms none /
|
|
438
|
+
* 1996ms one / 2574ms seven, so seven costs +578ms over one, not 7x. Meanwhile
|
|
439
|
+
* first token drops from a 9840ms median (worst 35.7s) to 6887ms (worst 7.8s),
|
|
440
|
+
* because declaring removes the model's speculative ToolSearch and dead ends.
|
|
441
|
+
* The barrier buys back more than it spends.
|
|
442
|
+
*
|
|
443
|
+
* `alwaysLoad` is the mechanism: it blocks startup until the server is connected
|
|
444
|
+
* (5s cap) precisely "since the tools must be present when the turn-1 prompt is
|
|
445
|
+
* built". It is a field on the server config, so the connector has to be a server
|
|
446
|
+
* WE declare — the CLI's own loader never applies it.
|
|
447
|
+
*
|
|
448
|
+
* Two things here are load-bearing and were established by measurement, not
|
|
449
|
+
* inference:
|
|
450
|
+
*
|
|
451
|
+
* 1. The key MUST be the CLI's own server name (`connectorServerName`). The key
|
|
452
|
+
* is the tool namespace, so any other key yields the connector twice under two
|
|
453
|
+
* namespaces. Consumers that pin fully-qualified tool names rather than
|
|
454
|
+
* globbing a namespace then break.
|
|
455
|
+
* 2. Only `installState === "connected"` connectors are declared. The rest are
|
|
456
|
+
* never attempted by the CLI either, and declaring them would mean asking
|
|
457
|
+
* `alwaysLoad` to block startup on servers that cannot connect.
|
|
458
|
+
*
|
|
459
|
+
* Deliberately NOT typed against the SDK's `McpServerConfig`: that exported union
|
|
460
|
+
* omits the `claudeai-proxy` variant entirely, and its `McpClaudeAIProxyServerConfig`
|
|
461
|
+
* has no `alwaysLoad` field — while the runtime zod schema in the shipped CLI does.
|
|
462
|
+
* Typings and runtime disagree; the runtime honours `alwaysLoad` (verified live),
|
|
463
|
+
* so this builds the object the runtime accepts and casts once, here, with the
|
|
464
|
+
* reason recorded rather than spread across call sites.
|
|
465
|
+
*/
|
|
466
|
+
export function connectorMcpServers(inventory: ConnectorInventory): Record<string, unknown> {
|
|
467
|
+
if (!inventory.ok) return {};
|
|
468
|
+
// Escape hatch. `alwaysLoad` holds startup until each declared server
|
|
469
|
+
// connects, and the bound on that wait is NOT established: the SDK doc
|
|
470
|
+
// comment says a 5s cap while the CLI logs `timeout of 30000ms`, and four
|
|
471
|
+
// attempts to force a genuine mid-handshake hang each failed fast for a
|
|
472
|
+
// different reason, so the worst case was never observed. An account with
|
|
473
|
+
// slow or numerous connectors therefore has an unquantified turn-1 delay,
|
|
474
|
+
// and this switch turns declarations off without giving up connectors.
|
|
475
|
+
if (connectorDeclarationsDisabled()) return {};
|
|
476
|
+
const servers: Record<string, unknown> = {};
|
|
477
|
+
for (const entry of inventory.connectors) {
|
|
478
|
+
if (entry.installState !== "connected") continue;
|
|
479
|
+
if (!entry.installedServerId) continue;
|
|
480
|
+
servers[connectorServerName(entry.name)] = {
|
|
481
|
+
type: "claudeai-proxy",
|
|
482
|
+
url: connectorProxyUrl(entry.installedServerId),
|
|
483
|
+
id: entry.installedServerId,
|
|
484
|
+
alwaysLoad: true,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return servers;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
|
|
493
|
+
* connector declarations while leaving connectors themselves enabled. Falls back
|
|
494
|
+
* to the pre-#832 behaviour: connectors still load, they just race the turn-1
|
|
495
|
+
* manifest again.
|
|
496
|
+
*/
|
|
497
|
+
export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
498
|
+
const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
|
|
499
|
+
return v === "off" || v === "0" || v === "false" || v === "no";
|
|
500
|
+
}
|
package/src/convert.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { Message as PiMessage } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { ContentBlock, Message as SessionMessage } from "cc-session-io";
|
|
6
6
|
import { pascalCase } from "change-case";
|
|
7
|
+
import { isChildExecutedTool } from "./connectors.js";
|
|
7
8
|
|
|
8
9
|
export const PROVIDER_ID = "claude-bridge";
|
|
9
10
|
|
|
@@ -21,6 +22,19 @@ export function sanitizeToolId(id: string, cache: Map<string, string>): string {
|
|
|
21
22
|
|
|
22
23
|
export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
|
|
23
24
|
if (!name) return "";
|
|
25
|
+
// A claude.ai connector name is ALREADY the child's own tool id — the child
|
|
26
|
+
// owns that namespace natively. PascalCasing it invented an alias
|
|
27
|
+
// (`mcp__claude_ai_Slack__slack_search_channels` →
|
|
28
|
+
// `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
|
|
29
|
+
// projected history, so the model imitated it on the next turn and got a
|
|
30
|
+
// real `Tool ... not found` from the MCP dispatcher before retrying the
|
|
31
|
+
// canonical name — one wasted round-trip per affected call (memsira#320).
|
|
32
|
+
//
|
|
33
|
+
// Connector names stopped reaching this function at all once they stopped
|
|
34
|
+
// being mirrored as Pi tool calls (isChildExecutedTool), so this is the
|
|
35
|
+
// belt to that braces: LEGACY Pi history recorded before that fix still
|
|
36
|
+
// carries them, and a rebuild would still project the alias.
|
|
37
|
+
if (isChildExecutedTool(name)) return name;
|
|
24
38
|
const normalized = name.toLowerCase();
|
|
25
39
|
if (customToolNameToSdk) {
|
|
26
40
|
const mapped = customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized);
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { piUserDir } from "./config.js";
|
|
4
|
+
|
|
5
|
+
// --- Debug logging ---
|
|
6
|
+
// CLAUDE_BRIDGE_DEBUG=1 enables debug logging to <piUserDir>/claude-bridge.log
|
|
7
|
+
// (~/.pi/agent/claude-bridge.log unless PI_CODING_AGENT_DIR points elsewhere).
|
|
8
|
+
|
|
9
|
+
export const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
|
|
10
|
+
export const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(piUserDir(), "claude-bridge.log");
|
|
11
|
+
|
|
12
|
+
export function diagLogPath(): string {
|
|
13
|
+
return process.env.CLAUDE_BRIDGE_DIAG_PATH || join(piUserDir(), "claude-bridge-diag.log");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Ensure log directories exist when debug is enabled
|
|
17
|
+
if (DEBUG) {
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
|
|
20
|
+
mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
|
|
21
|
+
} catch {
|
|
22
|
+
// If directory creation fails, debug functions will throw on first use
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Unique per module evaluation — confirms whether subagents share module state
|
|
27
|
+
export const moduleInstanceId = Math.random().toString(36).slice(2, 8);
|
|
28
|
+
|
|
29
|
+
export function debug(...args: unknown[]) {
|
|
30
|
+
if (!DEBUG) return;
|
|
31
|
+
const ts = new Date().toISOString();
|
|
32
|
+
const fmt = (a: unknown): string => {
|
|
33
|
+
if (typeof a === "string") return a;
|
|
34
|
+
if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
35
|
+
return JSON.stringify(a);
|
|
36
|
+
};
|
|
37
|
+
const msg = args.map(fmt).join(" ");
|
|
38
|
+
try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`); } catch { /* debug is best effort */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
|
|
42
|
+
// CLI subprocess to write its own debug log to a file we choose, and also
|
|
43
|
+
// forward its stderr into our debug stream. Drops straight into the real SDK's
|
|
44
|
+
// Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
|
|
45
|
+
// stderr). Without this, CC's internal view of the world is invisible to us
|
|
46
|
+
// and "No conversation found" / empty-error reports are unactionable.
|
|
47
|
+
let nextCliDebugSeq = 1;
|
|
48
|
+
export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
|
|
49
|
+
if (!DEBUG) return {};
|
|
50
|
+
const seq = nextCliDebugSeq++;
|
|
51
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
52
|
+
const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
|
|
53
|
+
try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
|
|
54
|
+
const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
|
|
55
|
+
debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
|
|
56
|
+
return {
|
|
57
|
+
debug: true,
|
|
58
|
+
debugFile,
|
|
59
|
+
stderr: (data: string) => {
|
|
60
|
+
for (const line of data.split(/\r?\n/)) {
|
|
61
|
+
if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Unconditional diagnostic dump — for "should never happen" paths */
|
|
68
|
+
export function diagDump(label: string, data: Record<string, unknown>) {
|
|
69
|
+
try {
|
|
70
|
+
const ts = new Date().toISOString();
|
|
71
|
+
const entry = { ts, moduleInstanceId, label, ...data };
|
|
72
|
+
const path = diagLogPath();
|
|
73
|
+
try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } catch { /* best effort */ }
|
|
74
|
+
appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
75
|
+
try { chmodSync(path, 0o600); } catch { /* best effort */ }
|
|
76
|
+
debug(`DIAG: ${label} (see ${path})`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
debug(`DIAG FAILED: ${label}`, error);
|
|
79
|
+
}
|
|
80
|
+
}
|