mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-24 09:31:39 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a5bcd9df2 |
@@ -1,7 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
// Snowflake's built-in public OAuth client for local applications; its "secret" is the client ID itself.
|
||||
const clientID = "LOCAL_APPLICATION"
|
||||
const providerID = Provider.ID.make("snowflake-cortex")
|
||||
const integrationID = Integration.ID.make("snowflake-cortex")
|
||||
const browserMethodID = Integration.MethodID.make("browser")
|
||||
const accountPlaceholder = "${SNOWFLAKE_ACCOUNT}"
|
||||
|
||||
const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
type Token = typeof Token.Type
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
// Exported for testing: intercepts Cortex-specific request/response quirks.
|
||||
@@ -64,13 +84,163 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
const browser = (app: App.Info) =>
|
||||
({
|
||||
integrationID,
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (browser)",
|
||||
form: [
|
||||
{
|
||||
type: "string",
|
||||
key: "account",
|
||||
title: "Snowflake account identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
key: "role",
|
||||
title: "Snowflake role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (answer) =>
|
||||
Effect.gen(function* () {
|
||||
// Accept the bare identifier or a pasted account URL.
|
||||
const account = (typeof answer.account === "string" ? answer.account : "")
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\.snowflakecomputing\.com\/?$/, "")
|
||||
.replace(/\/+$/, "")
|
||||
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
|
||||
const role = typeof answer.role === "string" ? answer.role.trim() : ""
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== "/") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
// Validate state before reading anything else from the callback.
|
||||
if (url.searchParams.get("state") !== state) {
|
||||
const message = "Invalid OAuth state"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const value = url.searchParams.get("code")
|
||||
if (error || !value) {
|
||||
const message = error ?? "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
||||
})
|
||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
resume(
|
||||
address && typeof address === "object"
|
||||
? Effect.succeed(address.port)
|
||||
: Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port")),
|
||||
)
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://127.0.0.1:${port}/`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: `${issuer(account)}/oauth/authorize?${new URLSearchParams({
|
||||
client_id: clientID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
scope: scope(role),
|
||||
state,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})}`,
|
||||
instructions: "Complete Snowflake sign-in in your browser. This window will close automatically.",
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
token(
|
||||
account,
|
||||
{ grant_type: "authorization_code", code: value, redirect_uri: redirect, code_verifier: pkce.verifier },
|
||||
app,
|
||||
),
|
||||
),
|
||||
Effect.flatMap((tokens) => credential(tokens, account)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => {
|
||||
const account = accountOf(value.metadata)
|
||||
if (!account) return Effect.fail(new Error("Snowflake OAuth credential is missing its account"))
|
||||
return token(account, { grant_type: "refresh_token", refresh_token: value.refresh }, app).pipe(
|
||||
Effect.flatMap((tokens) => credential(tokens, account, value.refresh)),
|
||||
)
|
||||
},
|
||||
label: (value) => accountOf(value.metadata),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake.cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let account: string | undefined
|
||||
|
||||
const load = Effect.fn("SnowflakeCortexPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
account =
|
||||
credential?.type === "oauth" && credential.methodID === browserMethodID
|
||||
? accountOf(credential.metadata)
|
||||
: undefined
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update(browser(ctx.app))
|
||||
})
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item || !account) return
|
||||
// models.dev templates the endpoint on SNOWFLAKE_ACCOUNT; a browser login already knows the account.
|
||||
const baseURL = item.provider.settings?.baseURL
|
||||
if (typeof baseURL !== "string" || !baseURL.includes(accountPlaceholder)) return
|
||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, {
|
||||
baseURL: baseURL.replaceAll(accountPlaceholder, account),
|
||||
})
|
||||
})
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(() => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.make("snowflake-cortex")) return
|
||||
if (evt.model.providerID !== providerID) return
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
process.env.SNOWFLAKE_CORTEX_PAT ??
|
||||
@@ -88,3 +258,73 @@ export const SnowflakeCortexPlugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function issuer(account: string) {
|
||||
return `https://${account}.snowflakecomputing.com`
|
||||
}
|
||||
|
||||
function accountOf(metadata: Readonly<Record<string, unknown>> | undefined) {
|
||||
return typeof metadata?.account === "string" ? metadata.account : undefined
|
||||
}
|
||||
|
||||
// Snowflake only accepts plain role names inline; anything else must use the encoded scope form.
|
||||
function scope(role: string) {
|
||||
if (!role) return "refresh_token"
|
||||
if (/^[-_A-Za-z0-9]+$/.test(role)) return `refresh_token session:role:${role}`
|
||||
return `refresh_token session:role-encoded:${encodeURIComponent(role)}`
|
||||
}
|
||||
|
||||
function token(account: string, params: Record<string, string>, app: App.Info) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(`${issuer(account)}/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
|
||||
"User-Agent": App.useragent(app),
|
||||
},
|
||||
body: new URLSearchParams({ ...params, client_id: clientID }).toString(),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
return Schema.decodeUnknownSync(Token)(await response.json())
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: Token, account: string, currentRefresh?: string) {
|
||||
const refresh = tokens.refresh_token ?? currentRefresh
|
||||
if (!refresh) {
|
||||
return Effect.fail(
|
||||
new Error(
|
||||
"Snowflake token response did not include refresh_token. Ensure the security integration issues refresh tokens.",
|
||||
),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: browserMethodID,
|
||||
refresh,
|
||||
access: tokens.access_token,
|
||||
// Snowflake access tokens default to a ten minute lifetime.
|
||||
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
|
||||
metadata: { account },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(64)), (byte) => chars[byte % chars.length]).join("")
|
||||
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
|
||||
"base64url",
|
||||
)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -12,12 +15,54 @@ import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const integrationID = Integration.ID.make("snowflake-cortex")
|
||||
const providerID = Provider.ID.make("snowflake-cortex")
|
||||
const browserMethodID = Integration.MethodID.make("browser")
|
||||
const templateURL = "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1"
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* SnowflakeCortexPlugin.effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
const connect = (answer: Record<string, string>) =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID: browserMethodID, answer })
|
||||
return { attempt, url: new URL(attempt.url) }
|
||||
})
|
||||
|
||||
const seedCatalog = Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: templateURL }
|
||||
})
|
||||
editor.model.update(providerID, Model.ID.make("claude-sonnet-4-6"), () => {})
|
||||
})
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -50,6 +95,153 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers the browser OAuth method with account and role prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (browser)",
|
||||
form: [
|
||||
{
|
||||
type: "string",
|
||||
key: "account",
|
||||
title: "Snowflake account identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
required: true,
|
||||
},
|
||||
{ type: "string", key: "role", title: "Snowflake role (optional)", placeholder: "PUBLIC" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("authorizes against the account issuer with PKCE and a loopback redirect", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const { attempt, url } = yield* connect({
|
||||
account: "https://myorg-myaccount.snowflakecomputing.com/",
|
||||
role: "ANALYST",
|
||||
})
|
||||
expect(url.origin).toBe("https://myorg-myaccount.snowflakecomputing.com")
|
||||
expect(url.pathname).toBe("/oauth/authorize")
|
||||
expect(url.searchParams.get("client_id")).toBe("LOCAL_APPLICATION")
|
||||
expect(url.searchParams.get("response_type")).toBe("code")
|
||||
expect(url.searchParams.get("scope")).toBe("refresh_token session:role:ANALYST")
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
const redirect = new URL(required(url.searchParams.get("redirect_uri") ?? undefined))
|
||||
expect(redirect.hostname).toBe("127.0.0.1")
|
||||
expect(redirect.pathname).toBe("/")
|
||||
expect(attempt.mode).toBe("auto")
|
||||
|
||||
// The loopback listener is live and rejects callbacks that do not carry our state.
|
||||
const response = yield* Effect.promise(() => fetch(`${redirect.origin}/?code=stolen&state=forged`))
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => response.text())).toContain("Invalid OAuth state")
|
||||
const status = yield* eventually(
|
||||
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
|
||||
(status) => status.status === "failed",
|
||||
)
|
||||
expect(status).toMatchObject({ status: "failed", message: "Invalid OAuth state" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses the encoded role scope for roles that are not plain identifiers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const { attempt, url } = yield* connect({ account: "myorg-myaccount", role: "my role" })
|
||||
expect(url.searchParams.get("scope")).toBe("refresh_token session:role-encoded:my%20role")
|
||||
yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID })
|
||||
|
||||
const plain = yield* connect({ account: "myorg-myaccount" })
|
||||
expect(plain.url.searchParams.get("scope")).toBe("refresh_token")
|
||||
yield* integrations.oauth.cancel({ integrationID, attemptID: plain.attempt.attemptID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a blank account", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const error = yield* connect({ account: "https://.snowflakecomputing.com" }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Integration.AuthorizationError)
|
||||
expect(String((error as Integration.AuthorizationError).cause)).toContain("Snowflake account is required")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("expands the account into the Cortex endpoint under a browser login", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: browserMethodID,
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
metadata: { account: "myorg-myaccount" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings).toMatchObject({
|
||||
baseURL: "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1",
|
||||
})
|
||||
expect(required(yield* catalog.model.get(providerID, Model.ID.make("claude-sonnet-4-6"))).settings).toMatchObject(
|
||||
{ baseURL: "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the SNOWFLAKE_ACCOUNT template under an API key connection", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({ integrationID, value: Credential.Key.make({ type: "key", key: "pat" }) })
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings).toMatchObject({ baseURL: templateURL })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("re-expands the endpoint when the active connection changes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings).toMatchObject({ baseURL: templateURL })
|
||||
|
||||
// Far enough out that Integration does not try to refresh (and hit the network) on resolve.
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: browserMethodID,
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
metadata: { account: "switched-account" },
|
||||
}),
|
||||
})
|
||||
yield* eventually(
|
||||
catalog.provider.get(providerID),
|
||||
(provider) =>
|
||||
provider?.settings?.baseURL === "https://switched-account.snowflakecomputing.com/api/v2/cortex/v1",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-snowflake-cortex providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
Reference in New Issue
Block a user