@bivy/bivy 0.0.0 → 0.1.0-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { IntegrationStore } from "./store.js";
|
|
5
|
+
import { BUILT_IN_INTEGRATIONS } from "./registry.js";
|
|
6
|
+
import { buildAuthorizeUrl, createPkce, exchangeCode, refreshToken, } from "./oauth.js";
|
|
7
|
+
import { SecretVault } from "../secrets.js";
|
|
8
|
+
/**
|
|
9
|
+
* Owns integration connections and exposes them to any agent as tools.
|
|
10
|
+
*
|
|
11
|
+
* - REST handlers in server.ts call `list / connectApiKey / startOAuth /
|
|
12
|
+
* completeOAuth / disconnect`.
|
|
13
|
+
* - `toolProvider()` exposes the connected integrations' tools as a
|
|
14
|
+
* runtime-agnostic ToolProvider handed to each session; the tools execute here
|
|
15
|
+
* on the daemon (where credentials live) for in-process AND remote agents alike.
|
|
16
|
+
*/
|
|
17
|
+
export class IntegrationManager {
|
|
18
|
+
store;
|
|
19
|
+
registry;
|
|
20
|
+
secrets;
|
|
21
|
+
pending = new Map();
|
|
22
|
+
/** Names of tools flagged risky (approval-gated), regardless of connection. */
|
|
23
|
+
riskyTools;
|
|
24
|
+
constructor(appDir, registry = BUILT_IN_INTEGRATIONS) {
|
|
25
|
+
this.store = new IntegrationStore(appDir);
|
|
26
|
+
this.secrets = new SecretVault(appDir);
|
|
27
|
+
this.registry = registry;
|
|
28
|
+
this.riskyTools = new Set(registry.flatMap((d) => d.tools.filter((t) => t.risky).map((t) => t.name)));
|
|
29
|
+
}
|
|
30
|
+
// --- helpers ------------------------------------------------------------
|
|
31
|
+
def(id) {
|
|
32
|
+
return this.registry.find((d) => d.id === id);
|
|
33
|
+
}
|
|
34
|
+
oauthClient(spec) {
|
|
35
|
+
const clientId = process.env[spec.clientIdEnv];
|
|
36
|
+
const clientSecret = process.env[spec.clientSecretEnv];
|
|
37
|
+
if (!clientId)
|
|
38
|
+
return undefined;
|
|
39
|
+
return { clientId, clientSecret: clientSecret ?? "" };
|
|
40
|
+
}
|
|
41
|
+
/** Whether the operator has supplied the credentials needed to connect this integration. */
|
|
42
|
+
isConfigured(def) {
|
|
43
|
+
if (def.auth.kind === "oauth2")
|
|
44
|
+
return Boolean(this.oauthClient(def.auth));
|
|
45
|
+
return true; // apiKey / none are always "configurable" by the user
|
|
46
|
+
}
|
|
47
|
+
isRiskyTool(toolName) {
|
|
48
|
+
return this.riskyTools.has(toolName);
|
|
49
|
+
}
|
|
50
|
+
// --- public views -------------------------------------------------------
|
|
51
|
+
list() {
|
|
52
|
+
return this.registry.map((def) => {
|
|
53
|
+
const conn = this.store.get(def.id);
|
|
54
|
+
const status = conn ? conn.status : "disconnected";
|
|
55
|
+
return {
|
|
56
|
+
id: def.id,
|
|
57
|
+
name: def.name,
|
|
58
|
+
description: def.description,
|
|
59
|
+
icon: def.icon,
|
|
60
|
+
docsUrl: def.docsUrl,
|
|
61
|
+
authKind: def.auth.kind,
|
|
62
|
+
apiKeyLabel: def.auth.kind === "apiKey" ? def.auth.label : undefined,
|
|
63
|
+
apiKeyHelp: def.auth.kind === "apiKey" ? def.auth.help : undefined,
|
|
64
|
+
configured: this.isConfigured(def),
|
|
65
|
+
status,
|
|
66
|
+
accountLabel: conn?.accountLabel,
|
|
67
|
+
toolNames: def.tools.map((t) => t.name),
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// --- connect / disconnect ----------------------------------------------
|
|
72
|
+
async connectApiKey(id, key) {
|
|
73
|
+
const def = this.def(id);
|
|
74
|
+
if (!def)
|
|
75
|
+
throw new Error(`Unknown integration: ${id}`);
|
|
76
|
+
if (def.auth.kind !== "apiKey")
|
|
77
|
+
throw new Error(`${id} does not use an API key`);
|
|
78
|
+
if (!key.trim())
|
|
79
|
+
throw new Error("API key is required");
|
|
80
|
+
const secretId = `integration.${id}.api-key`;
|
|
81
|
+
this.secrets.setLocal(secretId, key.trim(), `${def.name} API key`);
|
|
82
|
+
const conn = { id, status: "connected", apiKeyRef: `secret://${secretId}`, connectedAt: Date.now() };
|
|
83
|
+
this.store.set(conn);
|
|
84
|
+
return conn;
|
|
85
|
+
}
|
|
86
|
+
/** Begin an OAuth2 flow; returns the URL the user should open. */
|
|
87
|
+
startOAuth(id, redirectUri) {
|
|
88
|
+
const def = this.def(id);
|
|
89
|
+
if (!def || def.auth.kind !== "oauth2")
|
|
90
|
+
throw new Error(`${id} is not an OAuth integration`);
|
|
91
|
+
const client = this.oauthClient(def.auth);
|
|
92
|
+
if (!client) {
|
|
93
|
+
throw new Error(`Set ${def.auth.clientIdEnv} (and ${def.auth.clientSecretEnv}) to connect ${def.name}`);
|
|
94
|
+
}
|
|
95
|
+
const state = randomUUID();
|
|
96
|
+
const pkce = def.auth.pkce ? createPkce() : undefined;
|
|
97
|
+
this.pending.set(state, { id: state, integrationId: id, redirectUri, verifier: pkce?.verifier, createdAt: Date.now() });
|
|
98
|
+
const authUrl = buildAuthorizeUrl(def.auth, {
|
|
99
|
+
clientId: client.clientId,
|
|
100
|
+
redirectUri,
|
|
101
|
+
state,
|
|
102
|
+
codeChallenge: pkce?.challenge,
|
|
103
|
+
});
|
|
104
|
+
return { authUrl, state };
|
|
105
|
+
}
|
|
106
|
+
/** Complete an OAuth2 flow from the redirect callback. */
|
|
107
|
+
async completeOAuth(state, code) {
|
|
108
|
+
const pending = this.pending.get(state);
|
|
109
|
+
if (!pending)
|
|
110
|
+
throw new Error("Unknown or expired OAuth state");
|
|
111
|
+
this.pending.delete(state);
|
|
112
|
+
const def = this.def(pending.integrationId);
|
|
113
|
+
if (!def || def.auth.kind !== "oauth2")
|
|
114
|
+
throw new Error("Integration is no longer available");
|
|
115
|
+
const client = this.oauthClient(def.auth);
|
|
116
|
+
if (!client)
|
|
117
|
+
throw new Error("OAuth client credentials are no longer configured");
|
|
118
|
+
const token = await exchangeCode(def.auth, client, {
|
|
119
|
+
code,
|
|
120
|
+
redirectUri: pending.redirectUri,
|
|
121
|
+
codeVerifier: pending.verifier,
|
|
122
|
+
});
|
|
123
|
+
const secretId = `integration.${def.id}.oauth`;
|
|
124
|
+
this.secrets.setLocal(secretId, JSON.stringify(token), `${def.name} OAuth token set`);
|
|
125
|
+
const conn = {
|
|
126
|
+
id: def.id,
|
|
127
|
+
status: "connected",
|
|
128
|
+
oauthRef: `secret://${secretId}`,
|
|
129
|
+
connectedAt: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
this.store.set(conn);
|
|
132
|
+
// Best-effort: label the connection with the account email/workspace.
|
|
133
|
+
if (def.auth.accountLabel) {
|
|
134
|
+
try {
|
|
135
|
+
const label = await def.auth.accountLabel(this.httpFor(def, conn));
|
|
136
|
+
if (label) {
|
|
137
|
+
conn.accountLabel = label;
|
|
138
|
+
this.store.set(conn);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// ignore labeling failures
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return conn;
|
|
146
|
+
}
|
|
147
|
+
disconnect(id) {
|
|
148
|
+
this.secrets.delete(`integration.${id}.api-key`);
|
|
149
|
+
this.secrets.delete(`integration.${id}.oauth`);
|
|
150
|
+
return this.store.remove(id);
|
|
151
|
+
}
|
|
152
|
+
// --- auth'd HTTP --------------------------------------------------------
|
|
153
|
+
/** Build a fetch helper bound to a connection: auth header + baseUrl + refresh. */
|
|
154
|
+
httpFor(def, conn) {
|
|
155
|
+
const resolve = (input) => /^https?:\/\//i.test(input) ? input : `${(def.baseUrl ?? "").replace(/\/$/, "")}${input.startsWith("/") ? "" : "/"}${input}`;
|
|
156
|
+
const oauthToken = async () => {
|
|
157
|
+
if (conn.oauthRef) {
|
|
158
|
+
const raw = await this.secrets.resolve(conn.oauthRef);
|
|
159
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
160
|
+
}
|
|
161
|
+
return conn.oauth;
|
|
162
|
+
};
|
|
163
|
+
const authHeaders = async () => {
|
|
164
|
+
if (def.auth.kind === "apiKey") {
|
|
165
|
+
const key = conn.apiKeyRef ? await this.secrets.resolve(conn.apiKeyRef) : conn.apiKey;
|
|
166
|
+
if (key)
|
|
167
|
+
return def.auth.header ? def.auth.header(key) : { authorization: `Bearer ${key}` };
|
|
168
|
+
}
|
|
169
|
+
const token = await oauthToken();
|
|
170
|
+
if (token?.accessToken) {
|
|
171
|
+
return { authorization: `${token.tokenType || "Bearer"} ${token.accessToken}` };
|
|
172
|
+
}
|
|
173
|
+
return {};
|
|
174
|
+
};
|
|
175
|
+
const ensureFreshToken = async () => {
|
|
176
|
+
if (def.auth.kind !== "oauth2")
|
|
177
|
+
return;
|
|
178
|
+
const token = await oauthToken();
|
|
179
|
+
if (!token)
|
|
180
|
+
return;
|
|
181
|
+
const expiringSoon = token.expiresAt !== undefined && token.expiresAt - Date.now() < 60_000;
|
|
182
|
+
if (!expiringSoon || !token.refreshToken)
|
|
183
|
+
return;
|
|
184
|
+
const client = this.oauthClient(def.auth);
|
|
185
|
+
if (!client)
|
|
186
|
+
return;
|
|
187
|
+
await this.applyRefresh(def, conn, client, token.refreshToken);
|
|
188
|
+
};
|
|
189
|
+
const send = async (input, init = {}, didRefresh = false) => {
|
|
190
|
+
await ensureFreshToken();
|
|
191
|
+
const res = await fetch(resolve(input), {
|
|
192
|
+
...init,
|
|
193
|
+
headers: { ...(await authHeaders()), ...init.headers },
|
|
194
|
+
});
|
|
195
|
+
// Reactive refresh: token rejected unexpectedly -> refresh once and retry.
|
|
196
|
+
const token = await oauthToken();
|
|
197
|
+
if (res.status === 401 && !didRefresh && def.auth.kind === "oauth2" && token?.refreshToken) {
|
|
198
|
+
const client = this.oauthClient(def.auth);
|
|
199
|
+
if (client) {
|
|
200
|
+
await this.applyRefresh(def, conn, client, token.refreshToken);
|
|
201
|
+
return send(input, init, true);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return res;
|
|
205
|
+
};
|
|
206
|
+
const http = ((input, init) => send(input, init ?? {}));
|
|
207
|
+
http.json = async (input, init) => {
|
|
208
|
+
const res = await send(input, init ?? {});
|
|
209
|
+
const body = await res.text();
|
|
210
|
+
if (!res.ok)
|
|
211
|
+
throw new Error(`${def.name} request failed (${res.status}): ${body.slice(0, 300)}`);
|
|
212
|
+
return (body ? JSON.parse(body) : undefined);
|
|
213
|
+
};
|
|
214
|
+
return http;
|
|
215
|
+
}
|
|
216
|
+
async applyRefresh(def, conn, client, refresh) {
|
|
217
|
+
if (def.auth.kind !== "oauth2")
|
|
218
|
+
return;
|
|
219
|
+
let next;
|
|
220
|
+
try {
|
|
221
|
+
next = await refreshToken(def.auth, client, refresh);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
conn.status = "error";
|
|
225
|
+
conn.error = error instanceof Error ? error.message : String(error);
|
|
226
|
+
this.store.set(conn);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
if (conn.oauthRef?.startsWith("secret://"))
|
|
230
|
+
this.secrets.setLocal(conn.oauthRef.slice("secret://".length), JSON.stringify(next));
|
|
231
|
+
else
|
|
232
|
+
conn.oauth = next;
|
|
233
|
+
conn.status = "connected";
|
|
234
|
+
conn.error = undefined;
|
|
235
|
+
this.store.set(conn);
|
|
236
|
+
}
|
|
237
|
+
// --- agent-agnostic tool provider ---------------------------------------
|
|
238
|
+
/**
|
|
239
|
+
* A runtime-agnostic ToolProvider exposing every connected integration's tools.
|
|
240
|
+
* This is the seam the daemon hands to a session (in-process OR remote) so any
|
|
241
|
+
* agent can use the tools without the IntegrationManager knowing which agent it
|
|
242
|
+
* is — the tools execute HERE, on the daemon, where the credentials/HTTP clients
|
|
243
|
+
* live. A snapshot of the connected set is taken per call (at session start),
|
|
244
|
+
* mirroring the previous per-session behavior; disconnected integrations
|
|
245
|
+
* contribute nothing, so the tool surface and system prompt stay clean.
|
|
246
|
+
*/
|
|
247
|
+
toolProvider() {
|
|
248
|
+
const specs = [];
|
|
249
|
+
const executors = new Map();
|
|
250
|
+
for (const def of this.registry) {
|
|
251
|
+
const conn = this.store.get(def.id);
|
|
252
|
+
if (!conn || conn.status !== "connected")
|
|
253
|
+
continue;
|
|
254
|
+
const http = this.httpFor(def, conn);
|
|
255
|
+
for (const tool of def.tools) {
|
|
256
|
+
specs.push({ name: tool.name, label: tool.label, description: tool.description, promptSnippet: tool.description, parameters: tool.parameters });
|
|
257
|
+
executors.set(tool.name, async (params, signal) => {
|
|
258
|
+
try {
|
|
259
|
+
const result = await tool.execute(params, http, signal);
|
|
260
|
+
return { details: {}, ...result };
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
264
|
+
return { content: [{ type: "text", text: `${tool.name} failed: ${message}` }], details: {}, isError: true };
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
list: () => specs,
|
|
271
|
+
invoke: async (toolName, _toolCallId, params, signal) => {
|
|
272
|
+
const exec = executors.get(toolName);
|
|
273
|
+
if (!exec)
|
|
274
|
+
return { content: [{ type: "text", text: `Unknown integration tool: ${toolName}` }], details: {}, isError: true };
|
|
275
|
+
return exec(params, signal);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
function base64url(buf) {
|
|
5
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6
|
+
}
|
|
7
|
+
export function createPkce() {
|
|
8
|
+
const verifier = base64url(randomBytes(32));
|
|
9
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
10
|
+
return { verifier, challenge };
|
|
11
|
+
}
|
|
12
|
+
export function buildAuthorizeUrl(spec, opts) {
|
|
13
|
+
const url = new URL(spec.authUrl);
|
|
14
|
+
url.searchParams.set("response_type", "code");
|
|
15
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
16
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
17
|
+
url.searchParams.set("state", opts.state);
|
|
18
|
+
if (spec.scopes.length)
|
|
19
|
+
url.searchParams.set("scope", spec.scopes.join(" "));
|
|
20
|
+
if (opts.codeChallenge) {
|
|
21
|
+
url.searchParams.set("code_challenge", opts.codeChallenge);
|
|
22
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
23
|
+
}
|
|
24
|
+
for (const [k, v] of Object.entries(spec.extraAuthParams ?? {}))
|
|
25
|
+
url.searchParams.set(k, v);
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
function normalizeToken(payload) {
|
|
29
|
+
const expiresIn = Number(payload.expires_in);
|
|
30
|
+
return {
|
|
31
|
+
accessToken: String(payload.access_token),
|
|
32
|
+
refreshToken: payload.refresh_token ? String(payload.refresh_token) : undefined,
|
|
33
|
+
expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : undefined,
|
|
34
|
+
scope: payload.scope ? String(payload.scope) : undefined,
|
|
35
|
+
tokenType: payload.token_type ? String(payload.token_type) : "Bearer",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function postToken(spec, body) {
|
|
39
|
+
const res = await fetch(spec.tokenUrl, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
42
|
+
body: new URLSearchParams(body).toString(),
|
|
43
|
+
});
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
let payload;
|
|
46
|
+
try {
|
|
47
|
+
payload = JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(`Token endpoint returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok || !payload.access_token) {
|
|
53
|
+
throw new Error(`Token exchange failed (${res.status}): ${payload.error_description || payload.error || text.slice(0, 200)}`);
|
|
54
|
+
}
|
|
55
|
+
return normalizeToken(payload);
|
|
56
|
+
}
|
|
57
|
+
export function exchangeCode(spec, client, opts) {
|
|
58
|
+
return postToken(spec, {
|
|
59
|
+
grant_type: "authorization_code",
|
|
60
|
+
code: opts.code,
|
|
61
|
+
redirect_uri: opts.redirectUri,
|
|
62
|
+
client_id: client.clientId,
|
|
63
|
+
client_secret: client.clientSecret,
|
|
64
|
+
...(opts.codeVerifier ? { code_verifier: opts.codeVerifier } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function refreshToken(spec, client, refresh) {
|
|
68
|
+
const next = await postToken(spec, {
|
|
69
|
+
grant_type: "refresh_token",
|
|
70
|
+
refresh_token: refresh,
|
|
71
|
+
client_id: client.clientId,
|
|
72
|
+
client_secret: client.clientSecret,
|
|
73
|
+
});
|
|
74
|
+
// Providers often omit the refresh_token on refresh; keep the existing one.
|
|
75
|
+
if (!next.refreshToken)
|
|
76
|
+
next.refreshToken = refresh;
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Built-in integrations.
|
|
6
|
+
//
|
|
7
|
+
// Each entry is fully declarative: an auth spec + a handful of tools. The tool
|
|
8
|
+
// `execute` receives an already-authenticated `http` helper (auth header +
|
|
9
|
+
// baseUrl + token refresh handled by the manager), so a tool is usually a
|
|
10
|
+
// single fetch + format. To add a service, append one object to this array.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
function text(s, details) {
|
|
13
|
+
return { content: [{ type: "text", text: s }], details };
|
|
14
|
+
}
|
|
15
|
+
function toRfc822({ to, subject, body }) {
|
|
16
|
+
const lines = [`To: ${to}`, `Subject: ${subject}`, "Content-Type: text/plain; charset=UTF-8", "", body];
|
|
17
|
+
return Buffer.from(lines.join("\r\n")).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
export const BUILT_IN_INTEGRATIONS = [
|
|
20
|
+
{
|
|
21
|
+
id: "notion",
|
|
22
|
+
name: "Notion",
|
|
23
|
+
description: "Search your Notion workspace and read pages.",
|
|
24
|
+
icon: "📝",
|
|
25
|
+
docsUrl: "https://www.notion.so/my-integrations",
|
|
26
|
+
baseUrl: "https://api.notion.com/v1",
|
|
27
|
+
auth: {
|
|
28
|
+
kind: "apiKey",
|
|
29
|
+
label: "Notion internal integration token",
|
|
30
|
+
help: "Create an internal integration at notion.so/my-integrations, then share the pages you want reachable with it.",
|
|
31
|
+
placeholder: "secret_…",
|
|
32
|
+
},
|
|
33
|
+
tools: [
|
|
34
|
+
{
|
|
35
|
+
name: "notion_search",
|
|
36
|
+
label: "Notion: Search",
|
|
37
|
+
description: "Search pages and databases shared with the Notion integration.",
|
|
38
|
+
parameters: Type.Object({ query: Type.String({ description: "Search text" }) }),
|
|
39
|
+
async execute(params, http) {
|
|
40
|
+
const data = await http.json("/search", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Notion-Version": "2022-06-28" },
|
|
43
|
+
body: JSON.stringify({ query: params.query, page_size: 10 }),
|
|
44
|
+
});
|
|
45
|
+
const results = (data.results ?? []).map((r) => {
|
|
46
|
+
const title = r?.properties?.title?.title?.[0]?.plain_text ||
|
|
47
|
+
r?.properties?.Name?.title?.[0]?.plain_text ||
|
|
48
|
+
r?.title?.[0]?.plain_text ||
|
|
49
|
+
"(untitled)";
|
|
50
|
+
return `- ${title} [${r.object}] ${r.url ?? r.id}`;
|
|
51
|
+
});
|
|
52
|
+
return text(results.length ? results.join("\n") : "No matches.", { count: results.length });
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "notion_get_page",
|
|
57
|
+
label: "Notion: Read page",
|
|
58
|
+
description: "Read the block contents of a Notion page by id.",
|
|
59
|
+
parameters: Type.Object({ pageId: Type.String({ description: "Notion page id" }) }),
|
|
60
|
+
async execute(params, http) {
|
|
61
|
+
const data = await http.json(`/blocks/${encodeURIComponent(params.pageId)}/children?page_size=100`, {
|
|
62
|
+
headers: { "Notion-Version": "2022-06-28" },
|
|
63
|
+
});
|
|
64
|
+
const lines = (data.results ?? []).flatMap((b) => {
|
|
65
|
+
const rich = b?.[b.type]?.rich_text;
|
|
66
|
+
return Array.isArray(rich) ? rich.map((t) => t.plain_text).join("") : [];
|
|
67
|
+
});
|
|
68
|
+
return text(lines.filter(Boolean).join("\n") || "(no readable text blocks)");
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "google",
|
|
75
|
+
name: "Google (Gmail)",
|
|
76
|
+
description: "Search and send Gmail. The same connection covers other Google APIs (Docs, Drive).",
|
|
77
|
+
icon: "✉️",
|
|
78
|
+
docsUrl: "https://console.cloud.google.com/apis/credentials",
|
|
79
|
+
baseUrl: "https://gmail.googleapis.com/gmail/v1",
|
|
80
|
+
auth: {
|
|
81
|
+
kind: "oauth2",
|
|
82
|
+
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
83
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
84
|
+
clientIdEnv: "BIVY_GOOGLE_CLIENT_ID",
|
|
85
|
+
clientSecretEnv: "BIVY_GOOGLE_CLIENT_SECRET",
|
|
86
|
+
pkce: true,
|
|
87
|
+
// offline + consent so Google returns a refresh token.
|
|
88
|
+
extraAuthParams: { access_type: "offline", prompt: "consent" },
|
|
89
|
+
scopes: [
|
|
90
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
91
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
92
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
93
|
+
],
|
|
94
|
+
async accountLabel(http) {
|
|
95
|
+
try {
|
|
96
|
+
const me = await http.json("https://www.googleapis.com/oauth2/v2/userinfo");
|
|
97
|
+
return me?.email;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
tools: [
|
|
105
|
+
{
|
|
106
|
+
name: "gmail_search",
|
|
107
|
+
label: "Gmail: Search",
|
|
108
|
+
description: "Search Gmail messages and return subjects and snippets. Supports Gmail query syntax.",
|
|
109
|
+
parameters: Type.Object({
|
|
110
|
+
query: Type.String({ description: "Gmail search query, e.g. 'from:boss is:unread'" }),
|
|
111
|
+
max: Type.Optional(Type.Number({ description: "Max messages (default 5)" })),
|
|
112
|
+
}),
|
|
113
|
+
async execute(params, http) {
|
|
114
|
+
const max = Math.min(Math.max(Number(params.max) || 5, 1), 20);
|
|
115
|
+
const list = await http.json(`/users/me/messages?maxResults=${max}&q=${encodeURIComponent(params.query)}`);
|
|
116
|
+
const ids = (list.messages ?? []).map((m) => m.id);
|
|
117
|
+
const summaries = [];
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
const msg = await http.json(`/users/me/messages/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From`);
|
|
120
|
+
const headers = msg.payload?.headers ?? [];
|
|
121
|
+
const subject = headers.find((h) => h.name === "Subject")?.value ?? "(no subject)";
|
|
122
|
+
const from = headers.find((h) => h.name === "From")?.value ?? "";
|
|
123
|
+
summaries.push(`- ${subject} — ${from}\n ${msg.snippet ?? ""}`);
|
|
124
|
+
}
|
|
125
|
+
return text(summaries.length ? summaries.join("\n") : "No messages matched.", { count: summaries.length });
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "gmail_send",
|
|
130
|
+
label: "Gmail: Send",
|
|
131
|
+
description: "Send a plain-text email from the connected Gmail account.",
|
|
132
|
+
risky: true,
|
|
133
|
+
parameters: Type.Object({
|
|
134
|
+
to: Type.String({ description: "Recipient email address" }),
|
|
135
|
+
subject: Type.String(),
|
|
136
|
+
body: Type.String({ description: "Plain text body" }),
|
|
137
|
+
}),
|
|
138
|
+
async execute(params, http) {
|
|
139
|
+
const raw = toRfc822(params);
|
|
140
|
+
const res = await http.json("/users/me/messages/send", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ raw }),
|
|
144
|
+
});
|
|
145
|
+
return text(`Sent. Message id ${res.id}.`, { id: res.id });
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "dropbox",
|
|
152
|
+
name: "Dropbox",
|
|
153
|
+
description: "List and read files in your Dropbox.",
|
|
154
|
+
icon: "📦",
|
|
155
|
+
docsUrl: "https://www.dropbox.com/developers/apps",
|
|
156
|
+
baseUrl: "https://api.dropboxapi.com/2",
|
|
157
|
+
auth: {
|
|
158
|
+
kind: "oauth2",
|
|
159
|
+
authUrl: "https://www.dropbox.com/oauth2/authorize",
|
|
160
|
+
tokenUrl: "https://api.dropboxapi.com/oauth2/token",
|
|
161
|
+
clientIdEnv: "BIVY_DROPBOX_CLIENT_ID",
|
|
162
|
+
clientSecretEnv: "BIVY_DROPBOX_CLIENT_SECRET",
|
|
163
|
+
// offline so Dropbox returns a refresh token (its access tokens are short-lived).
|
|
164
|
+
extraAuthParams: { token_access_type: "offline" },
|
|
165
|
+
scopes: ["account_info.read", "files.metadata.read", "files.content.read"],
|
|
166
|
+
async accountLabel(http) {
|
|
167
|
+
try {
|
|
168
|
+
const acct = await http.json("/users/get_current_account", { method: "POST" });
|
|
169
|
+
return acct?.email || acct?.name?.display_name;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
tools: [
|
|
177
|
+
{
|
|
178
|
+
name: "dropbox_list_folder",
|
|
179
|
+
label: "Dropbox: List folder",
|
|
180
|
+
description: "List entries in a Dropbox folder. Use an empty path for the root.",
|
|
181
|
+
parameters: Type.Object({ path: Type.Optional(Type.String({ description: "Folder path, e.g. '/Documents'. Empty = root." })) }),
|
|
182
|
+
async execute(params, http) {
|
|
183
|
+
const data = await http.json("/files/list_folder", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "content-type": "application/json" },
|
|
186
|
+
body: JSON.stringify({ path: params.path ? params.path : "" }),
|
|
187
|
+
});
|
|
188
|
+
const entries = (data.entries ?? []).map((e) => `- [${e[".tag"]}] ${e.path_display}`);
|
|
189
|
+
return text(entries.length ? entries.join("\n") : "(empty)", { count: entries.length });
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "dropbox_read_file",
|
|
194
|
+
label: "Dropbox: Read file",
|
|
195
|
+
description: "Download a text file from Dropbox and return its contents.",
|
|
196
|
+
parameters: Type.Object({ path: Type.String({ description: "Full file path, e.g. '/notes.txt'" }) }),
|
|
197
|
+
async execute(params, http) {
|
|
198
|
+
const res = await http("https://content.dropboxapi.com/2/files/download", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "Dropbox-API-Arg": JSON.stringify({ path: params.path }) },
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok)
|
|
203
|
+
return { content: [{ type: "text", text: `Download failed (${res.status})` }], isError: true };
|
|
204
|
+
const body = await res.text();
|
|
205
|
+
return text(body.slice(0, 20000));
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "github",
|
|
212
|
+
name: "GitHub",
|
|
213
|
+
description: "Search issues/PRs and read repository files.",
|
|
214
|
+
icon: "🐙",
|
|
215
|
+
docsUrl: "https://github.com/settings/tokens",
|
|
216
|
+
baseUrl: "https://api.github.com",
|
|
217
|
+
auth: {
|
|
218
|
+
kind: "apiKey",
|
|
219
|
+
label: "GitHub personal access token",
|
|
220
|
+
help: "Create a fine-grained or classic token with repo read scope at github.com/settings/tokens.",
|
|
221
|
+
placeholder: "github_pat_…",
|
|
222
|
+
},
|
|
223
|
+
tools: [
|
|
224
|
+
{
|
|
225
|
+
name: "github_search_issues",
|
|
226
|
+
label: "GitHub: Search issues",
|
|
227
|
+
description: "Search issues and pull requests across GitHub using the issues search syntax.",
|
|
228
|
+
parameters: Type.Object({ query: Type.String({ description: "e.g. 'repo:owner/name is:open label:bug'" }) }),
|
|
229
|
+
async execute(params, http) {
|
|
230
|
+
const data = await http.json(`/search/issues?per_page=10&q=${encodeURIComponent(params.query)}`, {
|
|
231
|
+
headers: { accept: "application/vnd.github+json" },
|
|
232
|
+
});
|
|
233
|
+
const items = (data.items ?? []).map((i) => `- #${i.number} ${i.title} [${i.state}] ${i.html_url}`);
|
|
234
|
+
return text(items.length ? items.join("\n") : "No results.", { total: data.total_count });
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// Tiny persistent store for integration connections. Secrets (API keys, OAuth
|
|
6
|
+
// tokens) live here, so the file is written 0600 and kept out of any agent's
|
|
7
|
+
// config/session directory that ships with sessions.
|
|
8
|
+
export class IntegrationStore {
|
|
9
|
+
file;
|
|
10
|
+
cache = new Map();
|
|
11
|
+
constructor(appDir) {
|
|
12
|
+
this.file = path.join(appDir, "integrations.json");
|
|
13
|
+
this.load();
|
|
14
|
+
}
|
|
15
|
+
load() {
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
18
|
+
for (const [id, conn] of Object.entries(raw))
|
|
19
|
+
this.cache.set(id, conn);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Missing or unreadable: start empty.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
persist() {
|
|
26
|
+
const obj = {};
|
|
27
|
+
for (const [id, conn] of this.cache)
|
|
28
|
+
obj[id] = conn;
|
|
29
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
30
|
+
fs.writeFileSync(this.file, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
try {
|
|
32
|
+
fs.chmodSync(this.file, 0o600);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// best effort on platforms without chmod
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
get(id) {
|
|
39
|
+
return this.cache.get(id);
|
|
40
|
+
}
|
|
41
|
+
list() {
|
|
42
|
+
return [...this.cache.values()];
|
|
43
|
+
}
|
|
44
|
+
set(conn) {
|
|
45
|
+
this.cache.set(conn.id, conn);
|
|
46
|
+
this.persist();
|
|
47
|
+
}
|
|
48
|
+
remove(id) {
|
|
49
|
+
const had = this.cache.delete(id);
|
|
50
|
+
if (had)
|
|
51
|
+
this.persist();
|
|
52
|
+
return had;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|