From fb648e430f7b8c0bf07cb26508bbfe85004c69cc Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Mon, 14 Sep 2026 20:08:17 -0700 Subject: [PATCH 1/2] sdk: callService unwraps the proxy's envelope instead of returning it, and adds listServices and useService so an agent can discover and declare the managed connections it uses: --- agent/src/index.ts | 217 ++++++++++++++++++++++++++++++++++++++++ cli/src/project.test.ts | 149 +++++++++++++++++++++++++++ cli/src/project.ts | 99 +++++++++++++++++- 3 files changed, 464 insertions(+), 1 deletion(-) diff --git a/agent/src/index.ts b/agent/src/index.ts index 54c3e0bc..9ee95f92 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -387,6 +387,9 @@ interface AgentHooks { useSubagent(agent: string | ResourceReference): void; useSessionData(key: string): T | undefined; useMcpServer(server: string | ResourceReference): void; + /** Optional: the declaration is extracted at build time, so a host + * that does nothing at run time is still correct. */ + useService?(service: string): void; useMemory(memory: string): MemoryProjection | undefined; } @@ -430,6 +433,205 @@ export function bearer(secret: SecretReference): SecretHeaderReference { return secretHeader(secret, { prefix: "Bearer " }); } +/** + * A service the platform holds an OAuth credential for. + * + * `defineConnection` covers the case where WE hold the secret: the egress proxy + * attaches a managed secret to a declared origin. It cannot express an OAuth + * integration, because the credential is short-lived, per-person, and has to be + * refreshed — which is why the runtime is forbidden from setting `Authorization` + * on a declared connection at all. + * + * This is the other half, and the platform already implements it: the request + * names a service and a mailbox rather than a URL and a header, and the + * credential is resolved, refreshed and attached on the way out. The agent never + * sees a token, which is the same guarantee, reached differently. + */ +export type ManagedService = + | "gmail" + | "calendar" + | "drive" + | "sheets" + | "github"; + +export interface ServiceRequest { + /** Which service. `google` is accepted as an alias for `gmail`. */ + service: ManagedService | string; + /** + * Which connected account, when a user has more than one. This is the label + * the connection was linked under — an agent sweeping two mailboxes asks for + * each by name rather than hoping the right one is first. + */ + label?: string; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Path on the service, e.g. `/gmail/v1/users/me/messages`. */ + path: string; + headers?: Readonly>; + body?: string; + signal?: AbortSignal; +} + +/** + * Call a service the platform is connected to on this session's behalf. + * + * Returns the upstream response, so a caller reads status and body exactly as + * it would from `fetch` — a 404 from the service arrives as a 404, not as an + * exception that has lost the distinction. + * + * The transport does not make that free. A managed connection answers with an + * envelope — `{status, headers, body}`, the body a string — wrapped in a 200, + * because the proxy has to report its own failures separately from the + * service's. Handing that to a caller means every one of them reinvents the + * unwrapping, and the ones that forget see `ok` on a request that failed. So + * the envelope is opened here and a real Response is rebuilt from it. A + * non-2xx from the proxy itself is passed through untouched: that is the + * platform failing, not the service. + */ +export async function callService(request: ServiceRequest): Promise { + const runtime = globalThis as typeof globalThis & { + process?: { env?: Record }; + }; + const base = runtime.process?.env?.OPENCOMPUTER_CONNECTIONS_URL; + const token = runtime.process?.env?.OPENCOMPUTER_CONNECTION_TOKEN; + if (!base || !token) { + throw new Error("OpenComputer managed connections are unavailable"); + } + if (!request.path.startsWith("/")) { + throw new Error("Service requests require an absolute path"); + } + const service = request.service.trim().toLowerCase(); + if (!service) throw new Error("A service request needs a service"); + // The provider segment routes the supervisor; the service in the body is what + // the platform resolves a credential for. GitHub and Google are separate + // providers with separate grants, so the two cannot be collapsed. + const provider = service === "github" ? "github" : "google"; + const response = await fetch(`${base.replace(/\/$/, "")}/${provider}/fetch`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + service, + ...(request.label ? { label: request.label } : {}), + method: (request.method ?? "GET").toUpperCase(), + path: request.path, + ...(request.headers ? { headers: request.headers } : {}), + ...(request.body === undefined ? {} : { body: request.body }), + }), + ...(request.signal ? { signal: request.signal } : {}), + }); + return unwrapServiceResponse(response); +} + +/** + * Rebuild the upstream response from the proxy's envelope. + * + * Anything that is not a well-formed envelope is returned as it arrived — + * a proxy error, or a future shape this does not recognise, should reach the + * caller rather than be flattened into a confusing success. + */ +async function unwrapServiceResponse(response: Response): Promise { + if (!response.ok) return response; + const envelope = (await response.clone().json().catch(() => null)) as { + status?: unknown; + headers?: unknown; + body?: unknown; + } | null; + if ( + !envelope || + typeof envelope.status !== "number" || + typeof envelope.body !== "string" + ) { + return response; + } + const headers = + envelope.headers && typeof envelope.headers === "object" + ? Object.fromEntries( + Object.entries(envelope.headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : {}; + return new Response(envelope.body, { status: envelope.status, headers }); +} + +/** A service account the platform holds a credential for, as listed. */ +export interface ConnectedService { + readonly id: string; + /** `google` or `github` — the grant, not the API being called. */ + readonly provider: string; + /** The alias this account was connected under. Pass it as `label`. */ + readonly label: string; + /** Who the account belongs to, e.g. the mailbox address. */ + readonly displayName?: string; + readonly scopes?: readonly string[]; + /** `connected` accounts are usable; anything else is not yet. */ + readonly status: string; +} + +/** + * The service accounts this session can reach. + * + * An agent that sweeps several mailboxes cannot hold their names in its + * source: they are connected and disconnected by an operator long after the + * artifact is built. This answers "which ones exist right now" so the loop is + * over live state rather than over a list someone has to remember to redeploy. + * + * The platform reconciles pending consents before answering, so an account + * connected a moment ago is already `connected` here rather than on the next + * run. Only providers the deployment declares are returned. + * + * Unlike `callService`, this returns parsed rows rather than a `Response` — + * it is the platform's own API, not an upstream service whose status codes + * the caller needs to see. + */ +export async function listServices(options: { + /** Restrict to one grant, e.g. `google`. Omit for everything. */ + provider?: string; + /** Omit unusable accounts. Defaults to true. */ + connectedOnly?: boolean; + signal?: AbortSignal; +} = {}): Promise { + const runtime = globalThis as typeof globalThis & { + process?: { env?: Record }; + }; + const base = runtime.process?.env?.OPENCOMPUTER_CONNECTIONS_URL; + const token = runtime.process?.env?.OPENCOMPUTER_CONNECTION_TOKEN; + if (!base || !token) { + throw new Error("OpenComputer managed connections are unavailable"); + } + // `opencomputer` is the reserved provider segment for the platform's own + // connection actions; a body carrying no method and no path is what routes + // this to them rather than to managed egress. + const response = await fetch( + `${base.replace(/\/$/, "")}/opencomputer/fetch`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ action: "list" }), + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + if (!response.ok) { + throw new Error( + `Listing connected services failed: ${response.status} ${( + await response.text() + ).slice(0, 300)}`, + ); + } + const body = (await response.json()) as { connections?: ConnectedService[] }; + const provider = options.provider?.trim().toLowerCase(); + return (body.connections ?? []).filter( + (connection) => + (!provider || connection.provider?.toLowerCase() === provider) && + (options.connectedOnly === false || connection.status === "connected"), + ); +} + export function defineConnection(input: { id: string; origin: string; @@ -966,6 +1168,21 @@ export const useModel = (model: ModelSelection): void => hooks().useModel(model); export const useTool = (tool: string | ResourceReference): void => hooks().useTool(tool); +/** + * Declare that this agent reaches a managed service. + * + * `callService` works without this, but two things do not. The capability + * manifest is extracted from source at build time and is meant to be the whole + * statement of what an agent can reach — an undeclared `callService("gmail")` + * is reach that no reviewer can see. And `listServices()` only returns grants + * the deployment declares, so an agent that sweeps mailboxes without declaring + * `gmail` is told, truthfully and uselessly, that none are connected. + * + * Pass a literal string: it is read out of the source, not evaluated. + */ +export const useService = (service: string): void => + hooks().useService?.(service); + export const useSubagent = (agent: string | ResourceReference): void => hooks().useSubagent(agent); export const useMcpServer = (server: string | ResourceReference): void => diff --git a/cli/src/project.test.ts b/cli/src/project.test.ts index 311a793b..ce6338e0 100644 --- a/cli/src/project.test.ts +++ b/cli/src/project.test.ts @@ -424,6 +424,155 @@ export default function Agent() { } }); +test("the managed-connection clients survive the generated runtime", async () => { + const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-service-")); + const root = resolve(parent, "app"); + try { + const initialized = await initializeAgentProject(root); + await mkdir(resolve(initialized.agentRoot, "tools"), { recursive: true }); + await writeFile( + resolve(initialized.agentRoot, "tools", "mail.ts"), + `import { callService, defineTool } from "@opencomputer/agent"; + +export const unread = defineTool({ + name: "unread", + description: "Count unread mail", + async run() { + const response = await callService({ + service: "gmail", + label: "work", + path: "/gmail/v1/users/me/messages", + }); + return { status: response.status }; + }, +}); +`, + ); + await writeFile( + resolve(initialized.agentRoot, "agent.ts"), + `import { useTool } from "@opencomputer/agent"; +import { unread } from "./tools/mail.js"; + +export default function Agent() { + useTool(unread); + return "Read mail when asked."; +} +`, + ); + + await buildAgentArtifact(initialized.agentRoot); + + // Importing the EMITTED shim is the point. The shim is produced by + // interpolating a template literal, where a regex like /\/$/ collapses into + // a line comment and silently swallows the rest of the call — which is + // exactly how this shipped broken the first time. A syntax error here is + // invisible to tsc and only shows up at build or import. + const runtime = await import( + `${pathToFileURL(resolve(initialized.agentRoot, ".opencomputer", "runtime", "opencomputer-agent.js")).href}?test=${crypto.randomUUID()}` + ); + assert.equal(typeof runtime.callService, "function"); + + // Without the platform's env there is no connection to call, and the + // failure must say so rather than fetching something arbitrary. + await assert.rejects( + runtime.callService({ service: "gmail", path: "/gmail/v1/users/me/profile" }), + /managed connections are unavailable/, + ); + + const calls: Array<{ url: string; init: RequestInit }> = []; + const realFetch = globalThis.fetch; + globalThis.process.env.OPENCOMPUTER_CONNECTIONS_URL = "https://edge.test/conn/"; + globalThis.process.env.OPENCOMPUTER_CONNECTION_TOKEN = "rt-token"; + // What a managed connection actually answers: the service's status and + // body inside an envelope, wrapped in a 200. A caller reading `ok` off the + // outer response sees success on a request that failed, and a caller + // reading .json() gets the envelope instead of the payload. + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response( + JSON.stringify({ + status: 403, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ error: { message: "Insufficient Permission" } }), + }), + { status: 200 }, + ); + }) as unknown as typeof globalThis.fetch; + let unwrapped: Response | undefined; + try { + unwrapped = await runtime.callService({ + service: "google", + label: "work", + method: "post", + path: "/gmail/v1/users/me/messages/send", + body: "{}", + }); + } finally { + globalThis.fetch = realFetch; + delete globalThis.process.env.OPENCOMPUTER_CONNECTIONS_URL; + delete globalThis.process.env.OPENCOMPUTER_CONNECTION_TOKEN; + } + + assert.equal(calls.length, 1); + // A trailing slash on the base must not produce a doubled one — the fix for + // the comment bug replaced a regex trim, so the behaviour needs pinning. + assert.equal(calls[0]!.url, "https://edge.test/conn/google/fetch"); + const sent = JSON.parse(String(calls[0]!.init.body)) as Record; + // `google` is an alias the platform resolves to gmail; the SDK passes the + // caller's word through and only decides the PROVIDER segment itself. + assert.equal(sent.service, "google"); + assert.equal(sent.label, "work"); + assert.equal(sent.method, "POST"); + assert.equal(sent.path, "/gmail/v1/users/me/messages/send"); + + // The envelope must be opened: the service said 403, so the caller must. + assert.equal(unwrapped!.status, 403); + assert.equal(unwrapped!.ok, false); + assert.deepEqual(await unwrapped!.json(), { + error: { message: "Insufficient Permission" }, + }); + + // listServices routes to the reserved `opencomputer` provider segment and + // must send NO method and NO path — that body shape is the only thing + // distinguishing a platform action from managed egress on the same route. + assert.equal(typeof runtime.listServices, "function"); + calls.length = 0; + globalThis.process.env.OPENCOMPUTER_CONNECTIONS_URL = "https://edge.test/conn"; + globalThis.process.env.OPENCOMPUTER_CONNECTION_TOKEN = "rt-token"; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response( + JSON.stringify({ + connections: [ + { id: "1", provider: "google", label: "alice", displayName: "a@x.com", status: "connected" }, + { id: "2", provider: "google", label: "bob", status: "pending" }, + { id: "3", provider: "github", label: "default", status: "connected" }, + ], + }), + { status: 200 }, + ); + }) as unknown as typeof globalThis.fetch; + let listed; + try { + listed = await runtime.listServices({ provider: "google" }); + } finally { + globalThis.fetch = realFetch; + delete globalThis.process.env.OPENCOMPUTER_CONNECTIONS_URL; + delete globalThis.process.env.OPENCOMPUTER_CONNECTION_TOKEN; + } + assert.equal(calls[0]!.url, "https://edge.test/conn/opencomputer/fetch"); + assert.deepEqual(JSON.parse(String(calls[0]!.init.body)), { action: "list" }); + // Pending accounts are unusable and the github row is a different grant; + // a sweep that tried either would fail on a mailbox that does not exist. + assert.deepEqual( + listed.map((connection: { label: string }) => connection.label), + ["alice"], + ); + } finally { + await rm(parent, { recursive: true, force: true }); + } +}); + test("the compiler records secret-backed HTTP connections without secret values", async () => { const parent = await mkdtemp(resolve(tmpdir(), "opencomputer-egress-")); const root = resolve(parent, "app"); diff --git a/cli/src/project.ts b/cli/src/project.ts index ac8326ba..87c47b23 100644 --- a/cli/src/project.ts +++ b/cli/src/project.ts @@ -776,6 +776,41 @@ function literalHookIds(source: string, hook: string): string[] { return [...source.matchAll(pattern)].map((match) => match[1]!).sort(); } +/** + * The grant a managed service belongs to. + * + * The platform gates a session's connections on the PROVIDER, not the service: + * gmail, calendar, drive and sheets are one Google grant, and github is its + * own. An agent declares the service it uses, because that is what it calls; + * the deployment records the provider, because that is what was consented to. + */ +const MANAGED_SERVICE_PROVIDERS: Readonly> = { + gmail: "google", + google: "google", + calendar: "google", + drive: "google", + sheets: "google", + github: "github", +}; + +function declaredServiceProviders(agentSource: string): string[] { + const declared = literalHookIds(agentSource, "useService"); + const unknown = declared.find( + (service) => !MANAGED_SERVICE_PROVIDERS[service.trim().toLowerCase()], + ); + if (unknown) { + throw new Error( + `useService(${JSON.stringify(unknown)}) names no managed service; ` + + `expected one of ${Object.keys(MANAGED_SERVICE_PROVIDERS).join(", ")}`, + ); + } + return [ + ...new Set( + declared.map((service) => MANAGED_SERVICE_PROVIDERS[service.trim().toLowerCase()]!), + ), + ]; +} + function definedConnectionBindings( source: string, path: string, @@ -2365,6 +2400,59 @@ export const useSecret = (value) => { }; export const secretHeader = (secret, options = {}) => Object.freeze({ kind: "secret-header", secret, ...options }); export const bearer = (secret) => secretHeader(secret, { prefix: "Bearer " }); +export const callService = async (request) => { + const base = globalThis.process?.env?.OPENCOMPUTER_CONNECTIONS_URL; + const token = globalThis.process?.env?.OPENCOMPUTER_CONNECTION_TOKEN; + if (!base || !token) throw new Error("OpenComputer managed connections are unavailable"); + if (!request?.path?.startsWith("/")) throw new Error("Service requests require an absolute path"); + const service = String(request.service ?? "").trim().toLowerCase(); + if (!service) throw new Error("A service request needs a service"); + const provider = service === "github" ? "github" : "google"; + const root = base.endsWith("/") ? base.slice(0, -1) : base; + const response = await fetch(root + "/" + provider + "/fetch", { + method: "POST", + headers: { authorization: "Bearer " + token, "content-type": "application/json" }, + body: JSON.stringify({ + service, + ...(request.label ? { label: request.label } : {}), + method: (request.method ?? "GET").toUpperCase(), + path: request.path, + ...(request.headers ? { headers: request.headers } : {}), + ...(request.body === undefined ? {} : { body: request.body }), + }), + ...(request.signal ? { signal: request.signal } : {}), + }); + // A managed connection answers with an envelope wrapped in a 200. Open it so + // callers see the service's real status instead of the proxy's. + if (!response.ok) return response; + const envelope = await response.clone().json().catch(() => null); + if (!envelope || typeof envelope.status !== "number" || typeof envelope.body !== "string") return response; + const headers = {}; + for (const [name, value] of Object.entries(envelope.headers || {})) { + if (typeof value === "string") headers[name] = value; + } + return new Response(envelope.body, { status: envelope.status, headers }); +}; + +export const listServices = async (options = {}) => { + const base = globalThis.process?.env?.OPENCOMPUTER_CONNECTIONS_URL; + const token = globalThis.process?.env?.OPENCOMPUTER_CONNECTION_TOKEN; + if (!base || !token) throw new Error("OpenComputer managed connections are unavailable"); + const root = base.endsWith("/") ? base.slice(0, -1) : base; + const response = await fetch(root + "/opencomputer/fetch", { + method: "POST", + headers: { authorization: "Bearer " + token, "content-type": "application/json" }, + body: JSON.stringify({ action: "list" }), + ...(options.signal ? { signal: options.signal } : {}), + }); + if (!response.ok) throw new Error("Listing connected services failed: " + response.status + " " + (await response.text()).slice(0, 300)); + const body = await response.json(); + const provider = options.provider ? String(options.provider).trim().toLowerCase() : ""; + return (body.connections ?? []).filter((connection) => + (!provider || String(connection.provider ?? "").toLowerCase() === provider) && + (options.connectedOnly === false || connection.status === "connected")); +}; + export const defineConnection = (input) => { const connectionId = id(input.id, "defineConnection"); const origin = new URL(input.origin); @@ -2452,6 +2540,7 @@ export const useInput = () => hooks().useInput(); export const useCurrentInput = useInput; export const useModel = (model) => hooks().useModel(model); export const useTool = (tool) => hooks().useTool(tool); +export const useService = (service) => hooks().useService?.(service); export const useSubagent = (agent) => hooks().useSubagent(agent); export const useMcpServer = (server) => hooks().useMcpServer(server); export const useSessionData = (key) => hooks().useSessionData(key); @@ -2763,7 +2852,15 @@ the product or support surface presented to users. tools, toolModules: toolModules.sort(), subagents: literalHookIds(agentSource, "useSubagent"), - connections: httpConnections.map((connection) => connection.id).sort(), + // Declared HTTP connections AND managed-service grants: the platform + // reads one list, and a google grant absent from it makes every + // connected mailbox invisible to listServices(). + connections: [ + ...new Set([ + ...httpConnections.map((connection) => connection.id), + ...declaredServiceProviders(agentSource), + ]), + ].sort(), httpConnections, mcpServers: [ ...new Set([ From cb02dc96d1d2ba55d87ae6c61e14815a3436ab72 Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Mon, 14 Sep 2026 22:45:43 -0700 Subject: [PATCH 2/2] feat: restore managed connection controls Expose the connections page again and let operators add aliased Gmail, Calendar, and GitHub accounts without using the API directly. Reconcile pending OAuth links on return and require confirmation before disconnecting an account. Keep the inventory primary by moving creation into a focused dialog. --- web/src/App.tsx | 5 +- web/src/components/app-shell-nav.ts | 12 ++ web/src/components/app-shell.test.ts | 21 ++- web/src/managed-agents/Connections.tsx | 247 ++++++++++++++++++++++++- web/src/managed-agents/api.ts | 40 ++++ 5 files changed, 314 insertions(+), 11 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index a34e0c66..afb28971 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,6 +31,9 @@ const ManagedAgentDetail = lazy(() => import('./managed-agents/Detail')) const ManagedProjectDetail = lazy(() => import('./managed-agents/Project')) const ManagedSessionDetail = lazy(() => import('./managed-agents/Session')) const ManagedAgentChannels = lazy(() => import('./managed-agents/Channels')) +const ManagedAgentConnections = lazy( + () => import('./managed-agents/Connections'), +) const ManagedProjectOnboarding = lazy( () => import('./managed-agents/ProjectOnboarding'), ) @@ -78,7 +81,7 @@ export default function App() { /> } + element={} /> { @@ -13,8 +13,13 @@ describe('managed agents navigation', () => { managedAgentsNav(defaults).map((group) => group.items.map((item) => item.label), ), - ).toEqual([['Projects']]) + ).toEqual([['Projects'], ['Connections']]) expect(managedAgentsNav(defaults)[0]?.items[0]?.end).toBe(true) + expect(managedAgentsNav(defaults)[1]?.items[0]).toMatchObject({ + to: '/managed-agents/connections', + label: 'Connections', + icon: Plug, + }) }) it('shows project navigation only after a project is selected', () => { @@ -52,14 +57,14 @@ describe('managed agents navigation', () => { ...defaults, durableSessionsEnabled: true, }).map((group) => group.label), - ).toEqual([undefined, 'Durable sessions']) + ).toEqual([undefined, 'Account', 'Durable sessions']) expect( managedAgentsNav({ ...defaults, infrastructureEnabled: true, }).map((group) => group.label), - ).toEqual([undefined, 'Infrastructure']) + ).toEqual([undefined, 'Account', 'Infrastructure']) }) it('keeps enabled advanced areas below project navigation', () => { @@ -69,6 +74,12 @@ describe('managed agents navigation', () => { durableSessionsEnabled: true, infrastructureEnabled: true, }).map((group) => group.label), - ).toEqual([undefined, undefined, 'Durable sessions', 'Infrastructure']) + ).toEqual([ + undefined, + undefined, + 'Account', + 'Durable sessions', + 'Infrastructure', + ]) }) }) diff --git a/web/src/managed-agents/Connections.tsx b/web/src/managed-agents/Connections.tsx index 2fb939b3..297f38b7 100644 --- a/web/src/managed-agents/Connections.tsx +++ b/web/src/managed-agents/Connections.tsx @@ -1,17 +1,31 @@ import { useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Loader2, Plug } from 'lucide-react' +import { Loader2, Plug, Plus, Trash2 } from 'lucide-react' import { useSearchParams } from 'react-router-dom' +import { ConfirmDialog } from '@/components/confirm-dialog' import { PageHeader } from '@/components/page-header' import { Panel, PanelContent } from '@/components/panel' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { displayManagedAgentName, claimManagedAgentChannelIdentity, + disconnectManagedAgentConnection, getManagedAgentConnections, getManagedAgents, linkManagedAgentConnection, + refreshManagedAgentConnection, + type ManagedAgentConnection, } from './api' function displayResourceName(value: string) { @@ -38,6 +52,48 @@ function connectionService(connection: { provider: string; scopes: string[] }) { return displayResourceName(connection.provider) } +function connectionServiceId( + connection: ManagedAgentConnection, +): 'gmail' | 'calendar' | 'github' | undefined { + if (connection.provider === 'github') return 'github' + if (connection.provider !== 'google') return undefined + if ( + connection.scopes.some((scope) => + scope.toLowerCase().includes('/auth/calendar'), + ) + ) { + return 'calendar' + } + if ( + connection.scopes.some((scope) => + scope.toLowerCase().includes('/auth/gmail.'), + ) + ) { + return 'gmail' + } + return undefined +} + +async function loadManagedAgentConnections() { + const current = await getManagedAgentConnections() + const pending = current.filter( + (connection) => connection.status === 'pending', + ) + if (!pending.length) return current + await Promise.allSettled( + pending.map((connection) => { + const service = connectionServiceId(connection) + if (!service) return Promise.resolve() + return refreshManagedAgentConnection( + service === 'github' ? 'github' : 'google', + service, + connection.id, + ) + }), + ) + return getManagedAgentConnections() +} + export default function ManagedAgentConnections() { const [searchParams, setSearchParams] = useSearchParams() const channelLinkStarted = useRef(false) @@ -48,11 +104,19 @@ export default function ManagedAgentConnections() { const [connectionRequestState, setConnectionRequestState] = useState< 'idle' | 'connecting' | 'connected' | 'failed' >('idle') + const [newService, setNewService] = useState('gmail') + const [newAlias, setNewAlias] = useState('gmail') + const [addConnectionOpen, setAddConnectionOpen] = useState(false) + const [addConnectionError, setAddConnectionError] = useState() + const [removeConnectionError, setRemoveConnectionError] = useState() + const [connectionToRemove, setConnectionToRemove] = + useState() + const [removingConnection, setRemovingConnection] = useState(false) const requestedService = searchParams.get('service') const requestedAlias = searchParams.get('alias') || 'default' const connections = useQuery({ queryKey: ['managed-agent-connections'], - queryFn: getManagedAgentConnections, + queryFn: loadManagedAgentConnections, }) const agents = useQuery({ queryKey: ['managed-agents'], @@ -113,7 +177,19 @@ export default function ManagedAgentConnections() {
{ + setConnectionRequestState('idle') + setAddConnectionError(undefined) + setAddConnectionOpen(true) + }} + > + + Add connection + + } /> {channelLinkState !== 'idle' && ( @@ -196,6 +272,17 @@ export default function ManagedAgentConnections() {

+ {connectionServiceId(connection) ? ( + + ) : null} ) @@ -209,12 +296,162 @@ export default function ManagedAgentConnections() {

No connections yet

- Connect an account with the OpenComputer CLI and it will appear - here. + Add Gmail, Google Calendar, or GitHub to make it available to your + agents.

+ )} + + { + if (connectionRequestState === 'connecting') return + setAddConnectionOpen(open) + if (!open) setAddConnectionError(undefined) + }} + > + + + Add connection + + Choose a service and a memorable alias for this account. + + +
{ + event.preventDefault() + const alias = newAlias.trim() + if (!alias || connectionRequestState === 'connecting') return + setAddConnectionError(undefined) + setConnectionRequestState('connecting') + void linkManagedAgentConnection(newService, alias) + .then((result) => { + if (result.authorizationUrl) { + window.location.assign(result.authorizationUrl) + return + } + setConnectionRequestState('connected') + setAddConnectionOpen(false) + void connections.refetch() + }) + .catch((error: unknown) => { + setConnectionRequestState('failed') + setAddConnectionError( + error instanceof Error + ? error.message + : 'The connection could not be started.', + ) + }) + }} + > +
+ + +
+
+ + setNewAlias(event.target.value)} + pattern="[A-Za-z0-9._-]+" + placeholder="work-gmail" + required + /> +

