diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 8f34f1333..ff344d807 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -68,17 +68,19 @@ export interface ConnectionStatus { icon?: string /** #327: display name from registry */ name?: string + /** auth methods advertised to the UI — browser for google, token for others */ + auth_methods?: string[] } /** The connection cards this module serves; company-compute renders first. */ -export const CONNECTION_IDS: ConnectionType[] = ["company-compute", "pasqal-cloud", "slack", "github", "linear"] +export const CONNECTION_IDS: ConnectionType[] = ["company-compute", "pasqal-cloud", "slack", "github", "linear", "google", "google-drive"] // --- Registry (issue #327): formalized built-in catalog with logos + custom --- /** Inline SVG icons — full-color brand marks, 18×18 with explicit fills (not currentColor). */ export const CONNECTION_ICONS: Record = { "company-compute": - '', + '', "pasqal-cloud": '', slack: @@ -87,6 +89,10 @@ export const CONNECTION_ICONS: Record = { '', linear: '', + google: + '', + "google-drive": + '', } export interface ConnectionEntry { @@ -94,7 +100,7 @@ export interface ConnectionEntry { kind: "built-in" | "custom" name: string icon: { kind: "svg"; svg: string } | { kind: "letter"; letter: string } - validator: "company-compute" | "pasqal" | "slack" | "github" | "linear" | "none" + validator: "company-compute" | "pasqal" | "slack" | "github" | "linear" | "google" | "google-drive" | "none" authShape: "base-url-token" | "token-only" | "pasqal-credentials" url?: string } @@ -140,6 +146,22 @@ export const BUILT_IN_CATALOG: ConnectionEntry[] = [ validator: "linear", authShape: "token-only", }, + { + id: "google", + kind: "built-in", + name: "Google", + icon: { kind: "svg", svg: CONNECTION_ICONS["google"] }, + validator: "google", + authShape: "token-only", + }, + { + id: "google-drive", + kind: "built-in", + name: "Google Drive", + icon: { kind: "svg", svg: CONNECTION_ICONS["google-drive"] }, + validator: "google-drive", + authShape: "token-only", + }, ] export function getBuiltInEntry(id: string): ConnectionEntry | undefined { @@ -414,6 +436,7 @@ function renderStatus( if (icon) out.icon = icon const name = nameForId(id) if (name) out.name = name + if (id === "google" || id === "google-drive") out.auth_methods = ["browser"] return out } let state: ConnectionState @@ -448,6 +471,7 @@ function renderStatus( if (icon) out.icon = icon const name = nameForId(id) if (name) out.name = name + if (id === "google" || id === "google-drive") out.auth_methods = ["browser"] return out } @@ -596,7 +620,8 @@ function kickStaleRevalidations(body: string, deps: { fetchImpl?: FetchImpl; pas try { if (id === "company-compute") await backgroundRevalidateCompanyCompute(deps) else if (id === "pasqal-cloud") await backgroundRevalidatePasqal(deps) - else if (id === "slack" || id === "github" || id === "linear") await backgroundRevalidateToken(id, deps) + else if (id === "slack" || id === "github" || id === "linear" || id === "google" || id === "google-drive") + await backgroundRevalidateToken(id, deps) } catch { // background refresh must never surface trouble; the next GET retries } @@ -680,6 +705,8 @@ async function backgroundRevalidateToken(id: ConnectionType, deps: { fetchImpl?: let probe: ProbeResult if (id === "slack") probe = await probeSlack(cred.token, deps.fetchImpl) else if (id === "github") probe = await probeGithub(cred.token, deps.fetchImpl) + else if (id === "google") probe = await probeGoogle(cred.token, deps.fetchImpl) + else if (id === "google-drive") probe = await probeGoogleDrive(cred.token, deps.fetchImpl) else probe = await probeLinear(cred.token, deps.fetchImpl) const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) if (probe.outcome === "unreachable") { @@ -830,6 +857,36 @@ export async function probeLinear(token: string, fetchImpl: FetchImpl = fetch): return { outcome: "unreachable" } } +export async function probeGoogle(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + encodeURIComponent(token), { + method: "GET", + headers: {}, + }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 200) return { outcome: "valid" } + if (response.status === 400 || response.status === 401 || response.status === 403) return { outcome: "invalid" } + return { outcome: "unreachable" } +} + +export async function probeGoogleDrive(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl("https://www.googleapis.com/drive/v3/about?fields=user", { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 200) return { outcome: "valid" } + if (response.status === 400 || response.status === 401 || response.status === 403) return { outcome: "invalid" } + return { outcome: "unreachable" } +} + // --- Pasqal validator spawn (amicode#169 / parent #159; #164 contract) --- // The fork never sees SDK internals: the validator's one-line JSON + exit-code // contract is the ENTIRE interface. Inputs ride env variables ONLY — never @@ -1179,7 +1236,7 @@ export async function submitCredentialResponse(rawBody: string, deps: MutationDe const body = parseMutationBody(rawBody) if (!body) return synthesizeConnection("bad_request", "body must be JSON with an id and that id's credential fields") if (body.id === "pasqal-cloud") return submitPasqalCredential(body, deps) - if (body.id === "slack" || body.id === "github" || body.id === "linear") { + if (body.id === "slack" || body.id === "github" || body.id === "linear" || body.id === "google" || body.id === "google-drive") { return submitTokenCredential(body.id as ConnectionType, body, deps) } if (body.id !== "company-compute") { @@ -1228,6 +1285,8 @@ async function submitTokenCredential(id: ConnectionType, body: MutationBody, dep try { if (id === "slack") probe = await probeSlack(token, deps.fetchImpl) else if (id === "github") probe = await probeGithub(token, deps.fetchImpl) + else if (id === "google") probe = await probeGoogle(token, deps.fetchImpl) + else if (id === "google-drive") probe = await probeGoogleDrive(token, deps.fetchImpl) else probe = await probeLinear(token, deps.fetchImpl) } finally { inflightOverlay.delete(id) @@ -1583,7 +1642,7 @@ export async function revalidateResponse(rawBody: string, deps: MutationDeps = { const id = parseIdBody(rawBody) if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") if (id === "pasqal-cloud") return revalidatePasqal(deps) - if (id === "slack" || id === "github" || id === "linear") { + if (id === "slack" || id === "github" || id === "linear" || id === "google" || id === "google-drive") { const cred = readCredential(id) as { token?: string } | undefined if (!cred || typeof cred.token !== "string" || cred.token === "") { clearStatus(id) @@ -1594,6 +1653,8 @@ export async function revalidateResponse(rawBody: string, deps: MutationDeps = { try { if (id === "slack") probe = await probeSlack(cred.token, deps.fetchImpl) else if (id === "github") probe = await probeGithub(cred.token, deps.fetchImpl) + else if (id === "google") probe = await probeGoogle(cred.token, deps.fetchImpl) + else if (id === "google-drive") probe = await probeGoogleDrive(cred.token, deps.fetchImpl) else probe = await probeLinear(cred.token, deps.fetchImpl) } finally { inflightOverlay.delete(id) diff --git a/packages/opencode/src/server/amicode/credentials.ts b/packages/opencode/src/server/amicode/credentials.ts index 406190d61..8244d8627 100644 --- a/packages/opencode/src/server/amicode/credentials.ts +++ b/packages/opencode/src/server/amicode/credentials.ts @@ -13,7 +13,7 @@ import { randomBytes } from "node:crypto" import { homedir } from "node:os" import path from "node:path" -export type BuiltInConnectionType = "company-compute" | "pasqal-cloud" | "slack" | "github" | "linear" +export type BuiltInConnectionType = "company-compute" | "pasqal-cloud" | "slack" | "github" | "linear" | "google" | "google-drive" export type ConnectionType = BuiltInConnectionType | (string & {}) /** FROZEN byte shape — every existing CLI consumer parses this unchanged. */ @@ -61,6 +61,16 @@ export function linearFile(): string { if (env && env.trim() !== "") return env return path.join(homedir(), ".amico", "linear.json") } +export function googleFile(): string { + const env = process.env.AMICO_GOOGLE_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "google.json") +} +export function googleDriveFile(): string { + const env = process.env.AMICO_GOOGLE_DRIVE_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "google-drive.json") +} // --- poison guard: writing any object carrying a password-like key through // this seam must be impossible. The encoders below are allowlist-only (they @@ -169,6 +179,36 @@ const BACKENDS: Record = { return { token: d.token } }, }, + google: { + file: googleFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('google credential needs non-empty "token"') + return JSON.stringify({ token }, null, 2) + "\n" + }, + decode(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + const d = raw as Record + if (typeof d.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, + "google-drive": { + file: googleDriveFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('google-drive credential needs non-empty "token"') + return JSON.stringify({ token }, null, 2) + "\n" + }, + decode(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + const d = raw as Record + if (typeof d.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, } // --- atomic 0600-at-birth writer --- @@ -205,6 +245,8 @@ export function readCredential(type: "pasqal-cloud"): PasqalCredential | undefin export function readCredential(type: "slack"): TokenCredential | undefined export function readCredential(type: "github"): TokenCredential | undefined export function readCredential(type: "linear"): TokenCredential | undefined +export function readCredential(type: "google"): TokenCredential | undefined +export function readCredential(type: "google-drive"): TokenCredential | undefined export function readCredential(type: string): Credential | undefined export function readCredential(type: ConnectionType): Credential | undefined export function readCredential(type: ConnectionType): Credential | undefined { @@ -226,6 +268,8 @@ export function writeCredential(type: "pasqal-cloud", value: PasqalCredential, h export function writeCredential(type: "slack", value: TokenCredential, hooks?: WriteHooks): void export function writeCredential(type: "github", value: TokenCredential, hooks?: WriteHooks): void export function writeCredential(type: "linear", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: "google", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: "google-drive", value: TokenCredential, hooks?: WriteHooks): void export function writeCredential(type: string, value: Credential, hooks?: WriteHooks): void export function writeCredential(type: ConnectionType, value: Credential, hooks?: WriteHooks): void { const backend = BACKENDS[type] diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts index d1d16d6c9..b5fa28478 100644 --- a/packages/ui/src/amicode/connections.ts +++ b/packages/ui/src/amicode/connections.ts @@ -85,12 +85,14 @@ export const PASQAL_ID = "pasqal-cloud" export const SLACK_ID = "slack" export const GITHUB_ID = "github" export const LINEAR_ID = "linear" +export const GOOGLE_ID = "google" +export const GOOGLE_DRIVE_ID = "google-drive" -export const BUILT_IN_IDS = [COMPANY_COMPUTE_ID, PASQAL_ID, SLACK_ID, GITHUB_ID, LINEAR_ID] as const +export const BUILT_IN_IDS = [COMPANY_COMPUTE_ID, PASQAL_ID, SLACK_ID, GITHUB_ID, LINEAR_ID, GOOGLE_ID, GOOGLE_DRIVE_ID] as const export const CONNECTION_ICONS: Record = { "company-compute": - '', + '', "pasqal-cloud": '', slack: @@ -99,6 +101,10 @@ export const CONNECTION_ICONS: Record = { '', linear: '', + google: + '', + "google-drive": + '', } export function isCustomConnectionId(id: string): boolean { @@ -138,6 +144,8 @@ export function connectionTitle(id: string): string { if (id === SLACK_ID) return "Slack" if (id === GITHUB_ID) return "GitHub" if (id === LINEAR_ID) return "Linear" + if (id === GOOGLE_ID) return "Google" + if (id === GOOGLE_DRIVE_ID) return "Google Drive" if (isCustomConnectionId(id)) return id // caller should use view.name when available return id } @@ -435,7 +443,7 @@ export type ConnectionFormKind = "base-url-token" | "pasqal-credentials" | "toke export function connectionFormKind(id: string): ConnectionFormKind { if (id === PASQAL_ID) return "pasqal-credentials" - if (id === SLACK_ID || id === GITHUB_ID || id === LINEAR_ID) return "token-only" + if (id === SLACK_ID || id === GITHUB_ID || id === LINEAR_ID || id === GOOGLE_ID || id === GOOGLE_DRIVE_ID) return "token-only" if (isCustomConnectionId(id)) return "custom" return "base-url-token" } @@ -463,6 +471,7 @@ export function customConnectionPayload(name: string, token: string, url?: strin * legacy single method implied by the card's form kind. */ export function connectionAuthMethods(view: ConnectionView): ConnectionAuthMethod[] { if (view.authMethods && view.authMethods.length > 0) return view.authMethods + if (view.id === GOOGLE_ID || view.id === GOOGLE_DRIVE_ID) return ["browser"] return connectionFormKind(view.id) === "pasqal-credentials" ? ["credentials"] : ["token"] }