@opencomputer/blue 0.0.5 → 0.0.7

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,49 @@ 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
+
133
176
  ### Local Slack
134
177
 
135
178
  Install and authenticate the
@@ -220,7 +263,10 @@ The hosted Worker requires:
220
263
  `BLUE_WORKOS_JWKS_URL`;
221
264
  - `OPENROUTER_API_KEY` as a Worker secret;
222
265
  - `BLUE_CHANNEL_ENCRYPTION_KEY` as a Worker secret containing a base64-encoded
223
- 32-byte random key;
266
+ 32-byte random key; this currently protects channel and tool connection
267
+ credentials;
268
+ - `BLUE_GOOGLE_CLIENT_ID` and optionally `BLUE_GOOGLE_CLIENT_SECRET` for a
269
+ Google OAuth desktop client;
224
270
  - `BLUE_RUNTIME_IMAGE_ARN` and `BLUE_RUNTIME_IMAGE_VERSION`;
225
271
  - a `DEPLOYMENTS` service binding that returns signed platform artifact
226
272
  uploads;
@@ -241,6 +287,10 @@ npx wrangler secret put BLUE_RUNTIME_IMAGE_VERSION \
241
287
  --config wrangler.production.jsonc
242
288
  npx wrangler secret put BLUE_CHANNEL_ENCRYPTION_KEY \
243
289
  --config wrangler.production.jsonc
290
+ npx wrangler secret put BLUE_GOOGLE_CLIENT_ID \
291
+ --config wrangler.production.jsonc
292
+ npx wrangler secret put BLUE_GOOGLE_CLIENT_SECRET \
293
+ --config wrangler.production.jsonc
244
294
  ```
245
295
 
246
296
  Generate the channel encryption secret with `openssl rand -base64 32`. Every
@@ -248,6 +298,11 @@ remote Slack installation receives a unique callback credential, stored by
248
298
  Blue only as a hash. Slack CLI configures that callback automatically from the
249
299
  agent manifest.
250
300
 
301
+ Google local authorization uses a desktop loopback redirect with PKCE.
302
+ Gmail's `gmail.modify` scope is restricted, so a public production OAuth app
303
+ must complete Google's verification requirements. OAuth access and refresh
304
+ tokens are never returned by the Blue API after connection.
305
+
251
306
  Configure the platform Worker's AWS secrets plus
252
307
  `BLUE_AWS_REGION`, `BLUE_ARTIFACT_BUCKET`, `BLUE_EXECUTION_ROLE_ARN`,
253
308
  `BLUE_NETWORK_CONNECTOR_ARN`, `BLUE_S3_FILESYSTEM_ID`, and
package/dist/cli/index.js CHANGED
@@ -1,14 +1,18 @@
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 { 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";
@@ -514,6 +518,7 @@ async function deployAgent(edge) {
514
518
  agentId: built.agentId,
515
519
  alias,
516
520
  channels: built.channels,
521
+ connections: built.connections,
517
522
  artifact: upload.artifact,
518
523
  });
519
524
  const identity = await client.whoami();
@@ -523,6 +528,7 @@ async function deployAgent(edge) {
523
528
  `alias: ${deployment.alias}\n` +
524
529
  `url: ${agentUrl}\n` +
525
530
  `channels: ${deployment.channels.join(", ") || "none"}\n` +
531
+ `connections: ${deployment.connections.join(", ") || "none"}\n` +
526
532
  `artifact: s3://${deployment.artifact.bucket}/${deployment.artifact.key}\n` +
527
533
  `ready in: ${String(built.elapsedMs)}ms\n`);
528
534
  }
@@ -558,6 +564,203 @@ async function agentRootForSlack() {
558
564
  }
559
565
  return root;
560
566
  }