+ Agents use this alias to select the account in callService(). +

+
+ {addConnectionError ? ( +

{addConnectionError}

+ ) : null} + + + + +
+
+
+ + { + if (!open) setConnectionToRemove(undefined) + }} + title="Remove connection?" + description={ + connectionToRemove + ? removeConnectionError + ? `The connection could not be removed: ${removeConnectionError}` + : `Agents will no longer be able to use the “${connectionToRemove.label}” account. You can reconnect it later.` + : undefined + } + confirmLabel="Remove connection" + destructive + pending={removingConnection} + onConfirm={() => { + if (!connectionToRemove) return + const service = connectionServiceId(connectionToRemove) + if (!service) return + setRemovingConnection(true) + setRemoveConnectionError(undefined) + void disconnectManagedAgentConnection( + service === 'github' ? 'github' : 'google', + service, + connectionToRemove.id, + ) + .then(() => connections.refetch()) + .then(() => setConnectionToRemove(undefined)) + .catch((error: unknown) => { + setRemoveConnectionError( + error instanceof Error + ? error.message + : 'The connection could not be removed.', + ) + }) + .finally(() => setRemovingConnection(false)) + }} + /> ) } diff --git a/web/src/managed-agents/api.ts b/web/src/managed-agents/api.ts index ccf4302a..4d834f11 100644 --- a/web/src/managed-agents/api.ts +++ b/web/src/managed-agents/api.ts @@ -305,6 +305,20 @@ const connectionLinkSchema = z.object({ authorizationUrl: z.string().url().optional(), }) +const connectionStatusSchema = z.object({ + connectionId: z.string(), + service: z.string(), + label: z.string(), + status: z.enum(['connected', 'pending']), +}) + +const connectionDisconnectSchema = z.object({ + connectionId: z.string(), + service: z.string(), + label: z.string(), + status: z.string(), +}) + const channelsResponseSchema = z.object({ channels: z.array(channelSchema), }) @@ -922,6 +936,32 @@ export async function linkManagedAgentConnection( ) } +export async function refreshManagedAgentConnection( + provider: 'google' | 'github', + service: string, + connectionId: string, +) { + const query = new URLSearchParams({ service, connectionId }) + return apiFetch( + `/managed-agents/connections/${provider}/status?${query.toString()}`, + undefined, + connectionStatusSchema, + ) +} + +export async function disconnectManagedAgentConnection( + provider: 'google' | 'github', + service: string, + connectionId: string, +) { + const query = new URLSearchParams({ service, connectionId }) + return apiFetch( + `/managed-agents/connections/${provider}?${query.toString()}`, + { method: 'DELETE' }, + connectionDisconnectSchema, + ) +} + export async function getManagedAgentChannels() { return ( await apiFetch(