feat(api): add command authentication attempts
This commit is contained in:
parent
a772767e9b
commit
b5177adb5b
18 changed files with 1138 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, {}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue