@opencomputer/blue 0.0.6 → 0.0.9

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 CHANGED
@@ -130,6 +130,79 @@ When running this repository directly, substitute
130
130
  `../../node_modules/.bin/tsx ../../src/cli/index.ts` for `blue`. The installed
131
131
  package exposes the shorter `blue` binary.
132
132
 
133
+ ### Tools and connections
134
+
135
+ Tools are checked-in OpenCode functions under `agent/tools/`. Connections are
136
+ separate account authorizations: they can be replaced or revoked without
137
+ changing agent code, and credentials never become part of an agent artifact.
138
+
139
+ Add the built-in Gmail tools and their Google connection declaration:
140
+
141
+ ```bash
142
+ blue tools add gmail
143
+ ```
144
+
145
+ This creates `agent/tools/gmail.ts` with search, read, modify, and send tools,
146
+ plus `agent/connections/google.json` with the required scopes. Blue compiles
147
+ the tools into `.opencode/tools/`, so OpenCode discovers them and streams their
148
+ normal tool events.
149
+
150
+ Authorize a Google account for local development:
151
+
152
+ ```bash
153
+ blue connections connect gmail --local
154
+ blue dev
155
+ blue session "Find unread email that needs a reply"
156
+ ```
157
+
158
+ Local OAuth credentials are stored in the ignored
159
+ `.blue/connections.json` file with mode `0600`. By default the CLI uses the
160
+ platform's public Google OAuth client ID; set `BLUE_GOOGLE_CLIENT_ID` and
161
+ optionally `BLUE_GOOGLE_CLIENT_SECRET` to use your own desktop OAuth client
162
+ without contacting the hosted provider configuration. For a deployed agent:
163
+
164
+ ```bash
165
+ blue deploy
166
+ blue connections connect gmail --remote
167
+ blue connections list
168
+ ```
169
+
170
+ Remote credentials are AES-GCM encrypted in the account Durable Object. The
171
+ MicroVM receives only its existing session-scoped runtime token. Custom tools
172
+ use that token to call Blue's provider proxy, which restricts requests to the
173
+ provider's supported API origins and refreshes OAuth tokens in the control
174
+ plane.
175
+
176
+ #### Gmail summarizer example
177
+
178
+ The `examples/gmail-summarizer` agent is a safe starting point for inbox
179
+ triage. In Google Cloud, enable the Gmail API, configure the OAuth consent
180
+ screen with your account as a test user, and create an OAuth client with the
181
+ Desktop app type. Then run:
182
+
183
+ ```bash
184
+ cd examples/gmail-summarizer
185
+ export BLUE_GOOGLE_CLIENT_ID="<client-id>.apps.googleusercontent.com"
186
+ export BLUE_GOOGLE_CLIENT_SECRET="<client-secret>"
187
+ blue connections connect gmail --local
188
+ blue session \
189
+ "Summarize email from the last 24 hours. Group it into urgent, needs reply, informational, and low priority. Do not modify or send anything."
190
+ ```
191
+
192
+ The browser consent redirects to a temporary loopback listener owned by the
193
+ CLI. The resulting local credential remains in `.blue/connections.json`, which
194
+ is ignored by Git. Run `blue connections list --local` to inspect it and
195
+ `blue connections disconnect <connection-id> --local` to revoke it from Blue's
196
+ local state.
197
+
198
+ If Google reports `ACCESS_TOKEN_SCOPE_INSUFFICIENT`, open the OAuth app's
199
+ Google Auth Platform **Data Access** settings and add
200
+ `https://www.googleapis.com/auth/gmail.modify`. Confirm that the Gmail API is
201
+ enabled and that the connecting account is an allowed test user, remove the
202
+ old local connection, and reconnect while approving Gmail access. Blue
203
+ validates the granted scopes during connection and will not store an
204
+ identity-only token.
205
+
133
206
  ### Local Slack
134
207
 
135
208
  Install and authenticate the
@@ -220,7 +293,10 @@ The hosted Worker requires:
220
293
  `BLUE_WORKOS_JWKS_URL`;
221
294
  - `OPENROUTER_API_KEY` as a Worker secret;
222
295
  - `BLUE_CHANNEL_ENCRYPTION_KEY` as a Worker secret containing a base64-encoded
