Skip to content

Commit f322582

Browse files
Rchari1Amico
andauthored
feat(connections): add Google + Google Drive connectors, make Harmoniqs Cloud yellow (#197)
* feat(connections): add Google + Google Drive connectors, make Harmoniqs Cloud yellow - Add Google and Google Drive as built-in connections (token-only) - New backends in credentials.ts (googleFile, googleDriveFile, BACKENDS) - New entries in BUILT_IN_CATALOG + CONNECTION_IDS + CONNECTION_ICONS - Validators probeGoogle (tokeninfo) + probeGoogleDrive (drive/v3/about) - Wired into submitCredentialResponse, submitTokenCredential, revalidateResponse, background revalidation - UI side: new IDs, icons, titles, form kinds in packages/ui - Make Harmoniqs Cloud connector yellow (#0EA5E9 -> #EAB308) in both server and UI CONNECTION_ICONS Follows existing slack/github/linear pattern exactly: probe-first, fixed error messages, no token in logs, same exit/whitelist discipline. * feat(connections): make Google + Drive browser-only - Server advertises auth_methods=["browser"] for google/google-drive via renderStatus (both connected and session-only paths) - UI fallback connectionAuthMethods returns ["browser"] for those IDs so ConnectionCard shows 'Sign in with Google' button (entryKind none) instead of token field Picker still token-only for initial add; full OAuth start/callback endpoint is next step once Google Cloud OAuth client ID is available. --------- Co-authored-by: Amico <amico@harmoniqs.dev>
1 parent 1144682 commit f322582

3 files changed

Lines changed: 124 additions & 10 deletions

File tree

packages/opencode/src/server/amicode/connections.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,19 @@ export interface ConnectionStatus {
6868
icon?: string
6969
/** #327: display name from registry */
7070
name?: string
71+
/** auth methods advertised to the UI — browser for google, token for others */
72+
auth_methods?: string[]
7173
}
7274

7375
/** The connection cards this module serves; company-compute renders first. */
74-
export const CONNECTION_IDS: ConnectionType[] = ["company-compute", "pasqal-cloud", "slack", "github", "linear"]
76+
export const CONNECTION_IDS: ConnectionType[] = ["company-compute", "pasqal-cloud", "slack", "github", "linear", "google", "google-drive"]
7577

7678
// --- Registry (issue #327): formalized built-in catalog with logos + custom ---
7779

7880
/** Inline SVG icons — full-color brand marks, 18×18 with explicit fills (not currentColor). */
7981
export const CONNECTION_ICONS: Record<string, string> = {
8082
"company-compute":
81-
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="#0EA5E9"/><path fill="white" d="M6.3 11.8h4.2c.8 0 1.4-.6 1.4-1.4 0-.6-.4-1.1-.9-1.3A2.2 2.2 0 0 0 9 7.4a2.2 2.2 0 0 0-2 .9c-.6.1-1 .6-1 1.2 0 .7.6 1.3 1.3 1.3z"/><path fill="white" opacity="0.95" d="M8.1 9.4 9 8.2l1 1.2 1.5-1.8 1 1-2.5 3-2-2.4z"/></svg>',
83+
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="#EAB308"/><path fill="white" d="M6.3 11.8h4.2c.8 0 1.4-.6 1.4-1.4 0-.6-.4-1.1-.9-1.3A2.2 2.2 0 0 0 9 7.4a2.2 2.2 0 0 0-2 .9c-.6.1-1 .6-1 1.2 0 .7.6 1.3 1.3 1.3z"/><path fill="white" opacity="0.95" d="M8.1 9.4 9 8.2l1 1.2 1.5-1.8 1 1-2.5 3-2-2.4z"/></svg>',
8284
"pasqal-cloud":
8385
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="#1E1B4B"/><circle cx="9" cy="9" r="1.5" fill="white"/><ellipse cx="9" cy="9" rx="4.6" ry="1.65" fill="none" stroke="#FB713C" stroke-width="0.95" opacity="0.98"/><ellipse cx="9" cy="9" rx="4.6" ry="1.65" fill="none" stroke="#FB713C" stroke-width="0.95" opacity="0.98" transform="rotate(60 9 9)"/><ellipse cx="9" cy="9" rx="4.6" ry="1.65" fill="none" stroke="#FB713C" stroke-width="0.95" opacity="0.98" transform="rotate(-60 9 9)"/></svg>',
8486
slack:
@@ -87,14 +89,18 @@ export const CONNECTION_ICONS: Record<string, string> = {
8789
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="white" stroke="#E5E7EB" stroke-width="0.5"/><path fill="#24292F" d="M9 3.2a5.9 5.9 0 0 0-1.86 11.48c.29.05.4-.13.4-.28V13.4c-1.55.34-1.87-.66-1.87-.66-.25-.64-.62-.81-.62-.81-.5-.34.04-.33.04-.33.56.04.85.57.85.57.5.85 1.3.6 1.62.46.05-.36.19-.6.35-.74-1.23-.14-2.52-.62-2.52-2.74 0-.6.22-1.1.57-1.48-.06-.14-.25-.7.05-1.45 0 0 .47-.15 1.54.56A5.34 5.34 0 0 1 9 6.35c.48 0 .96.06 1.41.19 1.07-.71 1.54-.56 1.54-.56.3.75.11 1.31.05 1.45.35.38.57.88.57 1.48 0 2.13-1.3 2.6-2.53 2.74.2.17.38.5.38 1.02v1.51c0 .16.1.34.4.28A5.9 5.9 0 0 0 9 3.2z"/></svg>',
8890
linear:
8991
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="#5E6AD2"/><path fill="white" d="M6.4 5.6h1.7L10.3 9 8 12.5H6.3L8.6 9 6.4 5.6z"/><path fill="white" opacity="0.75" d="M11.2 5.6h1.2v6.9h-1.2z"/></svg>',
92+
google:
93+
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="white" stroke="#E5E7EB" stroke-width="0.5"/><path fill="#4285F4" d="M9.2 13.3c1.5 0 2.5-.5 3.3-1.4l-1.5-1.3c-.3.4-.7.8-1.8.8-1.4 0-2.4-1-2.4-2.3s1-2.3 2.4-2.3c.6 0 1 .2 1.3.4l1.1-1.1C10.9 5.5 10 5 9.2 5 6.9 5 5 6.7 5 9s1.9 4 4.2 4z"/><path fill="#34A853" d="M13.6 9.2c0-.3 0-.5-.1-.8H9.2v1.5h2.5c-.1.6-.5 1.1-1 1.4l1.5 1.2c.9-.8 1.4-2 1.4-3.3z"/><path fill="#FBBC04" d="M7.8 11.3c-.2-.5-.3-1-.3-1.6s.1-1.1.3-1.6L6.2 6.8C5.7 7.7 5.5 8.3 5.5 9s.2 1.3.7 2.2l1.6-1z"/><path fill="#EA4335" d="M9.2 6.3c.8 0 1.3.3 1.6.6l1.2-1.2C11.1 5 10.1 4.6 9.2 4.6 6.9 4.6 5 6.3 5 8.6l1.6 1.3c.4-.9 1.2-1.6 2.6-1.6z"/></svg>',
94+
"google-drive":
95+
'<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="18" rx="4" fill="white" stroke="#E5E7EB" stroke-width="0.5"/><path fill="#4285F4" d="M10.2 4.2 4.5 13.8h3.4l5.7-9.6z"/><path fill="#34A853" d="M10.2 4.2h3.4L9 12.1 7.3 9.2z"/><path fill="#FBBC04" d="M4.5 13.8 9 12.1 7.3 9.2 4.5 13.8z"/></svg>',
9096
}
9197

9298
export interface ConnectionEntry {
9399
id: string
94100
kind: "built-in" | "custom"
95101
name: string
96102
icon: { kind: "svg"; svg: string } | { kind: "letter"; letter: string }
97-
validator: "company-compute" | "pasqal" | "slack" | "github" | "linear" | "none"
103+
validator: "company-compute" | "pasqal" | "slack" | "github" | "linear" | "google" | "google-drive" | "none"
98104
authShape: "base-url-token" | "token-only" | "pasqal-credentials"
99105
url?: string
100106
}
@@ -140,6 +146,22 @@ export const BUILT_IN_CATALOG: ConnectionEntry[] = [
140146
validator: "linear",
141147
authShape: "token-only",
142148
},
149+
{
150+
id: "google",
151+
kind: "built-in",
152+
name: "Google",
153+
icon: { kind: "svg", svg: CONNECTION_ICONS["google"] },
154+
validator: "google",
155+
authShape: "token-only",
156+
},
157+
{
158+
id: "google-drive",
159+
kind: "built-in",
160+
name: "Google Drive",
161+
icon: { kind: "svg", svg: CONNECTION_ICONS["google-drive"] },
162+
validator: "google-drive",
163+
authShape: "token-only",
164+
},
143165
]
144166

145167
export function getBuiltInEntry(id: string): ConnectionEntry | undefined {
@@ -414,6 +436,7 @@ function renderStatus(
414436
if (icon) out.icon = icon
415437
const name = nameForId(id)
416438
if (name) out.name = name
439+
if (id === "google" || id === "google-drive") out.auth_methods = ["browser"]
417440
return out
418441
}
419442
let state: ConnectionState
@@ -448,6 +471,7 @@ function renderStatus(
448471
if (icon) out.icon = icon
449472
const name = nameForId(id)
450473
if (name) out.name = name
474+
if (id === "google" || id === "google-drive") out.auth_methods = ["browser"]
451475
return out
452476
}
453477

@@ -596,7 +620,8 @@ function kickStaleRevalidations(body: string, deps: { fetchImpl?: FetchImpl; pas
596620
try {
597621
if (id === "company-compute") await backgroundRevalidateCompanyCompute(deps)
598622
else if (id === "pasqal-cloud") await backgroundRevalidatePasqal(deps)
599-
else if (id === "slack" || id === "github" || id === "linear") await backgroundRevalidateToken(id, deps)
623+
else if (id === "slack" || id === "github" || id === "linear" || id === "google" || id === "google-drive")
624+
await backgroundRevalidateToken(id, deps)
600625
} catch {
601626
// background refresh must never surface trouble; the next GET retries
602627
}
@@ -680,6 +705,8 @@ async function backgroundRevalidateToken(id: ConnectionType, deps: { fetchImpl?:
680705
let probe: ProbeResult
681706
if (id === "slack") probe = await probeSlack(cred.token, deps.fetchImpl)
682707
else if (id === "github") probe = await probeGithub(cred.token, deps.fetchImpl)
708+
else if (id === "google") probe = await probeGoogle(cred.token, deps.fetchImpl)
709+
else if (id === "google-drive") probe = await probeGoogleDrive(cred.token, deps.fetchImpl)
683710
else probe = await probeLinear(cred.token, deps.fetchImpl)
684711
const existing = whitelistPersisted(readCacheFile(connectionsFile())[id])
685712
if (probe.outcome === "unreachable") {
@@ -830,6 +857,36 @@ export async function probeLinear(token: string, fetchImpl: FetchImpl = fetch):
830857
return { outcome: "unreachable" }
831858
}
832859

860+
export async function probeGoogle(token: string, fetchImpl: FetchImpl = fetch): Promise<ProbeResult> {
861+
let response: { status: number; json?: () => Promise<unknown> }
862+
try {
863+
response = await fetchImpl("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + encodeURIComponent(token), {
864+
method: "GET",
865+
headers: {},
866+
})
867+
} catch {
868+
return { outcome: "unreachable" }
869+
}
870+
if (response.status === 200) return { outcome: "valid" }
871+
if (response.status === 400 || response.status === 401 || response.status === 403) return { outcome: "invalid" }
872+
return { outcome: "unreachable" }
873+
}
874+
875+
export async function probeGoogleDrive(token: string, fetchImpl: FetchImpl = fetch): Promise<ProbeResult> {
876+
let response: { status: number; json?: () => Promise<unknown> }
877+
try {
878+
response = await fetchImpl("https://www.googleapis.com/drive/v3/about?fields=user", {
879+
method: "GET",
880+
headers: { authorization: `Bearer ${token}` },
881+
})
882+
} catch {
883+
return { outcome: "unreachable" }
884+
}
885+
if (response.status === 200) return { outcome: "valid" }
886+
if (response.status === 400 || response.status === 401 || response.status === 403) return { outcome: "invalid" }
887+
return { outcome: "unreachable" }
888+
}
889+
833890
// --- Pasqal validator spawn (amicode#169 / parent #159; #164 contract) ---
834891
// The fork never sees SDK internals: the validator's one-line JSON + exit-code
835892
// contract is the ENTIRE interface. Inputs ride env variables ONLY — never
@@ -1179,7 +1236,7 @@ export async function submitCredentialResponse(rawBody: string, deps: MutationDe
11791236
const body = parseMutationBody(rawBody)
11801237
if (!body) return synthesizeConnection("bad_request", "body must be JSON with an id and that id's credential fields")
11811238
if (body.id === "pasqal-cloud") return submitPasqalCredential(body, deps)
1182-
if (body.id === "slack" || body.id === "github" || body.id === "linear") {
1239+
if (body.id === "slack" || body.id === "github" || body.id === "linear" || body.id === "google" || body.id === "google-drive") {
11831240
return submitTokenCredential(body.id as ConnectionType, body, deps)
11841241
}
11851242
if (body.id !== "company-compute") {
@@ -1228,6 +1285,8 @@ async function submitTokenCredential(id: ConnectionType, body: MutationBody, dep
12281285
try {
12291286
if (id === "slack") probe = await probeSlack(token, deps.fetchImpl)
12301287
else if (id === "github") probe = await probeGithub(token, deps.fetchImpl)
1288+
else if (id === "google") probe = await probeGoogle(token, deps.fetchImpl)
1289+
else if (id === "google-drive") probe = await probeGoogleDrive(token, deps.fetchImpl)
12311290
else probe = await probeLinear(token, deps.fetchImpl)
12321291
} finally {
12331292
inflightOverlay.delete(id)
@@ -1583,7 +1642,7 @@ export async function revalidateResponse(rawBody: string, deps: MutationDeps = {
15831642
const id = parseIdBody(rawBody)
15841643
if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id")
15851644
if (id === "pasqal-cloud") return revalidatePasqal(deps)
1586-
if (id === "slack" || id === "github" || id === "linear") {
1645+
if (id === "slack" || id === "github" || id === "linear" || id === "google" || id === "google-drive") {
15871646
const cred = readCredential(id) as { token?: string } | undefined
15881647
if (!cred || typeof cred.token !== "string" || cred.token === "") {
15891648
clearStatus(id)
@@ -1594,6 +1653,8 @@ export async function revalidateResponse(rawBody: string, deps: MutationDeps = {
15941653
try {
15951654
if (id === "slack") probe = await probeSlack(cred.token, deps.fetchImpl)
15961655
else if (id === "github") probe = await probeGithub(cred.token, deps.fetchImpl)
1656+
else if (id === "google") probe = await probeGoogle(cred.token, deps.fetchImpl)
1657+
else if (id === "google-drive") probe = await probeGoogleDrive(cred.token, deps.fetchImpl)
15971658
else probe = await probeLinear(cred.token, deps.fetchImpl)
15981659
} finally {
15991660
inflightOverlay.delete(id)

packages/opencode/src/server/amicode/credentials.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { randomBytes } from "node:crypto"
1313
import { homedir } from "node:os"
1414
import path from "node:path"
1515

16-
export type BuiltInConnectionType = "company-compute" | "pasqal-cloud" | "slack" | "github" | "linear"
16+
export type BuiltInConnectionType = "company-compute" | "pasqal-cloud" | "slack" | "github" | "linear" | "google" | "google-drive"
1717
export type ConnectionType = BuiltInConnectionType | (string & {})
1818

1919
/** FROZEN byte shape — every existing CLI consumer parses this unchanged. */
@@ -61,6 +61,16 @@ export function linearFile(): string {
6161
if (env && env.trim() !== "") return env
6262
return path.join(homedir(), ".amico", "linear.json")
6363
}
64+
export function googleFile(): string {
65+
const env = process.env.AMICO_GOOGLE_FILE
66+
if (env && env.trim() !== "") return env
67+
return path.join(homedir(), ".amico", "google.json")
68+
}
69+
export function googleDriveFile(): string {
70+
const env = process.env.AMICO_GOOGLE_DRIVE_FILE
71+
if (env && env.trim() !== "") return env
72+
return path.join(homedir(), ".amico", "google-drive.json")
73+
}
6474

6575
// --- poison guard: writing any object carrying a password-like key through
6676
// this seam must be impossible. The encoders below are allowlist-only (they
@@ -169,6 +179,36 @@ const BACKENDS: Record<string, Backend> = {
169179
return { token: d.token }
170180
},
171181
},
182+
google: {
183+
file: googleFile,
184+
encode(value) {
185+
rejectPoisonKeys(value)
186+
const token = typeof value.token === "string" ? value.token.trim() : ""
187+
if (token === "") throw new Error('google credential needs non-empty "token"')
188+
return JSON.stringify({ token }, null, 2) + "\n"
189+
},
190+
decode(raw) {
191+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
192+
const d = raw as Record<string, unknown>
193+
if (typeof d.token !== "string" || d.token === "") return undefined
194+
return { token: d.token }
195+
},
196+
},
197+
"google-drive": {
198+
file: googleDriveFile,
199+
encode(value) {
200+
rejectPoisonKeys(value)
201+
const token = typeof value.token === "string" ? value.token.trim() : ""
202+
if (token === "") throw new Error('google-drive credential needs non-empty "token"')
203+
return JSON.stringify({ token }, null, 2) + "\n"
204+
},
205+
decode(raw) {
206+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
207+
const d = raw as Record<string, unknown>
208+
if (typeof d.token !== "string" || d.token === "") return undefined
209+
return { token: d.token }
210+
},
211+
},
172212
}
173213

174214
// --- atomic 0600-at-birth writer ---
@@ -205,6 +245,8 @@ export function readCredential(type: "pasqal-cloud"): PasqalCredential | undefin
205245
export function readCredential(type: "slack"): TokenCredential | undefined
206246
export function readCredential(type: "github"): TokenCredential | undefined
207247
export function readCredential(type: "linear"): TokenCredential | undefined
248+
export function readCredential(type: "google"): TokenCredential | undefined
249+
export function readCredential(type: "google-drive"): TokenCredential | undefined
208250
export function readCredential(type: string): Credential | undefined
209251
export function readCredential(type: ConnectionType): Credential | undefined
210252
export function readCredential(type: ConnectionType): Credential | undefined {
@@ -226,6 +268,8 @@ export function writeCredential(type: "pasqal-cloud", value: PasqalCredential, h
226268
export function writeCredential(type: "slack", value: TokenCredential, hooks?: WriteHooks): void
227269
export function writeCredential(type: "github", value: TokenCredential, hooks?: WriteHooks): void
228270
export function writeCredential(type: "linear", value: TokenCredential, hooks?: WriteHooks): void
271+
export function writeCredential(type: "google", value: TokenCredential, hooks?: WriteHooks): void
272+
export function writeCredential(type: "google-drive", value: TokenCredential, hooks?: WriteHooks): void
229273
export function writeCredential(type: string, value: Credential, hooks?: WriteHooks): void
230274
export function writeCredential(type: ConnectionType, value: Credential, hooks?: WriteHooks): void {
231275
const backend = BACKENDS[type]

0 commit comments

Comments
 (0)