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 67804920..a0710d36 100644 --- a/cli/src/project.test.ts +++ b/cli/src/project.test.ts @@ -477,6 +477,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 05d85eb8..668e3c8c 100644 --- a/cli/src/project.ts +++ b/cli/src/project.ts @@ -777,6 +777,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, @@ -2399,6 +2434,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); @@ -2486,6 +2574,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); @@ -2797,7 +2886,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([ 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(