223
- 32-byte random key;
296
+ 32-byte random key; this currently protects channel and tool connection
297
+ credentials;
298
+ - `BLUE_GOOGLE_CLIENT_ID` and optionally `BLUE_GOOGLE_CLIENT_SECRET` for a
299
+ Google OAuth desktop client;
224
300
  - `BLUE_RUNTIME_IMAGE_ARN` and `BLUE_RUNTIME_IMAGE_VERSION`;
225
301
  - a `DEPLOYMENTS` service binding that returns signed platform artifact
226
302
  uploads;
@@ -241,6 +317,10 @@ npx wrangler secret put BLUE_RUNTIME_IMAGE_VERSION \
241
317
  --config wrangler.production.jsonc
242
318
  npx wrangler secret put BLUE_CHANNEL_ENCRYPTION_KEY \
243
319
  --config wrangler.production.jsonc
320
+ npx wrangler secret put BLUE_GOOGLE_CLIENT_ID \
321
+ --config wrangler.production.jsonc
322
+ npx wrangler secret put BLUE_GOOGLE_CLIENT_SECRET \
323
+ --config wrangler.production.jsonc
244
324
  ```
245
325
 
246
326
  Generate the channel encryption secret with `openssl rand -base64 32`. Every
@@ -248,6 +328,11 @@ remote Slack installation receives a unique callback credential, stored by
248
328
  Blue only as a hash. Slack CLI configures that callback automatically from the
249
329
  agent manifest.
250
330
 
331
+ Google local authorization uses a desktop loopback redirect with PKCE.
332
+ Gmail's `gmail.modify` scope is restricted, so a public production OAuth app
333
+ must complete Google's verification requirements. OAuth access and refresh
334
+ tokens are never returned by the Blue API after connection.
335
+
251
336
  Configure the platform Worker's AWS secrets plus
252
337
  `BLUE_AWS_REGION`, `BLUE_ARTIFACT_BUCKET`, `BLUE_EXECUTION_ROLE_ARN`,
253
338
  `BLUE_NETWORK_CONNECTOR_ARN`, `BLUE_S3_FILESYSTEM_ID`, and
package/dist/cli/index.js CHANGED
@@ -1,19 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
4
  import { existsSync, mkdirSync } from "node:fs";
4
5
  import { rm } from "node:fs/promises";
6
+ import { createServer } from "node:http";
5
7
  import { basename, dirname, resolve } from "node:path";
6
8
  import process from "node:process";
7
9
  import { createInterface } from "node:readline/promises";
8
10
  import { fileURLToPath } from "node:url";
9
- import { DEFAULT_BLUE_API_URL, accessTokenFor, login, logout, readCredentials, } from "../auth/cli-auth.js";
11
+ import { DEFAULT_BLUE_API_URL, accessTokenFor, login, logout, openBrowser, readCredentials, } from "../auth/cli-auth.js";
12
+ import { assertGoogleScopes, exchangeGoogleCode, GMAIL_SCOPES, googleAuthorizationUrl, resolveGoogleIdentity, } from "../connections/google.js";
13
+ import { readLocalConnections, writeLocalConnections, } from "../connections/local.js";
10
14
  import { captureLocalSlackState, localSlackStatePath, readLocalSlackState, readRemoteSlackState, remoteSlackStatePath, renderSlackProjectManifest, writeRemoteSlackState, } from "../channels/slack-project.js";
11
- import { addSlackChannel, findAgentRoot, initializeAgent, prepareAgent, } from "../core/agent.js";
15
+ import { addSlackChannel, addGmailTools, findAgentRoot, initializeAgent, prepareAgent, } from "../core/agent.js";
12
16
  import { buildAgentArtifact } from "../core/artifact.js";
13
17
  import { findDevState, requestDevManager, startDevManager, } from "../dev/manager.js";
14
18
  import { startLocalControlPlane } from "../dev/local-control-plane.js";
15
19
  import { authenticateOpenRouter, readOpenRouterCredential, } from "../openrouter/auth.js";
16
20
  import { agentLandingPath } from "../landing/agent.js";
21
+ import { renderConnectionCallback } from "../landing/connection-callback.js";
17
22
  import { BlueClient } from "../sdk/client.js";
18
23
  const DEFAULT_EDGE = DEFAULT_BLUE_API_URL;
19
24
  function argument(name) {
@@ -514,6 +519,7 @@ async function deployAgent(edge) {
514
519
  agentId: built.agentId,
515
520
  alias,
516
521
  channels: built.channels,
522
+ connections: built.connections,
517
523
  artifact: upload.artifact,
518
524
  });
519
525
  const identity = await client.whoami();
@@ -523,6 +529,7 @@ async function deployAgent(edge) {
523
529
  `alias: ${deployment.alias}\n` +
524
530
  `url: ${agentUrl}\n` +
525
531
  `channels: ${deployment.channels.join(", ") || "none"}\n` +
532
+ `connections: ${deployment.connections.join(", ") || "none"}\n` +
526
533
  `artifact: s3://${deployment.artifact.bucket}/${deployment.artifact.key}\n` +
