feat(api): add command authentication attempts

This commit is contained in:
Dax Raad 2026-07-15 19:08:14 -04:00
commit b5177adb5b
18 changed files with 1138 additions and 16 deletions

View file

@ -29,6 +29,20 @@ const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
)
function eventually<A, E, R>(
effect: Effect.Effect<A, E, R>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, E | Error, R> {
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)
})
}
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
Effect.gen(function* () {
@ -151,6 +165,51 @@ describe("Integration", () => {
}),
)
it.live("runs command authentication and stores the final output line", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("company")
const methodID = Integration.MethodID.make("login")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: {
id: methodID,
type: "command",
label: "Log in",
command: [
process.execPath,
"-e",
'console.log("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
],
},
}),
)
const attempt = yield* integrations.command.connect({ integrationID, methodID, label: "Work" })
const pending = yield* eventually(
integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
(status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
)
expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login" })
expect(
yield* eventually(
integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
(status) => status.status === "complete",
),
).toEqual({ status: "complete", time: attempt.time })
expect(yield* credentials.list(integrationID)).toEqual([
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
}),
])
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service

View file

@ -196,6 +196,11 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
complete: unusedIntegration,
cancel: unusedIntegration,
},
command: {
connect: unusedIntegration,
status: unusedIntegration,
cancel: unusedIntegration,
},
}),
Layer.mock(Credential.Service, {}),
),

View file

@ -5,7 +5,12 @@ import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import type {
IntegrationCommandMethod,
IntegrationEnvMethod,
IntegrationKeyMethod,
IntegrationOAuthMethod,
} from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
@ -55,6 +60,11 @@ export function host(overrides: Overrides = {}): PluginContext {
complete: () => Effect.die("unused integration.oauth.complete"),
cancel: () => Effect.die("unused integration.oauth.cancel"),
},
command: {
connect: () => Effect.die("unused integration.command.connect"),
status: () => Effect.die("unused integration.command.status"),
cancel: () => Effect.die("unused integration.command.cancel"),
},
transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"),
connection: {
@ -200,6 +210,11 @@ export function integrationHost(integration: Integration.Interface): PluginConte
complete: () => Effect.die("unused integration.oauth.complete"),
cancel: () => Effect.die("unused integration.oauth.cancel"),
},
command: {
connect: () => Effect.die("unused integration.command.connect"),
status: () => Effect.die("unused integration.command.status"),
cancel: () => Effect.die("unused integration.command.cancel"),
},
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
@ -281,6 +296,17 @@ export function integrationHost(integration: Integration.Interface): PluginConte
})
return
}
if (input.method.type === "command") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: {
...input.method,
id: Integration.MethodID.make(input.method.id),
command: [...input.method.command],
},
})
return
}
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: input.method,
@ -296,6 +322,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
function method(value: Integration.Method) {
if (value.type === "env") return { type: value.type, names: [...value.names] }
if (value.type === "key") return { type: value.type, label: value.label }
if (value.type === "command") return { ...value, command: [...value.command] }
return {
type: value.type,
id: value.id,
@ -308,10 +335,17 @@ function method(value: Integration.Method) {
}
function internalMethod(
value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod,
value: IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod,
): Integration.Method {
if (value.type === "env") return value
if (value.type === "key") return value
if (value.type === "command") {
return {
...value,
id: Integration.MethodID.make(value.id),
command: [...value.command],
}
}
return {
...value,
id: Integration.MethodID.make(value.id),