567
+ async function currentAgentRoot() {
568
+ const root = await findAgentRoot(argument("agent-dir") ?? process.cwd());
569
+ if (!root) {
570
+ throw new Error("No Blue agent repository was found");
571
+ }
572
+ return root;
573
+ }
574
+ async function googleOAuthCallback(begin) {
575
+ const codeVerifier = randomBytes(32).toString("base64url");
576
+ const codeChallenge = createHash("sha256")
577
+ .update(codeVerifier)
578
+ .digest("base64url");
579
+ let expectedState = "";
580
+ let resolveCallback;
581
+ let rejectCallback;
582
+ const callback = new Promise((resolvePromise, reject) => {
583
+ resolveCallback = resolvePromise;
584
+ rejectCallback = reject;
585
+ });
586
+ const server = createServer((request, response) => {
587
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
588
+ if (url.pathname !== "/oauth/callback") {
589
+ response.writeHead(404).end();
590
+ return;
591
+ }
592
+ const error = url.searchParams.get("error");
593
+ const code = url.searchParams.get("code");
594
+ const state = url.searchParams.get("state");
595
+ response.writeHead(error || !code || state !== expectedState ? 400 : 200, {
596
+ "content-type": "text/html; charset=utf-8",
597
+ });
598
+ response.end(error || !code || state !== expectedState
599
+ ? "<h1>Connection failed</h1><p>Return to the Blue CLI.</p>"
600
+ : "<h1>Connected</h1><p>You can close this tab.</p>");
601
+ if (error) {
602
+ rejectCallback(new Error(`Google authorization failed: ${error}`));
603
+ }
604
+ else if (!code || !state || state !== expectedState) {
605
+ rejectCallback(new Error("Google returned an invalid OAuth callback"));
606
+ }
607
+ else {
608
+ resolveCallback({ code, state });
609
+ }
610
+ });
611
+ await new Promise((resolvePromise, reject) => {
612
+ server.once("error", reject);
613
+ server.listen(0, "127.0.0.1", () => {
614
+ server.off("error", reject);
615
+ resolvePromise();
616
+ });
617
+ });
618
+ const address = server.address();
619
+ if (!address || typeof address === "string") {
620
+ server.close();
621
+ throw new Error("Could not create the local OAuth callback");
622
+ }
623
+ const redirectUri = `http://127.0.0.1:${String(address.port)}/oauth/callback`;
624
+ try {
625
+ const started = await begin(redirectUri, codeChallenge);
626
+ expectedState = started.state;
627
+ process.stdout.write(`Open ${started.authorizationUrl}\n`);
628
+ openBrowser(started.authorizationUrl);
629
+ const timeout = new Promise((_resolve, reject) => {
630
+ setTimeout(() => reject(new Error("Google authorization timed out")), 5 * 60_000).unref();
631
+ });
632
+ const result = await Promise.race([callback, timeout]);
633
+ return { ...result, codeVerifier, redirectUri };
634
+ }
635
+ finally {
636
+ await new Promise((resolvePromise) => server.close(() => resolvePromise()));
637
+ }
638
+ }
639
+ async function connectGoogleLocal(edge) {
640
+ const root = await currentAgentRoot();
641
+ const clientId = process.env.BLUE_GOOGLE_CLIENT_ID ??
642
+ (await (await clientFor(edge)).connectionProvider("google")).clientId;
643
+ const config = {
644
+ clientId,
645
+ clientSecret: process.env.BLUE_GOOGLE_CLIENT_SECRET,
646
+ };
647
+ const state = randomBytes(24).toString("base64url");
648
+ const callback = await googleOAuthCallback(async (redirectUri, codeChallenge) => ({
649
+ state,
650
+ authorizationUrl: googleAuthorizationUrl({
651
+ config,
652
+ redirectUri,
653
+ scopes: [...GMAIL_SCOPES],
654
+ state,
655
+ codeChallenge,
656
+ }),
657
+ }));
658
+ const credential = await exchangeGoogleCode({
659
+ config,
660
+ code: callback.code,
661
+ codeVerifier: callback.codeVerifier,
662
+ redirectUri: callback.redirectUri,
663
+ });
664
+ const identity = await resolveGoogleIdentity(credential);
665
+ const path = resolve(root, ".blue", "connections.json");
666
+ const connections = await readLocalConnections(path);
667
+ const label = argument("label") ?? "default";
668
+ const timestamp = new Date().toISOString();
669
+ const next = {
670
+ id: randomUUID(),
671
+ provider: "google",
672
+ label,
673
+ externalAccountId: identity.id,
674
+ displayName: identity.email,
675
+ scopes: credential.scopes.length
676
+ ? credential.scopes
677
+ : [...GMAIL_SCOPES],
678
+ clientId,
679
+ clientSecret: config.clientSecret,
680
+ credential,
681
+ createdAt: timestamp,
682
+ updatedAt: timestamp,
683
+ };
684
+ await writeLocalConnections(path, [
685
+ next,
686
+ ...connections.filter((connection) => connection.provider !== "google" || connection.label !== label),
687
+ ]);
688
+ process.stdout.write(`connected: local\nprovider: google\naccount: ${identity.email}\nlabel: ${label}\n`);
689
+ }
690
+ async function connectGoogleRemote(edge) {
691
+ await currentAgentRoot();
692
+ const agentId = argument("agent") ?? (await inferDeployedAgent());
693
+ if (!agentId) {
694
+ throw new Error("Run this inside an agent repository or pass --agent");
695
+ }
696
+ const client = await clientFor(edge);
697
+ let connectionId;
698
+ try {
699
+ const callback = await googleOAuthCallback(async (redirectUri, codeChallenge) => {
700
+ const started = await client.startConnection({
701
+ provider: "google",
702
+ agentId,
703
+ label: argument("label") ?? "default",
704
+ scopes: [...GMAIL_SCOPES],
705
+ redirectUri,
706
+ codeChallenge,
707
+ });
708
+ connectionId = started.connection.id;
709
+ return {
710
+ authorizationUrl: started.authorizationUrl,
711
+ state: started.state,
712
+ };
713
+ });
714
+ if (!connectionId)
715
+ throw new Error("Connection was not reserved");
716
+ const connection = await client.completeConnection(connectionId, callback);
717
+ process.stdout.write(`connected: remote\nconnection: ${connection.id}\n` +
718
+ `provider: ${connection.provider}\n` +
719
+ `account: ${connection.displayName ?? connection.externalAccountId ?? "unknown"}\n` +
720
+ `agent: ${connection.agentId}@${connection.alias}\n` +
721
+ `label: ${connection.label}\n`);
722
+ }
723
+ catch (error) {
724
+ if (connectionId) {
725
+ await client.disconnectConnection(connectionId).catch(() => undefined);
726
+ }
727
+ throw error;
728
+ }
729
+ }
730
+ async function listConnections(edge) {
731
+ let found = false;
732
+ const root = await findAgentRoot(process.cwd());
733
+ if (!hasFlag("remote") && root) {
734
+ const local = await readLocalConnections(resolve(root, ".blue", "connections.json"));
735
+ for (const connection of local) {
736
+ found = true;
737
+ process.stdout.write(`${connection.id} ${connection.provider.padEnd(10)} local ` +
738
+ `${connection.label.padEnd(12)} ${connection.displayName}\n`);
739
+ }
740
+ }
741
+ if (!hasFlag("local")) {
742
+ const remote = await (await clientFor(edge)).listConnections();
743
+ for (const connection of remote) {
744
+ found = true;
745
+ process.stdout.write(`${connection.id} ${connection.provider.padEnd(10)} ` +
746
+ `${connection.status.padEnd(11)} ${connection.label.padEnd(12)} ` +
747
+ `${connection.displayName ?? connection.externalAccountId ?? "pending"}\n`);
748
+ }
749
+ }
750
+ if (!found)
751
+ process.stdout.write("No connections.\n");
752
+ }
753
+ async function disconnectLocalConnection(connectionId) {
754
+ const root = await currentAgentRoot();
755
+ const path = resolve(root, ".blue", "connections.json");
756
+ const connections = await readLocalConnections(path);
757
+ const next = connections.filter((connection) => connection.id !== connectionId);
758
+ if (next.length === connections.length) {
759
+ throw new Error("Local connection not found");
760
+ }
761
+ await writeLocalConnections(path, next);
762
+ process.stdout.write(`disconnected: ${connectionId}\n`);
763
+ }
561
764
  async function connectLocalSlack() {
562
765
  const root = await agentRootForSlack();
563
766
  await runSlackCli(root, ["app", "install", "--environment", "local"], "local");
@@ -573,27 +776,44 @@ async function connectRemoteSlack(edge) {
573
776
  if (!agentId) {
574
777
  throw new Error("Run this inside an agent repository or pass --agent");
575
778
  }
576
- const result = await (await clientFor(edge)).createSlackConnection({
779
+ const client = await clientFor(edge);
780
+ const previous = await readRemoteSlackState(root);
781
+ const result = await client.createSlackConnection({
577
782
  agentId,
578
783
  });
579
- await writeRemoteSlackState(root, {
784
+ const nextState = {
580
785
  version: 1,
581
786
  connectionId: result.connection.id,
582
787
  agentId: `${result.connection.agentId}@${result.connection.alias}`,
583
788
  webhookUrl: result.webhookUrl,
584
789
  apiUrl: edge,
585
- });
586
- await runSlackCli(root, ["deploy", "--app", "deployed"], "remote");
587
- const connected = (await (await clientFor(edge)).listChannelConnections())
588
- .find((connection) => connection.id === result.connection.id);
589
- if (!connected || connected.status !== "connected") {
590
- throw new Error("Slack CLI finished without completing the connection");
591
- }
592
- process.stdout.write(`connected: remote\n` +
593
- `connection: ${connected.id}\n` +
594
- `agent: ${connected.agentId}@${connected.alias}\n` +
595
- `app: ${connected.appId ?? "unknown"}\n` +
596
- `workspace: ${connected.teamName ?? connected.teamId ?? "unknown"}\n`);
790
+ };
791
+ await writeRemoteSlackState(root, nextState);
792
+ try {
793
+ await runSlackCli(root, ["deploy", "--app", "deployed"], "remote");
794
+ const connected = (await client.listChannelConnections()).find((connection) => connection.id === result.connection.id);
795
+ if (!connected || connected.status !== "connected") {
796
+ throw new Error("Slack CLI finished without completing the connection");
797
+ }
798
+ if (previous && previous.connectionId !== connected.id) {
799
+ await client.disconnectSlack(previous.connectionId).catch(() => undefined);
800
+ }
801
+ process.stdout.write(`connected: remote\n` +
802
+ `connection: ${connected.id}\n` +
803
+ `agent: ${connected.agentId}@${connected.alias}\n` +
804
+ `app: ${connected.appId ?? "unknown"}\n` +
805
+ `workspace: ${connected.teamName ?? connected.teamId ?? "unknown"}\n`);
806
+ }
807
+ catch (error) {
808
+ await client.disconnectSlack(result.connection.id).catch(() => undefined);
809
+ if (previous) {
810
+ await writeRemoteSlackState(root, previous);
811
+ }
812
+ else {
813
+ await rm(remoteSlackStatePath(root), { force: true });
814
+ }
815
+ throw error;
816
+ }
597
817
  }
598
818
  async function listRemoteChannels(edge) {
599
819
  let found = false;
@@ -719,7 +939,9 @@ async function dev() {
719
939
  port,
720
940
  agentId: prepared.name,
721
941
  slackEnabled: builtInSlackChannel(prepared.root),
942
+ googleEnabled: existsSync(resolve(prepared.root, "agent", "connections", "google.json")),
722
943
  statePath: resolve(prepared.root, ".blue", "dev", "control-plane.json"),
944
+ connectionStatePath: resolve(prepared.root, ".blue", "connections.json"),
723
945
  openRouterApiKey: process.env.OPENROUTER_API_KEY,
724
946
  model: process.env.BLUE_MODEL,
725
947
  });
@@ -792,6 +1014,12 @@ Usage:
792
1014
  blue demo [prompt] [--agent-dir path] [--harness mock|opencode]
793
1015
  blue run <prompt> [--agent <agent>@<alias>]
794
1016
  blue auth openrouter
1017
+ blue tools add gmail [--agent-dir path]
1018
+ blue connections connect gmail --local
1019
+ blue connections connect gmail --remote [--agent agent@alias]
1020
+ blue connections list [--local|--remote]
1021
+ blue connections disconnect <connection-id> --local
1022
+ blue connections disconnect <connection-id> --remote
795
1023
  blue channels add slack [--agent-dir path]
796
1024
  blue channels connect slack --local
797
1025
  blue channels connect slack --remote [--agent agent@alias]
@@ -893,6 +1121,48 @@ async function main() {
893
1121
  process.stdout.write("OpenRouter connected locally.\n");
894
1122
  return;
895
1123
  }
1124
+ if (command === "tools") {
1125
+ const [action, toolName] = positional(3);
1126
+ if (action === "add" && toolName === "gmail") {
1127
+ process.stdout.write(`created: ${await addGmailTools(argument("agent-dir") ?? process.cwd())}\n`);
1128
+ return;
1129
+ }
1130
+ throw new Error("Usage: blue tools add gmail");
1131
+ }
1132
+ if (command === "connections") {
1133
+ const [action, provider, connectionId] = positional(3);
1134
+ if (action === "connect" && (provider === "gmail" || provider === "google")) {
1135
+ if (hasFlag("local") && hasFlag("remote")) {
1136
+ throw new Error("Choose either --local or --remote");
1137
+ }
1138
+ if (!hasFlag("local") && !hasFlag("remote")) {
1139
+ throw new Error("Pass --local or --remote");
1140
+ }
1141
+ if (hasFlag("local")) {
1142
+ await connectGoogleLocal(hostedEdge());
1143
+ }
1144
+ else {
1145
+ await connectGoogleRemote(hostedEdge());
1146
+ }
1147
+ return;
1148
+ }
1149
+ if (action === "list") {
1150
+ await listConnections(hostedEdge());
1151
+ return;
1152
+ }
1153
+ if (action === "disconnect" && provider) {
1154
+ if (hasFlag("local")) {
1155
+ await disconnectLocalConnection(provider);
1156
+ }
1157
+ else {
1158
+ const connection = await (await clientFor(hostedEdge())).disconnectConnection(provider);
1159
+ process.stdout.write(`disconnected: ${connection.id}\n`);
1160
+ }
1161
+ return;
1162
+ }
1163
+ void connectionId;
1164
+ throw new Error("Unsupported connections command");
1165
+ }
896
1166
  if (command === "channels") {
897
1167
  const [action, channel, connectionId] = positional(3);
898
1168
  if (action === "add" && channel === "slack") {