527
534
  `ready in: ${String(built.elapsedMs)}ms\n`);
528
535
  }
@@ -558,6 +565,210 @@ async function agentRootForSlack() {
558
565
  }
559
566
  return root;
560
567
  }
568
+ async function currentAgentRoot() {
569
+ const root = await findAgentRoot(argument("agent-dir") ?? process.cwd());
570
+ if (!root) {
571
+ throw new Error("No Blue agent repository was found");
572
+ }
573
+ return root;
574
+ }
575
+ async function googleOAuthCallback(begin) {
576
+ const codeVerifier = randomBytes(32).toString("base64url");
577
+ const codeChallenge = createHash("sha256")
578
+ .update(codeVerifier)
579
+ .digest("base64url");
580
+ let expectedState = "";
581
+ let resolveCallback;
582
+ let rejectCallback;
583
+ const callback = new Promise((resolvePromise, reject) => {
584
+ resolveCallback = resolvePromise;
585
+ rejectCallback = reject;
586
+ });
587
+ const server = createServer((request, response) => {
588
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
589
+ if (url.pathname !== "/oauth/callback") {
590
+ response.writeHead(404).end();
591
+ return;
592
+ }
593
+ const error = url.searchParams.get("error");
594
+ const code = url.searchParams.get("code");
595
+ const state = url.searchParams.get("state");
596
+ response.writeHead(error || !code || state !== expectedState ? 400 : 200, {
597
+ "content-type": "text/html; charset=utf-8",
598
+ });
599
+ response.end(error || !code || state !== expectedState
600
+ ? renderConnectionCallback({
601
+ provider: "Google",
602
+ status: "error",
603
+ })
604
+ : renderConnectionCallback({
605
+ provider: "Google",
606
+ status: "success",
607
+ }));
608
+ if (error) {
609
+ rejectCallback(new Error(`Google authorization failed: ${error}`));
610
+ }
611
+ else if (!code || !state || state !== expectedState) {
612
+ rejectCallback(new Error("Google returned an invalid OAuth callback"));
613
+ }
614
+ else {
615
+ resolveCallback({ code, state });
616
+ }
617
+ });
618
+ await new Promise((resolvePromise, reject) => {
619
+ server.once("error", reject);
620
+ server.listen(0, "127.0.0.1", () => {
621
+ server.off("error", reject);
622
+ resolvePromise();
623
+ });
624
+ });
625
+ const address = server.address();
626
+ if (!address || typeof address === "string") {
627
+ server.close();
628
+ throw new Error("Could not create the local OAuth callback");
629
+ }
630
+ const redirectUri = `http://127.0.0.1:${String(address.port)}/oauth/callback`;
631
+ try {
632
+ const started = await begin(redirectUri, codeChallenge);
633
+ expectedState = started.state;
634
+ process.stdout.write(`Open ${started.authorizationUrl}\n`);
635
+ openBrowser(started.authorizationUrl);
636
+ const timeout = new Promise((_resolve, reject) => {
637
+ setTimeout(() => reject(new Error("Google authorization timed out")), 5 * 60_000).unref();
638
+ });
639
+ const result = await Promise.race([callback, timeout]);
640
+ return { ...result, codeVerifier, redirectUri };
641
+ }
642
+ finally {
643
+ await new Promise((resolvePromise) => server.close(() => resolvePromise()));
644
+ }
645
+ }
646
+ async function connectGoogleLocal(edge) {
647
+ const root = await currentAgentRoot();
648
+ const clientId = process.env.BLUE_GOOGLE_CLIENT_ID ??
649
+ (await (await clientFor(edge)).connectionProvider("google")).clientId;
650
+ const config = {
651
+ clientId,
652
+ clientSecret: process.env.BLUE_GOOGLE_CLIENT_SECRET,
653
+ };
654
+ const state = randomBytes(24).toString("base64url");
655
+ const callback = await googleOAuthCallback(async (redirectUri, codeChallenge) => ({
656
+ state,
657
+ authorizationUrl: googleAuthorizationUrl({
658
+ config,
659
+ redirectUri,
660
+ scopes: [...GMAIL_SCOPES],
661
+ state,
662
+ codeChallenge,
663
+ }),
664
+ }));
665
+ const credential = await exchangeGoogleCode({
666
+ config,
667
+ code: callback.code,
668
+ codeVerifier: callback.codeVerifier,
669
+ redirectUri: callback.redirectUri,
670
+ });
671
+ assertGoogleScopes(credential, [...GMAIL_SCOPES]);
672
+ const identity = await resolveGoogleIdentity(credential);
673
+ const path = resolve(root, ".blue", "connections.json");
674
+ const connections = await readLocalConnections(path);
675
+ const label = argument("label") ?? "default";
676
+ const timestamp = new Date().toISOString();
677
+ const next = {
678
+ id: randomUUID(),
679
+ provider: "google",
680
+ label,
681
+ externalAccountId: identity.id,
682
+ displayName: identity.email,
683
+ scopes: credential.scopes.length
684
+ ? credential.scopes
685
+ : [...GMAIL_SCOPES],
686
+ clientId,
687
+ clientSecret: config.clientSecret,
688
+ credential,
689
+ createdAt: timestamp,
690
+ updatedAt: timestamp,
691
+ };
692
+ await writeLocalConnections(path, [
693
+ next,
694
+ ...connections.filter((connection) => connection.provider !== "google" || connection.label !== label),
695
+ ]);
696
+ process.stdout.write(`connected: local\nprovider: google\naccount: ${identity.email}\nlabel: ${label}\n`);
697
+ }
698
+ async function connectGoogleRemote(edge) {
699
+ await currentAgentRoot();
700
+ const agentId = argument("agent") ?? (await inferDeployedAgent());
701
+ if (!agentId) {
702
+ throw new Error("Run this inside an agent repository or pass --agent");
703
+ }
704
+ const client = await clientFor(edge);
705
+ let connectionId;
706
+ try {
707
+ const callback = await googleOAuthCallback(async (redirectUri, codeChallenge) => {
708
+ const started = await client.startConnection({
709
+ provider: "google",
710
+ agentId,
711
+ label: argument("label") ?? "default",
712
+ scopes: [...GMAIL_SCOPES],
713
+ redirectUri,
714
+ codeChallenge,
715
+ });
716
+ connectionId = started.connection.id;
717
+ return {
718
+ authorizationUrl: started.authorizationUrl,
719
+ state: started.state,
720
+ };
721
+ });
722
+ if (!connectionId)
723
+ throw new Error("Connection was not reserved");
724
+ const connection = await client.completeConnection(connectionId, callback);
725
+ process.stdout.write(`connected: remote\nconnection: ${connection.id}\n` +
726
+ `provider: ${connection.provider}\n` +
727
+ `account: ${connection.displayName ?? connection.externalAccountId ?? "unknown"}\n` +
728
+ `agent: ${connection.agentId}@${connection.alias}\n` +
729
+ `label: ${connection.label}\n`);
730
+ }
731
+ catch (error) {
732
+ if (connectionId) {
733
+ await client.disconnectConnection(connectionId).catch(() => undefined);
734
+ }
735
+ throw error;
736
+ }
737
+ }
738
+ async function listConnections(edge) {
739
+ let found = false;
740
+ const root = await findAgentRoot(process.cwd());
741
+ if (!hasFlag("remote") && root) {
742
+ const local = await readLocalConnections(resolve(root, ".blue", "connections.json"));
743
+ for (const connection of local) {
744
+ found = true;
745
+ process.stdout.write(`${connection.id} ${connection.provider.padEnd(10)} local ` +
746
+ `${connection.label.padEnd(12)} ${connection.displayName}\n`);
747
+ }
748
+ }
749
+ if (!hasFlag("local")) {
750
+ const remote = await (await clientFor(edge)).listConnections();
751
+ for (const connection of remote) {
752
+ found = true;
753
+ process.stdout.write(`${connection.id} ${connection.provider.padEnd(10)} ` +
754
+ `${connection.status.padEnd(11)} ${connection.label.padEnd(12)} ` +
755
+ `${connection.displayName ?? connection.externalAccountId ?? "pending"}\n`);
756
+ }
757
+ }
758
+ if (!found)
759
+ process.stdout.write("No connections.\n");
760
+ }
761
+ async function disconnectLocalConnection(connectionId) {
762
+ const root = await currentAgentRoot();
763
+ const path = resolve(root, ".blue", "connections.json");
764
+ const connections = await readLocalConnections(path);
765
+ const next = connections.filter((connection) => connection.id !== connectionId);
766
+ if (next.length === connections.length) {
767
+ throw new Error("Local connection not found");
768
+ }
769
+ await writeLocalConnections(path, next);
770
+ process.stdout.write(`disconnected: ${connectionId}\n`);
771
+ }
561
772
  async function connectLocalSlack() {
562
773
  const root = await agentRootForSlack();
563
774
  await runSlackCli(root, ["app", "install", "--environment", "local"], "local");
@@ -736,7 +947,9 @@ async function dev() {
736
947
  port,
737
948
  agentId: prepared.name,
738
949
  slackEnabled: builtInSlackChannel(prepared.root),
950
+ googleEnabled: existsSync(resolve(prepared.root, "agent", "connections", "google.json")),
739
951
  statePath: resolve(prepared.root, ".blue", "dev", "control-plane.json"),
952
+ connectionStatePath: resolve(prepared.root, ".blue", "connections.json"),
740
953
  openRouterApiKey: process.env.OPENROUTER_API_KEY,
741
954
  model: process.env.BLUE_MODEL,
742
955
  });
@@ -809,6 +1022,12 @@ Usage:
809
1022
  blue demo [prompt] [--agent-dir path] [--harness mock|opencode]
810
1023
  blue run <prompt> [--agent <agent>@<alias>]
811
1024
  blue auth openrouter
1025
+ blue tools add gmail [--agent-dir path]
1026
+ blue connections connect gmail --local
1027
+ blue connections connect gmail --remote [--agent agent@alias]
1028
+ blue connections list [--local|--remote]
1029
+ blue connections disconnect <connection-id> --local
1030
+ blue connections disconnect <connection-id> --remote
812
1031
  blue channels add slack [--agent-dir path]
813
1032
  blue channels connect slack --local
814
1033
  blue channels connect slack --remote [--agent agent@alias]
@@ -910,6 +1129,48 @@ async function main() {
910
1129
  process.stdout.write("OpenRouter connected locally.\n");
911
1130
  return;
912
1131
  }
1132
+ if (command === "tools") {
1133
+ const [action, toolName] = positional(3);
1134
+ if (action === "add" && toolName === "gmail") {
1135
+ process.stdout.write(`created: ${await addGmailTools(argument("agent-dir") ?? process.cwd())}\n`);
1136
+ return;
1137
+ }
1138
+ throw new Error("Usage: blue tools add gmail");
1139
+ }
1140
+ if (command === "connections") {
1141
+ const [action, provider, connectionId] = positional(3);
1142
+ if (action === "connect" && (provider === "gmail" || provider === "google")) {
1143
+ if (hasFlag("local") && hasFlag("remote")) {
1144
+ throw new Error("Choose either --local or --remote");
1145
+ }
1146
+ if (!hasFlag("local") && !hasFlag("remote")) {
1147
+ throw new Error("Pass --local or --remote");
1148
+ }
1149
+ if (hasFlag("local")) {
1150
+ await connectGoogleLocal(hostedEdge());
1151
+ }
1152
+ else {
1153
+ await connectGoogleRemote(hostedEdge());
1154
+ }
1155
+ return;
1156
+ }
1157
+ if (action === "list") {
1158
+ await listConnections(hostedEdge());
1159
+ return;
1160
+ }
1161
+ if (action === "disconnect" && provider) {
1162
+ if (hasFlag("local")) {
1163
+ await disconnectLocalConnection(provider);
1164
+ }
1165
+ else {
1166
+ const connection = await (await clientFor(hostedEdge())).disconnectConnection(provider);
1167
+ process.stdout.write(`disconnected: ${connection.id}\n`);
1168
+ }
1169
+ return;
1170
+ }
1171
+ void connectionId;
1172
+ throw new Error("Unsupported connections command");
1173
+ }
913
1174
  if (command === "channels") {
914
1175
  const [action, channel, connectionId] = positional(3);
915
1176
  if (action === "add" && channel === "slack") {