From dac0dd5309d0a960e76b2783074c965380c69fb6 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 11 Jun 2026 02:31:17 -0400 Subject: [PATCH] feat(core): add connector authentication (#31837) --- .../20260611035744_credential/migration.sql | 12 + .../20260611035744_credential/snapshot.json | 2190 +++++++++++++++++ packages/core/src/auth.ts | 340 --- packages/core/src/catalog.ts | 59 +- packages/core/src/connector.ts | 496 ++++ packages/core/src/connector/schema.ts | 9 + packages/core/src/credential.ts | 329 +++ packages/core/src/credential/sql.ts | 21 + packages/core/src/database/migration.gen.ts | 1 + .../migration/20260611035744_credential.ts | 23 + packages/core/src/location-layer.ts | 6 +- packages/core/src/plugin.ts | 8 - packages/core/src/plugin/account.ts | 45 - packages/core/src/plugin/agent.ts | 2 +- packages/core/src/plugin/boot.ts | 15 +- packages/core/src/plugin/models-dev.ts | 20 + .../plugin/provider/cloudflare-ai-gateway.ts | 2 +- .../plugin/provider/cloudflare-workers-ai.ts | 7 +- .../core/src/plugin/provider/openai-auth.ts | 251 ++ packages/core/src/plugin/provider/openai.ts | 7 + packages/core/src/plugin/provider/opencode.ts | 2 +- packages/core/src/provider.ts | 5 +- packages/core/test/account.test.ts | 284 --- packages/core/test/catalog.test.ts | 52 +- packages/core/test/connector.test.ts | 403 +++ packages/core/test/credential.test.ts | 207 ++ packages/core/test/location-layer.test.ts | 8 +- .../core/test/plugin/fixtures/models-dev.json | 14 + packages/core/test/plugin/models-dev.test.ts | 70 + .../core/test/plugin/provider-azure.test.ts | 33 +- .../provider-cloudflare-workers-ai.test.ts | 40 +- .../core/test/plugin/provider-gitlab.test.ts | 50 +- packages/core/test/plugin/provider-helper.ts | 9 + .../core/test/plugin/provider-openai.test.ts | 39 +- .../test/plugin/provider-opencode.test.ts | 5 +- .../test/server/httpapi-exercise/index.ts | 43 + .../server/httpapi-public-openapi.test.ts | 24 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 310 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 527 +++- packages/server/src/api.ts | 2 + packages/server/src/groups/connector.ts | 133 + packages/server/src/handlers.ts | 2 + packages/server/src/handlers/connector.ts | 102 + packages/tui/src/context/data.tsx | 26 +- packages/tui/src/context/event.ts | 3 +- packages/tui/test/cli/tui/data.test.tsx | 57 + packages/tui/test/fixture/tui-sdk.ts | 2 +- 47 files changed, 5433 insertions(+), 862 deletions(-) create mode 100644 packages/core/migration/20260611035744_credential/migration.sql create mode 100644 packages/core/migration/20260611035744_credential/snapshot.json delete mode 100644 packages/core/src/auth.ts create mode 100644 packages/core/src/connector.ts create mode 100644 packages/core/src/connector/schema.ts create mode 100644 packages/core/src/credential.ts create mode 100644 packages/core/src/credential/sql.ts create mode 100644 packages/core/src/database/migration/20260611035744_credential.ts delete mode 100644 packages/core/src/plugin/account.ts create mode 100644 packages/core/src/plugin/provider/openai-auth.ts delete mode 100644 packages/core/test/account.test.ts create mode 100644 packages/core/test/connector.test.ts create mode 100644 packages/core/test/credential.test.ts create mode 100644 packages/core/test/plugin/fixtures/models-dev.json create mode 100644 packages/core/test/plugin/models-dev.test.ts create mode 100644 packages/server/src/groups/connector.ts create mode 100644 packages/server/src/handlers/connector.ts diff --git a/packages/core/migration/20260611035744_credential/migration.sql b/packages/core/migration/20260611035744_credential/migration.sql new file mode 100644 index 0000000000..c950306e46 --- /dev/null +++ b/packages/core/migration/20260611035744_credential/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE `credential` ( + `id` text PRIMARY KEY, + `connector_id` text NOT NULL, + `method_id` text NOT NULL, + `label` text NOT NULL, + `value` text NOT NULL, + `active` integer DEFAULT false NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `credential_connector_active_idx` ON `credential` (`connector_id`) WHERE "credential"."active" = 1; diff --git a/packages/core/migration/20260611035744_credential/snapshot.json b/packages/core/migration/20260611035744_credential/snapshot.json new file mode 100644 index 0000000000..13cc247abf --- /dev/null +++ b/packages/core/migration/20260611035744_credential/snapshot.json @@ -0,0 +1,2190 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f25f9126-c7dc-4882-9ff4-af27e11d2da1", + "prevIds": [ + "d1bfa125-b81e-4c61-9b6e-e74abf6e488f" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "credential", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "connector_id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "method_id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "active", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'build'", + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "snapshot", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "credential_pk", + "table": "credential", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "connector_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"credential\".\"active\" = 1", + "origin": "manual", + "name": "credential_connector_active_idx", + "entityType": "indexes", + "table": "credential" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_admitted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_promoted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts deleted file mode 100644 index f5f23c6e54..0000000000 --- a/packages/core/src/auth.ts +++ /dev/null @@ -1,340 +0,0 @@ -export * as Auth from "./auth" - -import path from "path" -import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "./util/identifier" -import { NonNegativeInt, withStatics } from "./schema" -import { Global } from "./global" -import { FSUtil } from "./fs-util" -import { EventV2 } from "./event" - -export const ID = Schema.String.pipe( - Schema.brand("Auth.ID"), - withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) -export type ServiceID = typeof ServiceID.Type - -export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) -export type OrgID = typeof OrgID.Type -export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) -export type AccessToken = typeof AccessToken.Type -export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) -export type RefreshToken = typeof RefreshToken.Type - -export class OAuthCredential extends Schema.Class("Auth.OAuthCredential")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: NonNegativeInt, -}) {} - -export class ApiKeyCredential extends Schema.Class("Auth.ApiKeyCredential")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} - -export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ - identifier: "Auth.Credential", - }) -export type Credential = Schema.Schema.Type - -export class Info extends Schema.Class("Auth.Info")({ - id: ID, - serviceID: ServiceID, - description: Schema.String, - credential: Credential, -}) {} - -export class FileWriteError extends Schema.TaggedErrorClass()("Auth.FileWriteError", { - operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), - cause: Schema.Defect, -}) {} - -export type Error = FileWriteError - -export const Event = { - Added: EventV2.define({ - type: "account.added", - schema: { - account: Info, - }, - }), - Removed: EventV2.define({ - type: "account.removed", - schema: { - account: Info, - }, - }), - Switched: EventV2.define({ - type: "account.switched", - schema: { - serviceID: ServiceID, - from: Schema.optional(ID), - to: Schema.optional(ID), - }, - }), -} - -interface Writable { - version: 2 - accounts: Record - active: Record -} - -const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) - -function migrate(old: Record): Writable { - const accounts: Record = {} - const active: Record = {} - for (const [serviceID, value] of Object.entries(old)) { - const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) - const parsed = (decoded as Record)[serviceID] - if (!parsed) continue - const id = Identifier.ascending() - const account = ID.make(id) - const brandedServiceID = ServiceID.make(serviceID) - accounts[id] = new Info({ - id: account, - serviceID: brandedServiceID, - description: "default", - credential: parsed, - }) - active[brandedServiceID] = account - } - return { version: 2, accounts, active } -} - -export interface Interface { - readonly get: (id: ID) => Effect.Effect - readonly all: () => Effect.Effect - readonly create: (input: { - serviceID: ServiceID - credential: Credential - description?: string - }) => Effect.Effect - readonly update: (id: ID, updates: Partial>) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly activate: (id: ID) => Effect.Effect - readonly active: (serviceID: ServiceID) => Effect.Effect - readonly activeAll: () => Effect.Effect, Error> - readonly forService: (serviceID: ServiceID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Account") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* FSUtil.Service - const global = yield* Global.Service - const events = yield* EventV2.Service - const file = path.join(global.data, "account.json") - const legacyFile = path.join(global.data, "auth.json") - - const writeMigrated = Effect.fnUntraced(function* (raw: Record) { - const migrated = migrate(raw) - yield* fsys - .writeJson(file, migrated, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) - return migrated - }) - - const parseAuthContent = () => { - try { - return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") - } catch {} - } - - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.OPENCODE_AUTH_CONTENT) { - const raw = parseAuthContent() - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - return { version: 2, accounts: {}, active: {} } - } - - const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) - if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - - return { version: 2, accounts: {}, active: {} } - }) - - const write = (data: Writable) => - fsys - .writeJson(file, data, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) - - const state = SynchronizedRef.makeUnsafe( - yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), - ) - - const activate = Effect.fn("Auth.activate")(function* (id: ID) { - const data = yield* SynchronizedRef.get(state) - const account = data.accounts[id] - if (!account) return - const activated = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const nextAccount = data.accounts[id] - if (!nextAccount) return [undefined, data] as const - - const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } - yield* write(next) - return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const - }), - ) - if (activated) yield* events.publish(Event.Switched, activated) - }) - - const result: Interface = { - get: Effect.fn("Auth.get")(function* (id) { - return (yield* SynchronizedRef.get(state)).accounts[id] - }), - - all: Effect.fn("Auth.all")(function* () { - return Object.values((yield* SynchronizedRef.get(state)).accounts) - }), - - active: Effect.fn("Auth.active")(function* (serviceID) { - const data = yield* SynchronizedRef.get(state) - return ( - data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) - ) - }), - - activeAll: Effect.fn("Auth.activeAll")(function* () { - const data = yield* SynchronizedRef.get(state) - const result = new Map() - for (const account of Object.values(data.accounts)) { - if (!result.has(account.serviceID)) result.set(account.serviceID, account) - } - for (const [serviceID, id] of Object.entries(data.active)) { - const account = data.accounts[id] - if (account) result.set(ServiceID.make(serviceID), account) - } - return result - }), - - forService: Effect.fn("Auth.list")(function* (serviceID) { - return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) - }), - - create: Effect.fn("Auth.add")(function* (input) { - const id = ID.make(Identifier.ascending()) - const account = new Info({ - id, - serviceID: input.serviceID, - description: input.description ?? "default", - credential: input.credential, - }) - const added = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const next = { - ...data, - accounts: { ...data.accounts, [account.id]: account }, - active: { ...data.active, [account.serviceID]: account.id }, - } - - yield* write(next) - return [ - { - account, - switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, - }, - next, - ] as const - }), - ) - yield* events.publish(Event.Added, { account: added.account }) - yield* events.publish(Event.Switched, added.switched) - return added.account - }), - - update: Effect.fn("Auth.update")(function* (id, updates) { - const existing = (yield* SynchronizedRef.get(state)).accounts[id] - if (!existing) return - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - if (!data.accounts[id]) return [undefined, data] as const - - const next = { - ...data, - accounts: { - ...data.accounts, - [id]: new Info({ - id, - serviceID: existing.serviceID, - description: updates.description ?? existing.description, - credential: updates.credential ?? existing.credential, - }), - }, - } - - yield* write(next) - return [undefined, next] as const - }), - ) - }), - - remove: Effect.fn("Auth.remove")(function* (id) { - const removed = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const accounts = { ...data.accounts } - const active = { ...data.active } - const removed = accounts[id] - if (!removed) return [undefined, data] as const - const wasActive = active[removed.serviceID] === id - delete accounts[id] - const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) - if (wasActive) { - if (replacement) active[removed.serviceID] = replacement.id - else delete active[removed.serviceID] - } - - const next = { ...data, accounts, active } - yield* write(next) - return [ - { - account: removed, - switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, - }, - next, - ] as const - }), - ) - if (removed) { - yield* events.publish(Event.Removed, { account: removed.account }) - if (removed.switched) yield* events.publish(Event.Switched, removed.switched) - } - }), - - activate, - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index c44eec5d83..6d41dd2a65 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -10,6 +10,8 @@ import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" +import { Credential } from "./credential" +import { ConnectorSchema } from "./connector/schema" export type ProviderRecord = { provider: ProviderV2.Info @@ -94,10 +96,26 @@ export const layer = Layer.effect( const plugin = yield* PluginV2.Service const events = yield* EventV2.Service const policy = yield* Policy.Service + const credentials = yield* Credential.Service const scope = yield* Scope.Scope - const resolve = (model: ModelV2.Info) => { - const provider = state.get().providers.get(model.providerID)!.provider + const project = (provider: ProviderV2.Info, active: Map) => { + const credential = active.get(ConnectorSchema.ID.make(provider.id)) + if (!credential) return provider + const body = { ...provider.request.body } + if (credential.value.type === "key") { + body.apiKey = credential.value.key + Object.assign(body, credential.value.metadata ?? {}) + } + if (credential.value.type === "oauth") body.apiKey = credential.value.access + return new ProviderV2.Info({ + ...provider, + enabled: { via: "credential", credentialID: credential.id }, + request: { ...provider.request, body }, + }) + } + + const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 ? { ...provider.api, id: model.api.id } @@ -193,8 +211,7 @@ export const layer = Layer.effect( } }), }) - const available = (model: ModelV2.Info) => - state.get().providers.get(model.providerID)?.provider.enabled !== false && model.enabled + const active = () => credentials.activeAll().pipe(Effect.orDie) yield* events.subscribe(PluginV2.Event.Added).pipe( // Plugin registries are location scoped even though the event bus is process scoped. @@ -214,17 +231,16 @@ export const layer = Layer.effect( provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { const record = yield* getRecord(providerID) - return record.provider + return project(record.provider, yield* active()) }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) + const credentials = yield* active() + return Array.fromIterable(state.get().providers.values()).map((record) => project(record.provider, credentials)) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return Array.fromIterable(state.get().providers.values()) - .map((record) => record.provider) - .filter((provider) => provider.enabled) + return (yield* result.provider.all()).filter((provider) => provider.enabled) }), }, @@ -233,29 +249,35 @@ export const layer = Layer.effect( const record = yield* getRecord(providerID) const model = record.models.get(modelID) if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return resolve(model) + return resolve(model, project(record.provider, yield* active())) }), all: Effect.fn("CatalogV2.model.all")(function* () { + const credentials = yield* active() return pipe( Array.fromIterable(state.get().providers.values()), - Array.flatMap((record) => Array.fromIterable(record.models.values())), - Array.map(resolve), + Array.flatMap((record) => { + const provider = project(record.provider, credentials) + return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider)) + }), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) }), available: Effect.fn("CatalogV2.model.available")(function* () { - return (yield* result.model.all()).filter(available) + const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider])) + return (yield* result.model.all()).filter( + (model) => providers.get(model.providerID)?.enabled !== false && model.enabled, + ) }), default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { - const provider = state.get().providers.get(defaultModel.providerID)?.provider - if (provider?.enabled !== false) { + const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) + if (Option.isSome(provider) && provider.value.enabled !== false) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) - if (Option.isSome(model) && available(model.value)) return model + if (Option.isSome(model) && model.value.enabled) return model } } @@ -269,10 +291,11 @@ export const layer = Layer.effect( small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) if (!record) return Option.none() + const provider = project(record.provider, yield* active()) if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider)) } const candidates = pipe( @@ -300,7 +323,7 @@ export const layer = Layer.effect( return pipe( items, Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), - Array.map((item) => resolve(item.model)), + Array.map((item) => resolve(item.model, provider)), Array.head, ) } diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts new file mode 100644 index 0000000000..f17dbbd339 --- /dev/null +++ b/packages/core/src/connector.ts @@ -0,0 +1,496 @@ +export * as Connector from "./connector" + +import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" +import { Credential } from "./credential" +import { ConnectorSchema } from "./connector/schema" +import { withStatics } from "./schema" +import { State } from "./state" +import { Identifier } from "./util/identifier" +import { KeyedMutex } from "./effect/keyed-mutex" +import { EventV2 } from "./event" + +export const ID = ConnectorSchema.ID +export type ID = ConnectorSchema.ID + +export const MethodID = ConnectorSchema.MethodID +export type MethodID = ConnectorSchema.MethodID + +export const AttemptID = Schema.String.pipe( + Schema.brand("Connector.AttemptID"), + withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })), +) +export type AttemptID = typeof AttemptID.Type + +export const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}).annotate({ identifier: "Connector.When" }) +export type When = typeof When.Type + +export class TextPrompt extends Schema.Class("Connector.TextPrompt")({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: Schema.optional(Schema.String), + when: Schema.optional(When), +}) {} + +export class SelectPrompt extends Schema.Class("Connector.SelectPrompt")({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: Schema.optional(Schema.String), + }), + ), + when: Schema.optional(When), +}) {} + +export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) +export type Prompt = typeof Prompt.Type + +export class OAuthMethod extends Schema.Class("Connector.OAuthMethod")({ + id: MethodID, + type: Schema.Literal("oauth"), + label: Schema.String, + prompts: Schema.optional(Schema.Array(Prompt)), +}) {} + +export class KeyMethod extends Schema.Class("Connector.KeyMethod")({ + id: MethodID, + type: Schema.Literal("key"), + label: Schema.String, + prompts: Schema.optional(Schema.Array(Prompt)), +}) {} + +export const Method = Schema.Union([OAuthMethod, KeyMethod]).pipe(Schema.toTaggedUnion("type")) +export type Method = typeof Method.Type + +export class Info extends Schema.Class("Connector.Info")({ + id: ID, + name: Schema.String, + methods: Schema.Array(Method), +}) {} + +export type Inputs = Readonly<{ [key: string]: string }> + +export type OAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Effect.Effect + } + | { + readonly mode: "code" + readonly callback: (code: string) => Effect.Effect + } +) + +export interface OAuthImplementation { + readonly connectorID: ID + readonly method: OAuthMethod + readonly authorize: (inputs: Inputs) => Effect.Effect + readonly refresh?: (credential: Credential.OAuth) => Effect.Effect +} + +export interface KeyImplementation { + readonly connectorID: ID + readonly method: KeyMethod + readonly authorize: (key: string, inputs: Inputs) => Effect.Effect +} + +export type Implementation = OAuthImplementation | KeyImplementation + +function isKeyImplementation(implementation: Implementation): implementation is KeyImplementation { + return implementation.method.type === "key" +} + +function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation { + return implementation.method.type === "oauth" +} + +export class Attempt extends Schema.Class("Connector.Attempt")({ + attemptID: AttemptID, + url: Schema.String, + instructions: Schema.String, + mode: Schema.Literals(["auto", "code"]), + time: Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, + }), +}) {} + +const Time = Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, +}) + +export const AttemptStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("pending"), time: Time }), + Schema.Struct({ status: Schema.Literal("complete"), time: Time }), + Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }), + Schema.Struct({ status: Schema.Literal("expired"), time: Time }), +]).pipe(Schema.toTaggedUnion("status")) +export type AttemptStatus = typeof AttemptStatus.Type + +export class CodeRequiredError extends Schema.TaggedErrorClass()("Connector.CodeRequired", { + attemptID: AttemptID, +}) {} + +export class AuthorizationError extends Schema.TaggedErrorClass()("Connector.Authorization", { + cause: Schema.Defect, +}) {} + +export type Error = CodeRequiredError | AuthorizationError + +export const Event = { + Updated: EventV2.define({ + type: "connector.updated", + schema: {}, + }), +} + +type Entry = { + connector: Info + implementations: Map +} + +type Data = { + connectors: Map +} + +export type Editor = { + list: () => readonly Info[] + get: (id: ID) => Info | undefined + update: (id: ID, update: (connector: Draft>) => void) => void + remove: (id: ID) => void + method: { + update: (implementation: Implementation) => void + remove: (connectorID: ID, methodID: MethodID) => void + } +} + +export interface Interface { + /** Registers a scoped transform over the connector registry. */ + readonly transform: State.Interface["transform"] + /** Registers and immediately applies a scoped connector registry update. */ + readonly update: State.Interface["update"] + /** Returns one connector with its serializable login methods. */ + readonly get: (id: ID) => Effect.Effect + /** Returns all connectors with their serializable login methods. */ + readonly list: () => Effect.Effect + /** Refreshes an OAuth credential with its originating method. */ + readonly refresh: (credentialID: Credential.ID) => Effect.Effect + readonly connect: { + /** Runs a key method and stores the resulting credential. */ + readonly key: (input: { + /** Connector receiving the credential. */ + readonly connectorID: ID + /** Key method selected by the caller. */ + readonly methodID: MethodID + /** Secret entered by the user. */ + readonly key: string + /** Answers to the method's optional prompts. */ + readonly inputs: Inputs + /** User-facing label for the stored credential. */ + readonly label?: string + }) => Effect.Effect + readonly oauth: { + /** Starts a stateful OAuth attempt. */ + readonly begin: (input: { + /** Connector being authenticated. */ + readonly connectorID: ID + /** OAuth method selected by the caller. */ + readonly methodID: MethodID + /** Answers to the method's optional prompts. */ + readonly inputs: Inputs + /** User-facing label for the credential created on completion. */ + readonly label?: string + }) => Effect.Effect< + Attempt, + AuthorizationError + > + /** Returns the current state of an OAuth attempt. */ + readonly status: (attemptID: AttemptID) => Effect.Effect + /** Completes the attempt and stores its credential. */ + readonly complete: (input: { + /** Opaque handle returned by `begin`. */ + readonly attemptID: AttemptID + /** Authorization code required by attempts in code mode. */ + readonly code?: string + }) => Effect.Effect + /** Cancels an attempt and releases its resources. */ + readonly cancel: (attemptID: AttemptID) => Effect.Effect + } + } +} + +export class Service extends Context.Service()("@opencode/v2/Connector") {} + +enableMapSet() + +const attemptLifetime = Duration.toMillis(Duration.minutes(10)) +const terminalRetention = Duration.toMillis(Duration.minutes(1)) +const scrubInterval = Duration.seconds(30) + +type AttemptTime = { created: number; expires: number } +type PendingAttempt = { + status: "pending" + completing: boolean + authorization: OAuthAuthorization + connectorID: ID + methodID: MethodID + label?: string + scope: Scope.Closeable + time: AttemptTime +} +type TerminalAttempt = { + status: "complete" | "failed" | "expired" + message?: string + removeAt: number + time: AttemptTime +} +type AttemptEntry = PendingAttempt | TerminalAttempt + +export const locationLayer = Layer.effect( + Service, + Effect.gen(function* () { + const credentials = yield* Credential.Service + const events = yield* EventV2.Service + const scope = yield* Scope.Scope + const attempts = SynchronizedRef.makeUnsafe(new Map()) + const refreshLocks = KeyedMutex.makeUnsafe() + const state = State.create({ + initial: () => ({ connectors: new Map() }), + editor: (draft) => ({ + list: () => Array.from(draft.connectors.values(), (entry) => entry.connector) as Info[], + get: (id) => draft.connectors.get(id)?.connector as Info | undefined, + update: (id, update) => { + const current = draft.connectors.get(id) ?? + castDraft({ connector: new Info({ id, name: id, methods: [] }), implementations: new Map() }) + if (!draft.connectors.has(id)) draft.connectors.set(id, current) + update(current.connector) + current.connector.id = id + }, + remove: (id) => draft.connectors.delete(id), + method: { + update: (implementation) => { + const current = draft.connectors.get(implementation.connectorID) ?? + castDraft({ + connector: new Info({ id: implementation.connectorID, name: implementation.connectorID, methods: [] }), + implementations: new Map(), + }) + if (!draft.connectors.has(implementation.connectorID)) { + draft.connectors.set(implementation.connectorID, current) + } + const index = current.connector.methods.findIndex((method) => method.id === implementation.method.id) + if (index === -1) current.connector.methods.push(castDraft(implementation.method)) + else current.connector.methods[index] = castDraft(implementation.method) + current.implementations.set(implementation.method.id, castDraft(implementation)) + }, + remove: (connectorID, methodID) => { + const current = draft.connectors.get(connectorID) + if (!current) return + const index = current.connector.methods.findIndex((method) => method.id === methodID) + if (index !== -1) current.connector.methods.splice(index, 1) + current.implementations.delete(methodID) + }, + }, + }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), + }) + + const authorize = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause }))) + + const close = (attemptScope: Scope.Closeable) => + Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + + const message = (cause: Cause.Cause) => { + const error = Cause.squash(cause) + return error instanceof Error ? error.message : String(error) + } + + const settle = Effect.fnUntraced(function* ( + attemptID: AttemptID, + exit: Exit.Exit, + ) { + const now = yield* Clock.currentTimeMillis + const result = yield* SynchronizedRef.modify(attempts, (current) => { + const attempt = current.get(attemptID) + if (!attempt || attempt.status !== "pending") return [undefined, current] + const terminal: TerminalAttempt = Exit.isSuccess(exit) + ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention } + : { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention } + return [attempt, new Map(current).set(attemptID, terminal)] + }) + if (!result) return + if (Exit.isSuccess(exit)) { + yield* credentials.create({ + connectorID: result.connectorID, + methodID: result.methodID, + label: result.label, + value: exit.value, + }) + } + yield* close(result.scope) + }) + + const scrub = Effect.fnUntraced(function* () { + const now = yield* Clock.currentTimeMillis + const expired = yield* SynchronizedRef.modify(attempts, (current) => { + const next = new Map(current) + const scopes: Scope.Closeable[] = [] + for (const [id, attempt] of current) { + if (attempt.status === "pending" && attempt.time.expires <= now) { + scopes.push(attempt.scope) + next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) + continue + } + if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id) + } + return [scopes, next] + }) + yield* Effect.forEach(expired, close, { discard: true }) + }) + + yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope)) + + return Service.of({ + transform: state.transform, + update: state.update, + get: Effect.fn("Connector.get")(function* (id) { + return state.get().connectors.get(id)?.connector + }), + list: Effect.fn("Connector.list")(function* () { + return Array.from(state.get().connectors.values(), (record) => record.connector).toSorted((a, b) => + a.name.localeCompare(b.name), + ) + }), + refresh: Effect.fn("Connector.refresh")(function* (credentialID) { + yield* refreshLocks.withLock(credentialID)( + Effect.gen(function* () { + const credential = yield* credentials.get(credentialID) + if (!credential || credential.value.type !== "oauth") { + return yield* Effect.die(`OAuth credential not found: ${credentialID}`) + } + const implementation = state + .get() + .connectors.get(credential.connectorID) + ?.implementations.get(credential.methodID) + if (!implementation || !isOAuthImplementation(implementation) || !implementation.refresh) { + return yield* Effect.die( + `OAuth refresh method not found: ${credential.connectorID}/${credential.methodID}`, + ) + } + const value = yield* authorize(implementation.refresh(credential.value)) + yield* credentials.update(credential.id, { value }) + }), + ) + }), + connect: { + key: Effect.fn("Connector.connect.key")(function* (input) { + const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID) + if (!method || !isKeyImplementation(method)) { + return yield* Effect.die(`Key method not found: ${input.connectorID}/${input.methodID}`) + } + const value = yield* authorize(method.authorize(input.key, input.inputs)) + yield* credentials.create({ + connectorID: input.connectorID, + methodID: input.methodID, + label: input.label, + value, + }) + }), + oauth: { + begin: Effect.fn("Connector.connect.oauth.begin")(function* (input) { + const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID) + if (!method || !isOAuthImplementation(method)) { + return yield* Effect.die(`OAuth method not found: ${input.connectorID}/${input.methodID}`) + } + const attemptScope = yield* Scope.fork(scope) + const authorization = yield* authorize(method.authorize(input.inputs)).pipe( + Scope.provide(attemptScope), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), + ) + const id = AttemptID.create() + const created = yield* Clock.currentTimeMillis + const time = { created, expires: created + attemptLifetime } + yield* SynchronizedRef.update(attempts, (current) => + new Map(current).set(id, { + status: "pending", + completing: authorization.mode === "auto", + authorization, + connectorID: input.connectorID, + methodID: input.methodID, + label: input.label, + scope: attemptScope, + time, + }), + ) + if (authorization.mode === "auto") { + yield* authorization.callback.pipe( + Effect.exit, + Effect.flatMap((exit) => settle(id, exit)), + Effect.forkIn(attemptScope, { startImmediately: true }), + ) + } + return new Attempt({ + attemptID: id, + url: authorization.url, + instructions: authorization.instructions, + mode: authorization.mode, + time, + }) + }), + status: Effect.fn("Connector.connect.oauth.status")(function* (attemptID) { + const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) + if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`) + if (attempt.status === "failed") { + return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } + } + return { status: attempt.status, time: attempt.time } + }), + complete: Effect.fn("Connector.connect.oauth.complete")(function* (input) { + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(input.attemptID) + if (!match || match.status !== "pending" || match.completing) return [match, current] + if (match.authorization.mode === "code" && input.code === undefined) return [match, current] + return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] + }) + if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`) + if (attempt.status !== "pending") return + if (attempt.authorization.mode === "code" && input.code === undefined) { + return yield* new CodeRequiredError({ attemptID: input.attemptID }) + } + if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`) + const callback = + attempt.authorization.mode === "auto" + ? attempt.authorization.callback + : attempt.authorization.callback(input.code as string) + const exit = yield* authorize(callback).pipe(Effect.exit) + yield* settle(input.attemptID, exit) + if (Exit.isFailure(exit)) return yield* exit + }), + cancel: Effect.fn("Connector.connect.oauth.cancel")(function* (attemptID) { + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(attemptID) + if (!match || match.status !== "pending") return [undefined, current] + const next = new Map(current) + next.delete(attemptID) + return [match, next] + }) + if (attempt) yield* Scope.close(attempt.scope, Exit.void) + }), + }, + }, + }) + }), +) diff --git a/packages/core/src/connector/schema.ts b/packages/core/src/connector/schema.ts new file mode 100644 index 0000000000..40c43faab8 --- /dev/null +++ b/packages/core/src/connector/schema.ts @@ -0,0 +1,9 @@ +export * as ConnectorSchema from "./schema" + +import { Schema } from "effect" + +export const ID = Schema.String.pipe(Schema.brand("Connector.ID")) +export type ID = typeof ID.Type + +export const MethodID = Schema.String.pipe(Schema.brand("Connector.MethodID")) +export type MethodID = typeof MethodID.Type diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts new file mode 100644 index 0000000000..8c39756746 --- /dev/null +++ b/packages/core/src/credential.ts @@ -0,0 +1,329 @@ +export * as Credential from "./credential" + +import { and, asc, eq, ne } from "drizzle-orm" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { Database } from "./database/database" +import { ConnectorSchema } from "./connector/schema" +import { EventV2 } from "./event" +import { NonNegativeInt, withStatics } from "./schema" +import { CredentialTable } from "./credential/sql" +import { Identifier } from "./util/identifier" +import { FSUtil } from "./fs-util" +import { Global } from "./global" +import { DataMigrationTable } from "./data-migration.sql" +import path from "path" + +export const ID = Schema.String.pipe( + Schema.brand("Credential.ID"), + withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export class OAuth extends Schema.Class("Credential.OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class Key extends Schema.Class("Credential.Key")({ + type: Schema.Literal("key"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Value = Schema.Union([OAuth, Key]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type + +const LegacyOAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) + +const LegacyKey = Schema.Struct({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) + +const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey]) + +export class Info extends Schema.Class("Credential.Info")({ + id: ID, + connectorID: ConnectorSchema.ID, + methodID: ConnectorSchema.MethodID, + label: Schema.String, + value: Value, +}) {} + +export const Event = { + Added: EventV2.define({ + type: "credential.added", + schema: { credential: Info }, + }), + Removed: EventV2.define({ + type: "credential.removed", + schema: { credential: Info }, + }), + Switched: EventV2.define({ + type: "credential.switched", + schema: { + connectorID: ConnectorSchema.ID, + from: Schema.optional(ID), + to: Schema.optional(ID), + }, + }), +} + +export interface Interface { + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + connectorID: ConnectorSchema.ID + methodID: ConnectorSchema.MethodID + value: Value + label?: string + }) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly activate: (id: ID) => Effect.Effect + readonly active: (connectorID: ConnectorSchema.ID) => Effect.Effect + readonly activeAll: () => Effect.Effect> + readonly forConnector: (connectorID: ConnectorSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Credential") {} + +export const legacyImportLayer = Layer.effectDiscard( + Effect.gen(function* () { + const { db } = yield* Database.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const name = "credential.auth-json" + if (yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get()) return + const raw = yield* fs.readJson(path.join(global.data, "auth.json")).pipe(Effect.option) + if (Option.isNone(raw) || typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)) return + const decode = Schema.decodeUnknownOption(LegacyValue) + const values = Object.entries(raw.value).flatMap(([connectorID, value]) => { + const decoded = decode(value) + if (Option.isNone(decoded)) return [] + const credential = decoded.value + const id = ID.create() + const connector = ConnectorSchema.ID.make(connectorID.replace(/\/+$/, "")) + const methodID = ConnectorSchema.MethodID.make( + credential.type === "api" ? "api-key" : connector === ConnectorSchema.ID.make("openai") ? "chatgpt-browser" : "oauth", + ) + const next: Value = + credential.type === "api" + ? new Key({ type: "key", key: credential.key, metadata: credential.metadata }) + : new OAuth({ + type: "oauth", + refresh: credential.refresh, + access: credential.access, + expires: credential.expires, + metadata: { + ...(credential.accountId ? { accountID: credential.accountId } : {}), + ...(credential.enterpriseUrl ? { enterpriseURL: credential.enterpriseUrl } : {}), + }, + }) + return [{ id, connectorID: connector, methodID, value: next }] + }) + yield* db.transaction((tx) => + Effect.gen(function* () { + for (const item of values) { + if ( + yield* tx + .select({ id: CredentialTable.id }) + .from(CredentialTable) + .where(eq(CredentialTable.connector_id, item.connectorID)) + .get() + ) + continue + yield* tx.insert(CredentialTable).values({ + id: item.id, + connector_id: item.connectorID, + method_id: item.methodID, + label: "Imported", + value: item.value, + active: true, + }) + } + yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run() + }), + ) + }).pipe(Effect.orDie), +) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const events = yield* EventV2.Service + const decodeValue = Schema.decodeUnknownSync(Value) + const info = (row: typeof CredentialTable.$inferSelect) => + new Info({ + id: row.id, + connectorID: row.connector_id, + methodID: row.method_id, + label: row.label, + value: decodeValue(row.value), + }) + + const activate = Effect.fn("Credential.activate")(function* (id: ID) { + const switched = yield* db + .transaction((tx) => + Effect.gen(function* () { + const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + if (!credential || credential.active) return + const current = yield* tx + .select({ id: CredentialTable.id }) + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, credential.connector_id), eq(CredentialTable.active, true))) + .get() + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.connector_id, credential.connector_id)) + .run() + yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run() + return { connectorID: credential.connector_id, from: current?.id, to: id } + }), + ) + .pipe(Effect.orDie) + if (switched) yield* events.publish(Event.Switched, switched) + }) + + return Service.of({ + get: Effect.fn("Credential.get")(function* (id) { + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) + return row ? info(row) : undefined + }), + all: Effect.fn("Credential.all")(function* () { + return (yield* db.select().from(CredentialTable).orderBy(asc(CredentialTable.time_created)).all().pipe(Effect.orDie)).map( + info, + ) + }), + active: Effect.fn("Credential.active")(function* (connectorID) { + const row = yield* db + .select() + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, connectorID), eq(CredentialTable.active, true))) + .get() + .pipe(Effect.orDie) + return row ? info(row) : undefined + }), + activeAll: Effect.fn("Credential.activeAll")(function* () { + const rows = yield* db.select().from(CredentialTable).where(eq(CredentialTable.active, true)).all().pipe(Effect.orDie) + return new Map(rows.map((row) => [row.connector_id, info(row)])) + }), + forConnector: Effect.fn("Credential.forConnector")(function* (connectorID) { + return ( + yield* db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.connector_id, connectorID)) + .orderBy(asc(CredentialTable.time_created)) + .all() + .pipe(Effect.orDie) + ).map(info) + }), + create: Effect.fn("Credential.create")(function* (input) { + const credential = new Info({ + id: ID.create(), + connectorID: input.connectorID, + methodID: input.methodID, + label: input.label ?? "default", + value: input.value, + }) + const from = yield* db + .transaction((tx) => + Effect.gen(function* () { + const current = yield* tx + .select({ id: CredentialTable.id }) + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, input.connectorID), eq(CredentialTable.active, true))) + .get() + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.connector_id, input.connectorID)) + .run() + yield* tx + .insert(CredentialTable) + .values({ + id: credential.id, + connector_id: credential.connectorID, + method_id: credential.methodID, + label: credential.label, + value: credential.value, + active: true, + }) + .run() + return current?.id + }), + ) + .pipe(Effect.orDie) + yield* events.publish(Event.Added, { credential }) + yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: credential.id }) + return credential + }), + update: Effect.fn("Credential.update")(function* (id, updates) { + if (!updates.label && !updates.value) return + yield* db + .update(CredentialTable) + .set({ label: updates.label, value: updates.value }) + .where(eq(CredentialTable.id, id)) + .run() + .pipe(Effect.orDie) + }), + remove: Effect.fn("Credential.remove")(function* (id) { + const removed = yield* db + .transaction((tx) => + Effect.gen(function* () { + const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + if (!row) return + yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run() + if (!row.active) return { credential: info(row) } + const replacement = yield* tx + .select() + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, row.connector_id), ne(CredentialTable.id, id))) + .orderBy(asc(CredentialTable.time_created)) + .get() + if (replacement) { + yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, replacement.id)).run() + } + return { + credential: info(row), + switched: { connectorID: row.connector_id, from: id, to: replacement?.id }, + } + }), + ) + .pipe(Effect.orDie) + if (!removed) return + yield* events.publish(Event.Removed, { credential: removed.credential }) + if (removed.switched) yield* events.publish(Event.Switched, removed.switched) + }), + activate, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2.defaultLayer), + Layer.provideMerge( + legacyImportLayer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), + ), + ), +) diff --git a/packages/core/src/credential/sql.ts b/packages/core/src/credential/sql.ts new file mode 100644 index 0000000000..c67ff67b1b --- /dev/null +++ b/packages/core/src/credential/sql.ts @@ -0,0 +1,21 @@ +import { sql } from "drizzle-orm" +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import type { ConnectorSchema } from "../connector/schema" +import type { Credential } from "../credential" + +export const CredentialTable = sqliteTable( + "credential", + { + id: text().$type().primaryKey(), + connector_id: text().$type().notNull(), + method_id: text().$type().notNull(), + label: text().notNull(), + value: text({ mode: "json" }).$type().notNull(), + active: integer({ mode: "boolean" }).notNull().default(false), + ...Timestamps, + }, + (table) => [ + uniqueIndex("credential_connector_active_idx").on(table.connector_id).where(sql`${table.active} = 1`), + ], +) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index a7e9dd132e..29dd8bfabe 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -34,5 +34,6 @@ export const migrations = ( import("./migration/20260604172448_event_sourced_session_input"), import("./migration/20260605003541_add_session_context_snapshot"), import("./migration/20260605042240_add_context_epoch_agent"), + import("./migration/20260611035744_credential"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260611035744_credential.ts b/packages/core/src/database/migration/20260611035744_credential.ts new file mode 100644 index 0000000000..71660cb463 --- /dev/null +++ b/packages/core/src/database/migration/20260611035744_credential.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260611035744_credential", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`credential\` ( + \`id\` text PRIMARY KEY, + \`connector_id\` text NOT NULL, + \`method_id\` text NOT NULL, + \`label\` text NOT NULL, + \`value\` text NOT NULL, + \`active\` integer DEFAULT false NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run(`CREATE UNIQUE INDEX \`credential_connector_active_idx\` ON \`credential\` (\`connector_id\`) WHERE "credential"."active" = 1;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 2bf32fd755..39a3948a59 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,12 +4,13 @@ import { Policy } from "./policy" import { Config } from "./config" import { PluginV2 } from "./plugin" import { Catalog } from "./catalog" +import { Connector } from "./connector" import { CommandV2 } from "./command" import { AgentV2 } from "./agent" import { PluginBoot } from "./plugin/boot" import { Project } from "./project" import { EventV2 } from "./event" -import { Auth } from "./auth" +import { Credential } from "./credential" import { Npm } from "./npm" import { ModelsDev } from "./models-dev" import { FSUtil } from "./fs-util" @@ -58,6 +59,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Reference.locationLayer, PluginV2.locationLayer, Catalog.locationLayer, + Connector.locationLayer, CommandV2.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, @@ -114,7 +116,7 @@ export class LocationServiceMap extends LayerMap.Service()(" dependencies: [ Project.defaultLayer, EventV2.defaultLayer, - Auth.defaultLayer, + Credential.defaultLayer, Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 9cd9d6820a..aaef65d322 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -25,14 +25,6 @@ type HookSpec = { input: Catalog.Editor output: {} } - "account.switched": { - input: { - serviceID: import("./auth").Auth.ServiceID - from?: import("./auth").Auth.ID - to?: import("./auth").Auth.ID - } - output: {} - } "aisdk.language": { input: { model: ModelV2.Info diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts deleted file mode 100644 index 98c9ef7a3f..0000000000 --- a/packages/core/src/plugin/account.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Effect, Scope, Stream } from "effect" -import { EventV2 } from "../event" -import { PluginV2 } from "../plugin" -import { Auth } from "../auth" - -// Depending on what account is active, enable matching providers for that -// service -export const AccountPlugin = PluginV2.define({ - id: PluginV2.ID.make("account"), - effect: Effect.gen(function* () { - const accounts = yield* Auth.Service - const events = yield* EventV2.Service - const scope = yield* Scope.Scope - - yield* events.subscribe(Auth.Event.Switched).pipe( - Stream.runForEach((event) => - PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid), - ), - Effect.forkIn(scope, { startImmediately: true }), - ) - - return { - "catalog.transform": Effect.fn(function* (evt) { - const active = yield* accounts.activeAll().pipe(Effect.orDie) - if (active.size === 0) return - for (const item of evt.provider.list()) { - const account = active.get(Auth.ServiceID.make(item.provider.id)) - if (!account) continue - evt.provider.update(item.provider.id, (provider) => { - provider.enabled = { - via: "account", - service: account.serviceID, - } - if (account.credential.type === "api") { - provider.request.body.apiKey = account.credential.key - Object.assign(provider.request.body, account.credential.metadata ?? {}) - } - if (account.credential.type === "oauth") provider.request.body.apiKey = account.credential.access - }) - } - }), - "account.switched": Effect.fn(function* () {}), - } - }), -}) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 1e4dee5b32..e8a8d8bc9d 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -79,7 +79,7 @@ Your output must be: "implement rate limiting" -> Rate limiting implementation "how do I connect postgres to my API" -> Postgres API connection "best practices for React hooks" -> React hooks best practices -"@src/auth.ts can you add refresh token support" -> Auth refresh token support +"@src/credential.ts can you add refresh token support" -> Credential refresh token support "@utils/parser.ts this is broken" -> Parser bug fix "look at @config.json" -> Config review "@App.tsx add dark mode toggle" -> Dark mode toggle in App diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index b59769a0cf..89b6a5c2bf 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,7 +1,8 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" -import { Auth } from "../auth" +import { Credential } from "../credential" +import { Connector } from "../connector" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" @@ -17,7 +18,6 @@ import { Location } from "../location" import { ModelsDev } from "../models-dev" import { Npm } from "../npm" import { PluginV2 } from "../plugin" -import { AccountPlugin } from "./account" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { SkillPlugin } from "./skill" @@ -33,7 +33,8 @@ type Plugin = { effect: PluginV2.Effect< | Catalog.Service | CommandV2.Service - | Auth.Service + | Credential.Service + | Connector.Service | AgentV2.Service | Npm.Service | EventV2.Service @@ -60,7 +61,8 @@ export const layer = Layer.effect( const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service - const accounts = yield* Auth.Service + const credentials = yield* Credential.Service + const connectors = yield* Connector.Service const agents = yield* AgentV2.Service const config = yield* Config.Service const location = yield* Location.Service @@ -79,7 +81,8 @@ export const layer = Layer.effect( effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Auth.Service, accounts), + Effect.provideService(Credential.Service, credentials), + Effect.provideService(Connector.Service, connectors), Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config), Effect.provideService(Location.Service, location), @@ -97,7 +100,6 @@ export const layer = Layer.effect( const boot = Effect.gen(function* () { yield* add(EnvPlugin) - yield* add(AccountPlugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) @@ -125,6 +127,7 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe( + Layer.provideMerge(Connector.locationLayer), Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(CommandV2.locationLayer), Layer.provideMerge(Config.locationLayer), diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 6424c6b6ab..b406a1aaf0 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,7 @@ import { DateTime, Effect, Scope, Stream } from "effect" import { Catalog } from "../catalog" +import { Connector } from "../connector" +import { Credential } from "../credential" import { EventV2 } from "../event" import { ModelV2 } from "../model" import { ModelRequest } from "../model-request" @@ -54,12 +56,30 @@ export const ModelsDevPlugin = PluginV2.define({ id: PluginV2.ID.make("models-dev"), effect: Effect.gen(function* () { const catalog = yield* Catalog.Service + const connectors = yield* Connector.Service const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope const transform = yield* catalog.transform() + const connectorTransform = yield* connectors.transform() const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { const data = yield* modelsDev.get() + yield* connectorTransform((connectors) => { + for (const item of Object.values(data)) { + if (item.env.length === 0) continue + const connectorID = Connector.ID.make(item.id) + connectors.update(connectorID, (connector) => (connector.name = item.name)) + connectors.method.update({ + connectorID, + method: new Connector.KeyMethod({ + id: Connector.MethodID.make("api-key"), + type: "key", + label: "API Key", + }), + authorize: (key: string) => Effect.succeed(new Credential.Key({ type: "key", key })), + }) + } + }) yield* transform((catalog) => { for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index 1cd974ee95..afb3a183e5 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) function gatewayConfig(options: Record): GatewayConfig | undefined { const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // AccountPlugin copies CLI prompt metadata into options. The prompt stores the + // Credential projection copies key metadata into options. The prompt stores the // gateway as gatewayId, while older config examples may use gateway. const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index ca19e63c01..9bcd2684b1 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -24,9 +24,12 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return - if (!hasWorkersEndpoint(evt.model.api)) return + const accountId = resolveAccountId(evt.options) + if (!hasWorkersEndpoint(evt.model.api) && !accountId) return const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) - evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any) + evt.sdk = mod.createOpenAICompatible( + sdkOptions({ ...evt.options, baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined) }) as any, + ) }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts new file mode 100644 index 0000000000..ef43174aaf --- /dev/null +++ b/packages/core/src/plugin/provider/openai-auth.ts @@ -0,0 +1,251 @@ +import { createServer } from "node:http" +import { Deferred, Effect } from "effect" +import { Connector } from "../../connector" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" + +const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" +const issuer = "https://auth.openai.com" +const callbackPort = 1455 +const pollingSafetyMargin = 3000 + +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + expires_in?: number +} + +type Claims = { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + "https://api.openai.com/auth"?: { chatgpt_account_id?: string } +} + +export const browser = { + connectorID: Connector.ID.make("openai"), + method: new Connector.OAuthMethod({ + id: Connector.MethodID.make("chatgpt-browser"), + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }), + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const redirect = `http://localhost:${callbackPort}/auth/callback` + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.close() + }), + ) + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, redirect, pkce)), + Effect.map(credential), + ), + } + }), + refresh: (value) => refresh(value), +} satisfies Connector.OAuthImplementation + +export const headless = { + connectorID: Connector.ID.make("openai"), + method: new Connector.OAuthMethod({ + id: Connector.MethodID.make("chatgpt-headless"), + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }), + authorize: () => + Effect.gen(function* () { + const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( + `${issuer}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ client_id: clientID }), + }, + ) + const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 + return { + mode: "auto" as const, + url: `${issuer}/codex/device`, + instructions: `Enter code: ${device.user_code}`, + callback: Effect.gen(function* () { + while (true) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${issuer}/api/accounts/deviceauth/token`, { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const data = (yield* Effect.promise(() => response.json())) as { + authorization_code: string + code_verifier: string + } + return credential( + yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { + verifier: data.code_verifier, + challenge: "", + }), + ) + } + if (response.status !== 403 && response.status !== 404) { + return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + } + yield* Effect.sleep(interval + pollingSafetyMargin) + } + }), + } + }), + refresh: (value) => refresh(value), +} satisfies Connector.OAuthImplementation + +function headers(contentType: string) { + return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } +} + +function exchange(code: string, redirect: string, pkce: Pkce) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }) +} + +function refresh(value: Credential.OAuth) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }).pipe( + Effect.map((tokens) => { + const next = credential(tokens) + return new Credential.OAuth({ + ...next, + metadata: next.metadata ?? value.metadata, + }) + }), + ) +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(tokens: TokenResponse) { + const accountID = extractAccountID(tokens) + return new Credential.OAuth({ + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: accountID ? { accountID } : undefined, + }) +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +function authorizeURL(redirect: string, pkce: Pkce, state: string) { + return `${issuer}/oauth/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirect, + scope: "openid profile email offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state, + originator: "opencode", + })}` +} + +function extractAccountID(tokens: TokenResponse) { + return claim(tokens.id_token) ?? claim(tokens.access_token) +} + +function claim(token: string) { + const part = token.split(".")[1] + if (!part) return + try { + const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) + } catch { + return + } +} + +const successPage = "OpenCode

Authorization successful

You can close this window.

" +const errorPage = (message: string) => + `OpenCode

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 1218625a47..1142a39b4f 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -2,10 +2,17 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" +import { Connector } from "../../connector" +import { browser, headless } from "./openai-auth" export const OpenAIPlugin = PluginV2.define({ id: PluginV2.ID.make("openai"), effect: Effect.gen(function* () { + const connectors = yield* Connector.Service + yield* connectors.update((editor) => { + editor.method.update(browser) + editor.method.update(headless) + }) return { "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 29b48e825b..67fd7816a3 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -14,7 +14,7 @@ export const OpencodePlugin = PluginV2.define({ process.env.OPENCODE_API_KEY || item.provider.env.some((env) => process.env[env]) || item.provider.request.body.apiKey || - (item.provider.enabled && item.provider.enabled.via === "account"), + (item.provider.enabled && item.provider.enabled.via === "credential"), ) evt.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 044cf7b16b..7379e08881 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -2,6 +2,7 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" import { Schema } from "effect" +import { Credential } from "./credential" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -54,8 +55,8 @@ export class Info extends Schema.Class("ProviderV2.Info")({ name: Schema.String, }), Schema.Struct({ - via: Schema.Literal("account"), - service: Schema.String, + via: Schema.Literal("credential"), + credentialID: Credential.ID, }), Schema.Struct({ via: Schema.Literal("custom"), diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts deleted file mode 100644 index 51cf2e4253..0000000000 --- a/packages/core/test/account.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import path from "path" -import { describe, expect } from "bun:test" -import { produce } from "immer" -import { Effect, Fiber, Layer, Option, Stream } from "effect" -import { Auth } from "@opencode-ai/core/auth" -import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Global } from "@opencode-ai/core/global" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { AccountPlugin } from "@opencode-ai/core/plugin/account" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" - -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) - -function context( - records: { provider: ProviderV2.Info; models: Map }[], - updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>, -): Catalog.Editor { - return { - provider: { - list: () => records, - get: (providerID) => records.find((item) => item.provider.id === providerID), - update: (providerID, fn) => { - const record = records.find((item) => item.provider.id === providerID) - const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn) - if (record) record.provider = provider - else records.push({ provider, models: new Map() }) - updates.push({ - id: providerID, - enabled: provider.enabled, - apiKey: typeof provider.request.body.apiKey === "string" ? provider.request.body.apiKey : undefined, - }) - }, - remove: (providerID) => { - const index = records.findIndex((item) => item.provider.id === providerID) - if (index !== -1) records.splice(index, 1) - }, - }, - model: { - get: () => undefined, - update: () => {}, - remove: () => {}, - default: { - get: () => undefined, - set: () => {}, - }, - }, - } -} - -function testLayer(dir: string) { - return Auth.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Global.layerWith({ - data: dir, - cache: path.join(dir, "cache"), - config: path.join(dir, "config"), - state: path.join(dir, "state"), - tmp: path.join(dir, "tmp"), - bin: path.join(dir, "bin"), - log: path.join(dir, "log"), - repos: path.join(dir, "repos"), - }), - ), - ) -} - -describe("Auth", () => { - it.live("emits account lifecycle events", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const accounts = yield* Auth.Service - const eventSvc = yield* EventV2.Service - const addedFiber = yield* eventSvc - .subscribe(Auth.Event.Added) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) - const switchedFiber = yield* eventSvc - .subscribe(Auth.Event.Switched) - .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) - const removedFiber = yield* eventSvc - .subscribe(Auth.Event.Removed) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) - - yield* Effect.yieldNow - - const first = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }), - }) - expect(first).toBeDefined() - if (!first) return - expect(first.description).toBe("default") - expect(first.credential.type).toBe("api") - if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key") - - yield* accounts.update(first.id, { description: "keep" }) - const updated = yield* accounts.get(first.id) - expect(updated?.description).toBe("keep") - expect(updated?.credential.type).toBe("api") - if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key") - - const second = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), - }) - expect(second).toBeDefined() - if (!second) return - - yield* accounts.remove(second.id) - const added = Array.from(yield* Fiber.join(addedFiber)) - const switched = Array.from(yield* Fiber.join(switchedFiber)) - const removed = Array.from(yield* Fiber.join(removedFiber)) - expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id]) - expect(switched.map((event) => event.data)).toEqual([ - { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id }, - ]) - expect(removed[0]?.data.account.id).toBe(second.id) - }).pipe(Effect.provide(testLayer(tmp.path))), - ), - ), - ) - - it.live("always switches to newly created accounts", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const accounts = yield* Auth.Service - const eventSvc = yield* EventV2.Service - const switchedFiber = yield* eventSvc - .subscribe(Auth.Event.Switched) - .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) - - yield* Effect.yieldNow - - const first = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), - }) - const second = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), - }) - const third = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }), - }) - - expect(first).toBeDefined() - expect(second).toBeDefined() - expect(third).toBeDefined() - if (!first || !second || !third) return - - expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id) - expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([ - { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id }, - ]) - }).pipe(Effect.provide(testLayer(tmp.path))), - ), - ), - ) - - it.live("account plugin refreshes providers on account lifecycle events", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const accounts = yield* Auth.Service - const plugin = yield* PluginV2.Service - const records = [ - { - provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")), - models: new Map(), - }, - ] - const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = [] - const catalog = Catalog.Service.of({ - transform: () => Effect.die("unexpected catalog.transform"), - provider: { - get: () => Effect.die("unexpected provider.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - }, - model: { - get: () => Effect.die("unexpected model.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - default: () => Effect.succeed(Option.none()), - small: () => Effect.succeed(Option.none()), - }, - }) - - const eventSvc = yield* EventV2.Service - yield* plugin.add({ - ...AccountPlugin, - effect: AccountPlugin.effect.pipe( - Effect.provideService(Auth.Service, accounts), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(EventV2.Service, eventSvc), - Effect.provideService(PluginV2.Service, plugin), - ), - }) - yield* Effect.yieldNow - - const first = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), - }) - expect(first).toBeDefined() - if (!first) return - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "first-key", - }, - ]) - - updates.length = 0 - const second = yield* accounts.create({ - serviceID: Auth.ServiceID.make("provider"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), - }) - expect(second).toBeDefined() - if (!second) return - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "second-key", - }, - ]) - - updates.length = 0 - yield* accounts.activate(first.id) - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "first-key", - }, - ]) - - updates.length = 0 - yield* accounts.remove(first.id) - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([ - { - id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: Auth.ServiceID.make("provider") }, - apiKey: "second-key", - }, - ]) - - updates.length = 0 - yield* accounts.remove(second.id) - yield* plugin.trigger("catalog.transform", context(records, updates), {}) - expect(updates).toEqual([]) - }).pipe(Effect.provide(testLayer(tmp.path))), - ), - ), - ) -}) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index a291af8d67..bbb959abec 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Connector } from "@opencode-ai/core/connector" +import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" @@ -17,10 +19,58 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) const it = testEffect( - Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)), + Catalog.locationLayer.pipe( + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })), + ), ) describe("CatalogV2", () => { + it.effect("projects active credentials without rebuilding catalog state", () => { + const connectorID = Connector.ID.make("test") + const methodID = Connector.MethodID.make("api-key") + const first = new Credential.Info({ + id: Credential.ID.create(), + connectorID, + methodID, + label: "First", + value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), + }) + const second = new Credential.Info({ + id: Credential.ID.create(), + connectorID, + methodID, + label: "Second", + value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), + }) + let active = first + const layer = Catalog.locationLayer.pipe( + Layer.fresh, + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + Layer.provideMerge( + Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map([[connectorID, active]])) }), + ), + ) + + return Effect.gen(function* () { + const catalog = yield* Catalog.Service + const transform = yield* catalog.transform() + yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + + expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ + enabled: { via: "credential", credentialID: first.id }, + request: { body: { apiKey: "first", tenant: "one" } }, + }) + active = second + expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ + enabled: { via: "credential", credentialID: second.id }, + request: { body: { apiKey: "second", tenant: "two" } }, + }) + }).pipe(Effect.provide(layer)) + }) + it.effect("normalizes provider baseURL into api url", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/connector.test.ts b/packages/core/test/connector.test.ts new file mode 100644 index 0000000000..205de295f6 --- /dev/null +++ b/packages/core/test/connector.test.ts @@ -0,0 +1,403 @@ +import { describe, expect } from "bun:test" +import { Duration, Effect, Exit, Layer, Scope } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { Connector } from "@opencode-ai/core/connector" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { it } from "./lib/effect" + +const layer = Connector.locationLayer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide( + Layer.mock(Credential.Service)({ + create: () => Effect.die("unexpected credential creation"), + }), + ), +) + +function connectionLayer( + created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }>, +) { + return Connector.locationLayer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide( + Layer.mock(Credential.Service)({ + create: (input) => + Effect.sync(() => { + created.push(input) + return new Credential.Info({ id: Credential.ID.create(), ...input, label: input.label ?? "default" }) + }), + }), + ), + ) +} + +describe("Connector", () => { + it.effect("registers connectors through the editor", () => + Effect.gen(function* () { + const connectors = yield* Connector.Service + const scope = yield* Scope.fork(yield* Scope.Scope) + const openai = Connector.ID.make("openai") + + yield* connectors + .update((editor) => editor.update(openai, (connector) => (connector.name = "OpenAI"))) + .pipe(Scope.provide(scope)) + expect(yield* connectors.get(openai)).toEqual( + new Connector.Info({ id: openai, name: "OpenAI", methods: [] }), + ) + + yield* Scope.close(scope, Exit.void) + expect(yield* connectors.get(openai)).toBeUndefined() + }).pipe(Effect.provide(layer)), + ) + + it.effect("reveals the previous registration when an override closes", () => + Effect.gen(function* () { + const connectors = yield* Connector.Service + const id = Connector.ID.make("openai") + const first = yield* Scope.fork(yield* Scope.Scope) + const second = yield* Scope.fork(yield* Scope.Scope) + + yield* connectors + .update((editor) => editor.update(id, (connector) => (connector.name = "OpenAI"))) + .pipe(Scope.provide(first)) + yield* connectors + .update((editor) => editor.update(id, (connector) => (connector.name = "OpenAI Override"))) + .pipe(Scope.provide(second)) + expect((yield* connectors.get(id))?.name).toBe("OpenAI Override") + + yield* Scope.close(second, Exit.void) + expect((yield* connectors.get(id))?.name).toBe("OpenAI") + expect((yield* connectors.list()).map((connector) => connector.id)).toEqual([id]) + }).pipe(Effect.provide(layer)), + ) + + it.effect("registers and overrides methods independently", () => + Effect.gen(function* () { + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("chatgpt") + const first = yield* Scope.fork(yield* Scope.Scope) + const second = yield* Scope.fork(yield* Scope.Scope) + const authorize = () => + Effect.succeed({ + mode: "auto" as const, + url: "https://example.com/authorize", + instructions: "Sign in", + callback: Effect.never, + }) + + yield* connectors + .update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + authorize, + }), + ) + .pipe(Scope.provide(first)) + yield* connectors + .update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }), + authorize, + }), + ) + .pipe(Scope.provide(second)) + + expect((yield* connectors.get(connectorID))?.name).toBe("openai") + expect((yield* connectors.get(connectorID))?.methods[0]?.label).toBe("ChatGPT Override") + + yield* Scope.close(second, Exit.void) + expect((yield* connectors.get(connectorID))?.methods[0]?.label).toBe("ChatGPT") + expect((yield* connectors.get(connectorID))?.methods.map((method) => method.id)).toEqual([methodID]) + }).pipe(Effect.provide(layer)), + ) + + it.effect("connects with a key and stores the credential", () => { + const created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }> = [] + return Effect.gen(function* () { + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("api-key") + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.KeyMethod({ id: methodID, type: "key", label: "API key" }), + authorize: (key, inputs) => + Effect.succeed( + new Credential.Key({ type: "key", key, metadata: { organization: inputs.organization ?? "" } }), + ), + }), + ) + + yield* connectors.connect.key({ + connectorID, + methodID, + key: "secret", + inputs: { organization: "acme" }, + label: "Work", + }) + + expect(created).toEqual([ + { + connectorID, + methodID, + label: "Work", + value: new Credential.Key({ type: "key", key: "secret", metadata: { organization: "acme" } }), + }, + ]) + }).pipe(Effect.provide(connectionLayer(created))) + }) + + it.effect("refreshes OAuth with the originating method", () => { + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("chatgpt") + const credentialID = Credential.ID.create() + const current = new Credential.OAuth({ + type: "oauth", + access: "old-access", + refresh: "old-refresh", + expires: 1, + metadata: { accountID: "account" }, + }) + const updated: Array<{ id: Credential.ID; value: Credential.Value }> = [] + const refreshLayer = Connector.locationLayer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide( + Layer.mock(Credential.Service)({ + get: () => + Effect.succeed( + new Credential.Info({ + id: credentialID, + connectorID, + methodID, + label: "Personal", + value: current, + }), + ), + update: (id, input) => + Effect.sync(() => { + if (input.value) updated.push({ id, value: input.value }) + }), + }), + ), + ) + + return Effect.gen(function* () { + const connectors = yield* Connector.Service + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + authorize: () => Effect.die("unexpected authorization"), + refresh: (value) => + Effect.succeed( + new Credential.OAuth({ + type: "oauth", + access: "new-access", + refresh: "new-refresh", + expires: 2, + metadata: value.metadata, + }), + ), + }), + ) + + yield* connectors.refresh(credentialID) + expect(updated).toEqual([ + { + id: credentialID, + value: new Credential.OAuth({ + type: "oauth", + access: "new-access", + refresh: "new-refresh", + expires: 2, + metadata: { accountID: "account" }, + }), + }, + ]) + }).pipe(Effect.provide(refreshLayer)) + }) + + it.effect("completes code OAuth once and stores the credential", () => { + const created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }> = [] + return Effect.gen(function* () { + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("chatgpt") + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + authorize: () => + Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: (code: string) => + Effect.succeed( + new Credential.OAuth({ + type: "oauth", + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code }, + }), + ), + }), + }), + ) + + const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {}, label: "Personal" }) + expect(attempt.mode).toBe("code") + yield* connectors.connect.oauth.complete({ attemptID: attempt.attemptID, code: "1234" }) + + expect(created[0]).toEqual({ + connectorID, + methodID, + label: "Personal", + value: new Credential.OAuth({ + type: "oauth", + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code: "1234" }, + }), + }) + }).pipe(Effect.provide(connectionLayer(created))) + }) + + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => { + const created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }> = [] + return Effect.gen(function* () { + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("chatgpt") + let closed = false + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + authorize: () => + Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( + Effect.as({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: () => Effect.die("unexpected callback"), + }), + ), + }), + ) + + const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} }) + expect(yield* connectors.connect.oauth.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( + Connector.CodeRequiredError, + ) + expect(closed).toBe(false) + yield* connectors.connect.oauth.cancel(attempt.attemptID) + expect(closed).toBe(true) + expect(created).toEqual([]) + }).pipe(Effect.provide(connectionLayer(created))) + }) + + it.effect("completes auto OAuth in the background", () => { + const created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }> = [] + return Effect.gen(function* () { + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("browser") + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + authorize: () => + Effect.succeed({ + mode: "auto" as const, + url: "https://example.com/authorize", + instructions: "Sign in", + callback: Effect.succeed( + new Credential.OAuth({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + ), + }), + }), + ) + + const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} }) + yield* Effect.yieldNow + expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toEqual({ + status: "complete", + time: attempt.time, + }) + expect(created).toHaveLength(1) + }).pipe(Effect.provide(connectionLayer(created))) + }) + + it.effect("expires abandoned OAuth attempts", () => { + const created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }> = [] + return Effect.gen(function* () { + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("browser") + let closed = false + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + authorize: () => + Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( + Effect.as({ + mode: "auto" as const, + url: "https://example.com/authorize", + instructions: "Sign in", + callback: Effect.never, + }), + ), + }), + ) + + const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} }) + expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) + yield* TestClock.adjust(Duration.minutes(10)) + yield* Effect.yieldNow + expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toEqual({ + status: "expired", + time: attempt.time, + }) + expect(closed).toBe(true) + expect(created).toEqual([]) + }).pipe(Effect.provide(connectionLayer(created))) + }) +}) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts new file mode 100644 index 0000000000..5edac2a0de --- /dev/null +++ b/packages/core/test/credential.test.ts @@ -0,0 +1,207 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Stream } from "effect" +import { Credential } from "@opencode-ai/core/credential" +import { Connector } from "@opencode-ai/core/connector" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) + +function testLayer(directory: string) { + return Credential.layer.pipe( + Layer.fresh, + Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)), + Layer.provideMerge(EventV2.defaultLayer), + ) +} + +describe("Credential", () => { + it.live("imports supported legacy auth.json credentials once", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "auth.json"), + JSON.stringify({ + openai: { + type: "oauth", + refresh: "refresh", + access: "access", + expires: 123, + accountId: "account", + }, + azure: { type: "api", key: "key", metadata: { resourceName: "resource" } }, + ignored: { type: "wellknown", key: "TOKEN", token: "secret" }, + }), + ), + ) + const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh) + const global = Global.layerWith({ data: tmp.path }) + const importer = Credential.legacyImportLayer.pipe( + Layer.provide(database), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(global), + ) + const credentials = Credential.layer.pipe( + Layer.provide(database), + Layer.provide(EventV2.defaultLayer), + Layer.provideMerge(importer), + ) + const result = yield* Effect.gen(function* () { + const service = yield* Credential.Service + return yield* service.all() + }).pipe(Effect.provide(credentials), Effect.scoped) + + expect(result).toHaveLength(2) + expect(result).toContainEqual( + expect.objectContaining({ + connectorID: Connector.ID.make("openai"), + methodID: Connector.MethodID.make("chatgpt-browser"), + label: "Imported", + value: expect.objectContaining({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: 123, + metadata: { accountID: "account" }, + }), + }), + ) + expect(result).toContainEqual( + expect.objectContaining({ + connectorID: Connector.ID.make("azure"), + methodID: Connector.MethodID.make("api-key"), + value: expect.objectContaining({ type: "key", key: "key", metadata: { resourceName: "resource" } }), + }), + ) + + yield* importer.pipe(Layer.build, Effect.scoped) + const after = yield* Effect.gen(function* () { + return yield* (yield* Credential.Service).all() + }).pipe(Effect.provide(credentials), Effect.scoped) + expect(after).toHaveLength(2) + }), + ), + ), + ) + + it.live("emits credential lifecycle events", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const eventSvc = yield* EventV2.Service + const addedFiber = yield* eventSvc + .subscribe(Credential.Event.Added) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const switchedFiber = yield* eventSvc + .subscribe(Credential.Event.Switched) + .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) + const removedFiber = yield* eventSvc + .subscribe(Credential.Event.Removed) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + + yield* Effect.yieldNow + + const first = yield* credentials.create({ + connectorID: Connector.ID.make("lifecycle"), + methodID: Connector.MethodID.make("key"), + value: new Credential.Key({ type: "key", key: "raw-key" }), + }) + expect(first).toBeDefined() + if (!first) return + expect(first.label).toBe("default") + expect(first.value.type).toBe("key") + if (first.value.type === "key") expect(first.value.key).toBe("raw-key") + + yield* credentials.update(first.id, { label: "keep" }) + const updated = yield* credentials.get(first.id) + expect(updated?.label).toBe("keep") + expect(updated?.value.type).toBe("key") + if (updated?.value.type === "key") expect(updated.value.key).toBe("raw-key") + + const second = yield* credentials.create({ + connectorID: Connector.ID.make("lifecycle"), + methodID: Connector.MethodID.make("key"), + value: new Credential.Key({ type: "key", key: "second-key" }), + }) + expect(second).toBeDefined() + if (!second) return + + yield* credentials.remove(second.id) + const added = Array.from(yield* Fiber.join(addedFiber)) + const switched = Array.from(yield* Fiber.join(switchedFiber)) + const removed = Array.from(yield* Fiber.join(removedFiber)) + expect(added.map((event) => event.data.credential.id)).toEqual([first.id, second.id]) + expect(switched.map((event) => event.data)).toEqual([ + { connectorID: Connector.ID.make("lifecycle"), from: undefined, to: first.id }, + { connectorID: Connector.ID.make("lifecycle"), from: first.id, to: second.id }, + { connectorID: Connector.ID.make("lifecycle"), from: second.id, to: first.id }, + ]) + expect(removed[0]?.data.credential.id).toBe(second.id) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + + it.live("always switches to newly created credentials", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const eventSvc = yield* EventV2.Service + const switchedFiber = yield* eventSvc + .subscribe(Credential.Event.Switched) + .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) + + yield* Effect.yieldNow + + const first = yield* credentials.create({ + connectorID: Connector.ID.make("switch"), + methodID: Connector.MethodID.make("key"), + value: new Credential.Key({ type: "key", key: "first-key" }), + }) + const second = yield* credentials.create({ + connectorID: Connector.ID.make("switch"), + methodID: Connector.MethodID.make("key"), + value: new Credential.Key({ type: "key", key: "second-key" }), + }) + const third = yield* credentials.create({ + connectorID: Connector.ID.make("switch"), + methodID: Connector.MethodID.make("key"), + value: new Credential.Key({ type: "key", key: "third-key" }), + }) + + expect(first).toBeDefined() + expect(second).toBeDefined() + expect(third).toBeDefined() + if (!first || !second || !third) return + + expect((yield* credentials.active(Connector.ID.make("switch")))?.id).toBe(third.id) + expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([ + { connectorID: Connector.ID.make("switch"), from: undefined, to: first.id }, + { connectorID: Connector.ID.make("switch"), from: first.id, to: second.id }, + { connectorID: Connector.ID.make("switch"), from: second.id, to: third.id }, + ]) + }).pipe(Effect.provide(testLayer(tmp.path))), + ), + ), + ) + +}) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index ecffef0992..0bd3a2497a 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -13,7 +13,8 @@ import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { toolDefinitions } from "./lib/tool" import { FSUtil } from "../src/fs-util" -import { Auth } from "../src/auth" +import { Credential } from "../src/credential" +import { Database } from "../src/database/database" import { EventV2 } from "../src/event" import { Global } from "../src/global" import { ModelsDev } from "../src/models-dev" @@ -33,7 +34,10 @@ const it = testEffect( Layer.mergeAll( Project.defaultLayer, EventV2.defaultLayer, - Auth.defaultLayer, + Credential.layer.pipe( + Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), + Layer.provide(EventV2.defaultLayer), + ), Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, diff --git a/packages/core/test/plugin/fixtures/models-dev.json b/packages/core/test/plugin/fixtures/models-dev.json new file mode 100644 index 0000000000..fb8f2622be --- /dev/null +++ b/packages/core/test/plugin/fixtures/models-dev.json @@ -0,0 +1,14 @@ +{ + "acme": { + "id": "acme", + "name": "Acme", + "env": ["ACME_API_KEY"], + "models": {} + }, + "local": { + "id": "local", + "name": "Local", + "env": [], + "models": {} + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts new file mode 100644 index 0000000000..5bf290c694 --- /dev/null +++ b/packages/core/test/plugin/models-dev.test.ts @@ -0,0 +1,70 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { Connector } from "@opencode-ai/core/connector" +import { Credential } from "@opencode-ai/core/credential" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Location } from "@opencode-ai/core/location" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" +import { Policy } from "@opencode-ai/core/policy" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" + +const events = EventV2.defaultLayer +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), +) +const plugins = PluginV2.layer.pipe(Layer.provide(events)) +const policy = Policy.layer.pipe(Layer.provide(locationLayer)) +const credentials = Credential.layer.pipe( + Layer.provide(Database.layerFromPath(":memory:")), + Layer.provide(events), +) +const catalog = Catalog.layer.pipe( + Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, credentials)), +) +const connectors = Connector.locationLayer.pipe(Layer.provide(credentials), Layer.provide(events)) +const layer = Layer.mergeAll(catalog, connectors, credentials, events, locationLayer, plugins) +const it = testEffect(layer) + +describe("ModelsDevPlugin", () => { + it.effect("registers key connectors for providers with environment variables", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = { + path: Flag.OPENCODE_MODELS_PATH, + disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH, + } + Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json") + Flag.OPENCODE_DISABLE_MODELS_FETCH = true + return previous + }), + () => + Effect.gen(function* () { + yield* ModelsDevPlugin.effect + const connectors = yield* Connector.Service + expect(yield* connectors.list()).toEqual([ + new Connector.Info({ + id: Connector.ID.make("acme"), + name: "Acme", + methods: [ + new Connector.KeyMethod({ id: Connector.MethodID.make("api-key"), type: "key", label: "API Key" }), + ], + }), + ]) + }).pipe(Effect.provide(ModelsDev.defaultLayer)), + (previous) => + Effect.sync(() => { + Flag.OPENCODE_MODELS_PATH = previous.path + Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled + }), + ), + ) +}) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 3dc3fec114..d5fe1df027 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,11 +1,12 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { Auth } from "@opencode-ai/core/auth" +import { Credential } from "@opencode-ai/core/credential" +import { Connector } from "@opencode-ai/core/connector" +import { Database } from "@opencode-ai/core/database/database" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" -import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -15,7 +16,12 @@ import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provi const itWithAccount = testEffect( Catalog.locationLayer.pipe( - Layer.provideMerge(Auth.defaultLayer), + Layer.provideMerge( + Credential.layer.pipe( + Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), + Layer.provide(EventV2.defaultLayer), + ), + ), Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), @@ -74,26 +80,17 @@ describe("AzurePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const accounts = yield* Auth.Service + const credentials = yield* Credential.Service const catalog = yield* Catalog.Service - const events = yield* EventV2.Service - yield* accounts.create({ - serviceID: Auth.ServiceID.make("azure"), - credential: new Auth.ApiKeyCredential({ - type: "api", + yield* credentials.create({ + connectorID: Connector.ID.make("azure"), + methodID: Connector.MethodID.make("api-key"), + value: new Credential.Key({ + type: "key", key: "key", metadata: { resourceName: "from-account" }, }), }) - yield* plugin.add({ - ...AccountPlugin, - effect: AccountPlugin.effect.pipe( - Effect.provideService(Auth.Service, accounts), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(EventV2.Service, events), - Effect.provideService(PluginV2.Service, plugin), - ), - }) yield* plugin.add(AzurePlugin) const transform = yield* catalog.transform() yield* transform((catalog) => { diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d0a05e67a2..6195a2cc16 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { Auth } from "@opencode-ai/core/auth" +import { Credential } from "@opencode-ai/core/credential" +import { Connector } from "@opencode-ai/core/connector" +import { Database } from "@opencode-ai/core/database/database" import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" -import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -16,7 +17,12 @@ import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper const itWithAccount = testEffect( Catalog.locationLayer.pipe( - Layer.provideMerge(Auth.defaultLayer), + Layer.provideMerge( + Credential.layer.pipe( + Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), + Layer.provide(EventV2.defaultLayer), + ), + ), Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), @@ -128,26 +134,17 @@ describe("CloudflareWorkersAIPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const accounts = yield* Auth.Service + const credentials = yield* Credential.Service const catalog = yield* Catalog.Service - const events = yield* EventV2.Service - yield* accounts.create({ - serviceID: Auth.ServiceID.make("cloudflare-workers-ai"), - credential: new Auth.ApiKeyCredential({ - type: "api", + yield* credentials.create({ + connectorID: Connector.ID.make("cloudflare-workers-ai"), + methodID: Connector.MethodID.make("api-key"), + value: new Credential.Key({ + type: "key", key: "account-key", metadata: { accountId: "account-acct" }, }), }) - yield* plugin.add({ - ...AccountPlugin, - effect: AccountPlugin.effect.pipe( - Effect.provideService(Auth.Service, accounts), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(EventV2.Service, events), - Effect.provideService(PluginV2.Service, plugin), - ), - }) yield* plugin.add(CloudflareWorkersAIPlugin) const transform = yield* catalog.transform() yield* transform((catalog) => @@ -155,10 +152,9 @@ describe("CloudflareWorkersAIPlugin", () => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ - type: "aisdk", - package: "test-provider", - url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1", + expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject({ + apiKey: "account-key", + accountId: "account-acct", }) }), ), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index c2cbfc8c4f..5b82cda959 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,11 +1,12 @@ import { describe, expect, mock } from "bun:test" import { Effect, Layer } from "effect" -import { Auth } from "@opencode-ai/core/auth" +import { Credential } from "@opencode-ai/core/credential" +import { Connector } from "@opencode-ai/core/connector" +import { Database } from "@opencode-ai/core/database/database" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" -import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -30,7 +31,12 @@ void mock.module("gitlab-ai-provider", () => ({ const itWithAccount = testEffect( Catalog.locationLayer.pipe( - Layer.provideMerge(Auth.defaultLayer), + Layer.provideMerge( + Credential.layer.pipe( + Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), + Layer.provide(EventV2.defaultLayer), + ), + ), Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))), @@ -165,21 +171,12 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const accounts = yield* Auth.Service + const credentials = yield* Credential.Service const catalog = yield* Catalog.Service - const events = yield* EventV2.Service - yield* accounts.create({ - serviceID: Auth.ServiceID.make("gitlab"), - credential: new Auth.ApiKeyCredential({ type: "api", key: "account-token" }), - }) - yield* plugin.add({ - ...AccountPlugin, - effect: AccountPlugin.effect.pipe( - Effect.provideService(Auth.Service, accounts), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(EventV2.Service, events), - Effect.provideService(PluginV2.Service, plugin), - ), + yield* credentials.create({ + connectorID: Connector.ID.make("gitlab"), + methodID: Connector.MethodID.make("api-key"), + value: new Credential.Key({ type: "key", key: "account-token" }), }) yield* plugin.add(GitLabPlugin) const transform = yield* catalog.transform() @@ -208,27 +205,18 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const accounts = yield* Auth.Service + const credentials = yield* Credential.Service const catalog = yield* Catalog.Service - const events = yield* EventV2.Service - yield* accounts.create({ - serviceID: Auth.ServiceID.make("gitlab"), - credential: new Auth.OAuthCredential({ + yield* credentials.create({ + connectorID: Connector.ID.make("gitlab"), + methodID: Connector.MethodID.make("oauth"), + value: new Credential.OAuth({ type: "oauth", refresh: "refresh-token", access: "account-oauth-token", expires: 9999999999999, }), }) - yield* plugin.add({ - ...AccountPlugin, - effect: AccountPlugin.effect.pipe( - Effect.provideService(Auth.Service, accounts), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(EventV2.Service, events), - Effect.provideService(PluginV2.Service, plugin), - ), - }) yield* plugin.add(GitLabPlugin) const transform = yield* catalog.transform() yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1f4dbfb5aa..cc8ebbf396 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -3,6 +3,8 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import { expect } from "bun:test" import { Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Connector } from "@opencode-ai/core/connector" +import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" @@ -46,8 +48,15 @@ export const catalogLayer = Layer.succeed( }), ) +const connectors = Connector.locationLayer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide(Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation") })), +) + export const it = testEffect( Catalog.locationLayer.pipe( + Layer.provideMerge(connectors), + Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })), Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), Layer.provideMerge(npmLayer), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 3aa38a4b17..f67a4e9449 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,17 +1,44 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Connector } from "@opencode-ai/core/connector" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model, provider } from "./provider-helper" +function add(plugin: PluginV2.Interface, connectors: Connector.Interface) { + return plugin.add({ + ...OpenAIPlugin, + effect: OpenAIPlugin.effect.pipe(Effect.provideService(Connector.Service, connectors)), + }) +} + describe("OpenAIPlugin", () => { + it.effect("registers browser and headless ChatGPT OAuth methods", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* add(plugin, yield* Connector.Service) + expect((yield* (yield* Connector.Service).get(Connector.ID.make("openai")))?.methods).toEqual([ + new Connector.OAuthMethod({ + id: Connector.MethodID.make("chatgpt-browser"), + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }), + new Connector.OAuthMethod({ + id: Connector.MethodID.make("chatgpt-headless"), + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }), + ]) + }), + ) + it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAIPlugin) + yield* add(plugin, yield* Connector.Service) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -28,7 +55,7 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAIPlugin) + yield* add(plugin, yield* Connector.Service) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, @@ -42,7 +69,7 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(OpenAIPlugin) + yield* add(plugin, yield* Connector.Service) const result = yield* plugin.trigger( "aisdk.language", { @@ -63,7 +90,7 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(OpenAIPlugin) + yield* add(plugin, yield* Connector.Service) const result = yield* plugin.trigger( "aisdk.language", { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -78,7 +105,7 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenAIPlugin) + yield* add(plugin, yield* Connector.Service) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) @@ -97,7 +124,7 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenAIPlugin) + yield* add(plugin, yield* Connector.Service) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("custom-openai") diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index b9b79a0c9c..d12f04c0c9 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" @@ -161,7 +162,9 @@ describe("OpencodePlugin", () => { yield* plugin.add(OpencodePlugin) const transform = yield* catalog.transform() yield* transform((catalog) => { - const item = provider("opencode", { enabled: { via: "account", service: "opencode" } }) + const item = provider("opencode", { + enabled: { via: "credential", credentialID: Credential.ID.make("credential") }, + }) catalog.provider.update(item.id, (draft) => { draft.enabled = item.enabled }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 3b97c50834..bd07f75446 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -650,6 +650,49 @@ const scenarios: Scenario[] = [ http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)), http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)), http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)), + http.protected.get("/api/connector", "v2.connector.list").json(200, locationData(array)), + http.protected + .get("/api/connector/{connectorID}", "v2.connector.get") + .at((ctx) => ({ path: route("/api/connector/{connectorID}", { connectorID: "missing" }), headers: ctx.headers() })) + .json(200, object), + http.protected + .post("/api/connector/{connectorID}/connect/key", "v2.connector.connect.key") + .at((ctx) => ({ + path: route("/api/connector/{connectorID}/connect/key", { connectorID: "missing" }), + headers: ctx.headers(), + body: { methodID: "missing", key: "test", inputs: {} }, + })) + .status(500, undefined, "status"), + http.protected + .post("/api/connector/{connectorID}/connect/oauth", "v2.connector.connect.oauth.begin") + .at((ctx) => ({ + path: route("/api/connector/{connectorID}/connect/oauth", { connectorID: "missing" }), + headers: ctx.headers(), + body: { methodID: "missing", inputs: {} }, + })) + .status(500, undefined, "status"), + http.protected + .get("/api/connector/oauth/{attemptID}", "v2.connector.connect.oauth.status") + .at((ctx) => ({ + path: route("/api/connector/oauth/{attemptID}", { attemptID: "con_missing" }), + headers: ctx.headers(), + })) + .status(500, undefined, "status"), + http.protected + .post("/api/connector/oauth/{attemptID}/complete", "v2.connector.connect.oauth.complete") + .at((ctx) => ({ + path: route("/api/connector/oauth/{attemptID}/complete", { attemptID: "con_missing" }), + headers: ctx.headers(), + body: {}, + })) + .status(500, undefined, "status"), + http.protected + .delete("/api/connector/oauth/{attemptID}", "v2.connector.connect.oauth.cancel") + .at((ctx) => ({ + path: route("/api/connector/oauth/{attemptID}", { attemptID: "con_missing" }), + headers: ctx.headers(), + })) + .status(204, undefined, "status"), http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)), http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)), http.protected diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index ae090bd32f..7b998001f7 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -115,6 +115,30 @@ describe("PublicApi OpenAPI v2 errors", () => { } }) + test("documents connector discovery and connection routes", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const [method, path] of [ + ["get", "/api/connector"], + ["get", "/api/connector/{connectorID}"], + ["post", "/api/connector/{connectorID}/connect/key"], + ["post", "/api/connector/{connectorID}/connect/oauth"], + ["get", "/api/connector/oauth/{attemptID}"], + ["post", "/api/connector/oauth/{attemptID}/complete"], + ["delete", "/api/connector/oauth/{attemptID}"], + ] as const) { + expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined() + } + + for (const path of [ + "/api/connector/{connectorID}/connect/key", + "/api/connector/{connectorID}/connect/oauth", + "/api/connector/oauth/{attemptID}/complete", + ]) { + expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true) + } + }) + test("does not rewrite /api endpoint errors to legacy error components", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec const refs = v2Operations(spec) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9e70c4b44a..ffd59f2ba1 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -265,6 +265,20 @@ import type { V2AgentListResponses, V2CommandListErrors, V2CommandListResponses, + V2ConnectorConnectKeyErrors, + V2ConnectorConnectKeyResponses, + V2ConnectorConnectOauthBeginErrors, + V2ConnectorConnectOauthBeginResponses, + V2ConnectorConnectOauthCancelErrors, + V2ConnectorConnectOauthCancelResponses, + V2ConnectorConnectOauthCompleteErrors, + V2ConnectorConnectOauthCompleteResponses, + V2ConnectorConnectOauthStatusErrors, + V2ConnectorConnectOauthStatusResponses, + V2ConnectorGetErrors, + V2ConnectorGetResponses, + V2ConnectorListErrors, + V2ConnectorListResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, V2FsFindErrors, @@ -5567,6 +5581,297 @@ export class Provider2 extends HeyApiClient { } } +export class Oauth2 extends HeyApiClient { + /** + * Begin OAuth connection + * + * Start an OAuth attempt and return the authorization details. + */ + public begin( + parameters: { + connectorID: string + location?: { + directory?: string + workspace?: string + } + methodID?: string + inputs?: { + [key: string]: string + } + label?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "connectorID" }, + { in: "query", key: "location" }, + { in: "body", key: "methodID" }, + { in: "body", key: "inputs" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ConnectorConnectOauthBeginResponses, + V2ConnectorConnectOauthBeginErrors, + ThrowOnError + >({ + url: "/api/connector/{connectorID}/connect/oauth", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Cancel OAuth connection + * + * Cancel an OAuth attempt and release its resources. + */ + public cancel( + parameters: { + attemptID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2ConnectorConnectOauthCancelResponses, + V2ConnectorConnectOauthCancelErrors, + ThrowOnError + >({ + url: "/api/connector/oauth/{attemptID}", + ...options, + ...params, + }) + } + + /** + * Get OAuth attempt status + * + * Poll the current status of an OAuth attempt. + */ + public status( + parameters: { + attemptID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2ConnectorConnectOauthStatusResponses, + V2ConnectorConnectOauthStatusErrors, + ThrowOnError + >({ + url: "/api/connector/oauth/{attemptID}", + ...options, + ...params, + }) + } + + /** + * Complete OAuth connection + * + * Complete a code-based OAuth attempt and store the resulting credential. + */ + public complete( + parameters: { + attemptID: string + location?: { + directory?: string + workspace?: string + } + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ConnectorConnectOauthCompleteResponses, + V2ConnectorConnectOauthCompleteErrors, + ThrowOnError + >({ + url: "/api/connector/oauth/{attemptID}/complete", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Connect extends HeyApiClient { + /** + * Connect with key + * + * Run a key authentication method and store the resulting credential. + */ + public key( + parameters: { + connectorID: string + location?: { + directory?: string + workspace?: string + } + methodID?: string + key?: string + inputs?: { + [key: string]: string + } + label?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "connectorID" }, + { in: "query", key: "location" }, + { in: "body", key: "methodID" }, + { in: "body", key: "key" }, + { in: "body", key: "inputs" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ConnectorConnectKeyResponses, + V2ConnectorConnectKeyErrors, + ThrowOnError + >({ + url: "/api/connector/{connectorID}/connect/key", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _oauth?: Oauth2 + get oauth(): Oauth2 { + return (this._oauth ??= new Oauth2({ client: this.client })) + } +} + +export class Connector extends HeyApiClient { + /** + * List connectors + * + * Retrieve available connectors and their authentication methods. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/connector", + ...options, + ...params, + }) + } + + /** + * Get connector + * + * Retrieve one connector and its authentication methods. + */ + public get( + parameters: { + connectorID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "connectorID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/connector/{connectorID}", + ...options, + ...params, + }) + } + + private _connect?: Connect + get connect(): Connect { + return (this._connect ??= new Connect({ client: this.client })) + } +} + export class Request extends HeyApiClient { /** * List pending permission requests @@ -5922,6 +6227,11 @@ export class V2 extends HeyApiClient { return (this._provider ??= new Provider2({ client: this.client })) } + private _connector?: Connector + get connector(): Connector { + return (this._connector ??= new Connector({ client: this.client })) + } + private _permission?: Permission3 get permission(): Permission3 { return (this._permission ??= new Permission3({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 101a837c61..0ba11decbc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6,6 +6,9 @@ export type ClientOptions = { export type Event = | EventModelsDevRefreshed + | EventCredentialAdded + | EventCredentialRemoved + | EventCredentialSwitched | EventPluginAdded | EventCatalogModelUpdated | EventSessionCreated @@ -52,9 +55,7 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited - | EventAccountAdded - | EventAccountRemoved - | EventAccountSwitched + | EventConnectorUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventReferenceUpdated @@ -730,6 +731,29 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "credential.added" + properties: { + credential: CredentialInfo + } + } + | { + id: string + type: "credential.removed" + properties: { + credential: CredentialInfo + } + } + | { + id: string + type: "credential.switched" + properties: { + connectorID: string + from?: string + to?: string + } + } | { id: string type: "plugin.added" @@ -1251,25 +1275,9 @@ export type GlobalEvent = { } | { id: string - type: "account.added" + type: "connector.updated" properties: { - account: AuthInfo - } - } - | { - id: string - type: "account.removed" - properties: { - account: AuthInfo - } - } - | { - id: string - type: "account.switched" - properties: { - serviceID: string - from?: string - to?: string + [key: string]: unknown } } | { @@ -2838,6 +2846,34 @@ export type MoveSessionDestination = { directory: string } +export type CredentialOAuth = { + type: "oauth" + refresh: string + access: string + expires: number + metadata?: { + [key: string]: string + } +} + +export type CredentialKey = { + type: "key" + key: string + metadata?: { + [key: string]: string + } +} + +export type CredentialValue = CredentialOAuth | CredentialKey + +export type CredentialInfo = { + id: string + connectorID: string + methodID: string + label: string + value: CredentialValue +} + export type ModelV2Info = { id: string providerID: string @@ -2988,30 +3024,6 @@ export type SessionNextRetryError = { } } -export type AuthOAuthCredential = { - type: "oauth" - refresh: string - access: string - expires: number -} - -export type AuthApiKeyCredential = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type AuthCredential = AuthOAuthCredential | AuthApiKeyCredential - -export type AuthInfo = { - id: string - serviceID: string - description: string - credential: AuthCredential -} - export type PermissionV2Source = { type: "tool" messageID: string @@ -4062,8 +4074,8 @@ export type ProviderV2Info = { name: string } | { - via: "account" - service: string + via: "credential" + credentialID: string } | { via: "custom" @@ -4098,6 +4110,63 @@ export type ProviderV2Info = { } } +export type ConnectorWhen = { + key: string + op: "eq" | "neq" + value: string +} + +export type ConnectorTextPrompt = { + type: "text" + key: string + message: string + placeholder?: string + when?: ConnectorWhen +} + +export type ConnectorSelectPrompt = { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: ConnectorWhen +} + +export type ConnectorOAuthMethod = { + id: string + type: "oauth" + label: string + prompts?: Array +} + +export type ConnectorKeyMethod = { + id: string + type: "key" + label: string + prompts?: Array +} + +export type ConnectorInfo = { + id: string + name: string + methods: Array +} + +export type ConnectorAttempt = { + attemptID: string + url: string + instructions: string + mode: "auto" | "code" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + export type PermissionV2Request = { id: string sessionID: string @@ -4200,6 +4269,32 @@ export type EventModelsDevRefreshed = { } } +export type EventCredentialAdded = { + id: string + type: "credential.added" + properties: { + credential: CredentialInfo + } +} + +export type EventCredentialRemoved = { + id: string + type: "credential.removed" + properties: { + credential: CredentialInfo + } +} + +export type EventCredentialSwitched = { + id: string + type: "credential.switched" + properties: { + connectorID: string + from?: string + to?: string + } +} + export type EventPluginAdded = { id: string type: "plugin.added" @@ -4861,29 +4956,11 @@ export type EventFileEdited = { } } -export type EventAccountAdded = { +export type EventConnectorUpdated = { id: string - type: "account.added" + type: "connector.updated" properties: { - account: AuthInfo - } -} - -export type EventAccountRemoved = { - id: string - type: "account.removed" - properties: { - account: AuthInfo - } -} - -export type EventAccountSwitched = { - id: string - type: "account.switched" - properties: { - serviceID: string - from?: string - to?: string + [key: string]: unknown } } @@ -9982,6 +10059,320 @@ export type V2ProviderGetResponses = { export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] +export type V2ConnectorListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector" +} + +export type V2ConnectorListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorListError = V2ConnectorListErrors[keyof V2ConnectorListErrors] + +export type V2ConnectorListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ConnectorListResponse = V2ConnectorListResponses[keyof V2ConnectorListResponses] + +export type V2ConnectorGetData = { + body?: never + path: { + connectorID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector/{connectorID}" +} + +export type V2ConnectorGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorGetError = V2ConnectorGetErrors[keyof V2ConnectorGetErrors] + +export type V2ConnectorGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: ConnectorInfo + } +} + +export type V2ConnectorGetResponse = V2ConnectorGetResponses[keyof V2ConnectorGetResponses] + +export type V2ConnectorConnectKeyData = { + body: { + methodID: string + key: string + inputs: { + [key: string]: string + } + label?: string + } + path: { + connectorID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector/{connectorID}/connect/key" +} + +export type V2ConnectorConnectKeyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorConnectKeyError = V2ConnectorConnectKeyErrors[keyof V2ConnectorConnectKeyErrors] + +export type V2ConnectorConnectKeyResponses = { + /** + * + */ + 204: void +} + +export type V2ConnectorConnectKeyResponse = V2ConnectorConnectKeyResponses[keyof V2ConnectorConnectKeyResponses] + +export type V2ConnectorConnectOauthBeginData = { + body: { + methodID: string + inputs: { + [key: string]: string + } + label?: string + } + path: { + connectorID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector/{connectorID}/connect/oauth" +} + +export type V2ConnectorConnectOauthBeginErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorConnectOauthBeginError = + V2ConnectorConnectOauthBeginErrors[keyof V2ConnectorConnectOauthBeginErrors] + +export type V2ConnectorConnectOauthBeginResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: ConnectorAttempt + } +} + +export type V2ConnectorConnectOauthBeginResponse = + V2ConnectorConnectOauthBeginResponses[keyof V2ConnectorConnectOauthBeginResponses] + +export type V2ConnectorConnectOauthCancelData = { + body?: never + path: { + attemptID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector/oauth/{attemptID}" +} + +export type V2ConnectorConnectOauthCancelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorConnectOauthCancelError = + V2ConnectorConnectOauthCancelErrors[keyof V2ConnectorConnectOauthCancelErrors] + +export type V2ConnectorConnectOauthCancelResponses = { + /** + * + */ + 204: void +} + +export type V2ConnectorConnectOauthCancelResponse = + V2ConnectorConnectOauthCancelResponses[keyof V2ConnectorConnectOauthCancelResponses] + +export type V2ConnectorConnectOauthStatusData = { + body?: never + path: { + attemptID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector/oauth/{attemptID}" +} + +export type V2ConnectorConnectOauthStatusErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorConnectOauthStatusError = + V2ConnectorConnectOauthStatusErrors[keyof V2ConnectorConnectOauthStatusErrors] + +export type V2ConnectorConnectOauthStatusResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: + | { + status: "pending" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "complete" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "failed" + message: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "expired" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + } +} + +export type V2ConnectorConnectOauthStatusResponse = + V2ConnectorConnectOauthStatusResponses[keyof V2ConnectorConnectOauthStatusResponses] + +export type V2ConnectorConnectOauthCompleteData = { + body: { + code?: string + } + path: { + attemptID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/connector/oauth/{attemptID}/complete" +} + +export type V2ConnectorConnectOauthCompleteErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ConnectorConnectOauthCompleteError = + V2ConnectorConnectOauthCompleteErrors[keyof V2ConnectorConnectOauthCompleteErrors] + +export type V2ConnectorConnectOauthCompleteResponses = { + /** + * + */ + 204: void +} + +export type V2ConnectorConnectOauthCompleteResponse = + V2ConnectorConnectOauthCompleteResponses[keyof V2ConnectorConnectOauthCompleteResponses] + export type V2PermissionRequestListData = { body?: never path?: never diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index eab0dbfc44..b8e9bb2ad0 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -15,6 +15,7 @@ import { QuestionGroup } from "./groups/question" import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" +import { ConnectorGroup } from "./groups/connector" export const Api = HttpApi.make("server") .add(HealthGroup) @@ -24,6 +25,7 @@ export const Api = HttpApi.make("server") .add(MessageGroup) .add(ModelGroup) .add(ProviderGroup) + .add(ConnectorGroup) .add(PermissionGroup) .add(FileSystemGroup) .add(CommandGroup) diff --git a/packages/server/src/groups/connector.ts b/packages/server/src/groups/connector.ts new file mode 100644 index 0000000000..be16dfcdc6 --- /dev/null +++ b/packages/server/src/groups/connector.ts @@ -0,0 +1,133 @@ +import { Connector } from "@opencode-ai/core/connector" +import { Location } from "@opencode-ai/core/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" +import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" + +const Inputs = Schema.Record(Schema.String, Schema.String) + +export const ConnectorGroup = HttpApiGroup.make("server.connector") + .add( + HttpApiEndpoint.get("connector.list", "/api/connector", { + query: LocationQuery, + success: Location.response(Schema.Array(Connector.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.list", + summary: "List connectors", + description: "Retrieve available connectors and their authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.get("connector.get", "/api/connector/:connectorID", { + params: { connectorID: Connector.ID }, + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Connector.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.get", + summary: "Get connector", + description: "Retrieve one connector and its authentication methods.", + }), + ), + ) + .add( + HttpApiEndpoint.post("connector.connect.key", "/api/connector/:connectorID/connect/key", { + params: { connectorID: Connector.ID }, + query: LocationQuery, + payload: Schema.Struct({ + methodID: Connector.MethodID, + key: Schema.String, + inputs: Inputs, + label: Schema.optional(Schema.String), + }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.connect.key", + summary: "Connect with key", + description: "Run a key authentication method and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.post("connector.connect.oauth.begin", "/api/connector/:connectorID/connect/oauth", { + params: { connectorID: Connector.ID }, + query: LocationQuery, + payload: Schema.Struct({ + methodID: Connector.MethodID, + inputs: Inputs, + label: Schema.optional(Schema.String), + }), + success: Location.response(Connector.Attempt), + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.connect.oauth.begin", + summary: "Begin OAuth connection", + description: "Start an OAuth attempt and return the authorization details.", + }), + ), + ) + .add( + HttpApiEndpoint.get("connector.connect.oauth.status", "/api/connector/oauth/:attemptID", { + params: { attemptID: Connector.AttemptID }, + query: LocationQuery, + success: Location.response(Connector.AttemptStatus), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.connect.oauth.status", + summary: "Get OAuth attempt status", + description: "Poll the current status of an OAuth attempt.", + }), + ), + ) + .add( + HttpApiEndpoint.post("connector.connect.oauth.complete", "/api/connector/oauth/:attemptID/complete", { + params: { attemptID: Connector.AttemptID }, + query: LocationQuery, + payload: Schema.Struct({ code: Schema.optional(Schema.String) }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.connect.oauth.complete", + summary: "Complete OAuth connection", + description: "Complete a code-based OAuth attempt and store the resulting credential.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("connector.connect.oauth.cancel", "/api/connector/oauth/:attemptID", { + params: { attemptID: Connector.AttemptID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.connector.connect.oauth.cancel", + summary: "Cancel OAuth connection", + description: "Cancel an OAuth attempt and release its resources.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "connectors", description: "Connector discovery and authentication routes." }), + ) + .middleware(LocationMiddleware) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 6a29bed861..35c24e1b05 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -19,6 +19,7 @@ import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { LocationHandler } from "./handlers/location" +import { ConnectorHandler } from "./handlers/connector" export const handlers = Layer.mergeAll( HealthHandler, @@ -28,6 +29,7 @@ export const handlers = Layer.mergeAll( MessageHandler, ModelHandler, ProviderHandler, + ConnectorHandler, PermissionHandler, FileSystemHandler, CommandHandler, diff --git a/packages/server/src/handlers/connector.ts b/packages/server/src/handlers/connector.ts new file mode 100644 index 0000000000..41f8d36c4d --- /dev/null +++ b/packages/server/src/handlers/connector.ts @@ -0,0 +1,102 @@ +import { Connector } from "@opencode-ai/core/connector" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { InvalidRequestError } from "../errors" +import { response } from "../groups/location" + +const authorize = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + () => + new InvalidRequestError({ + message: "Authentication failed", + kind: "connector_authorization", + }), + ), + ) + +export const ConnectorHandler = HttpApiBuilder.group(Api, "server.connector", (handlers) => + Effect.gen(function* () { + return handlers + .handle( + "connector.list", + Effect.fn(function* () { + const service = yield* Connector.Service + return yield* response(service.list()) + }), + ) + .handle( + "connector.get", + Effect.fn(function* (ctx) { + const service = yield* Connector.Service + return yield* response(service.get(ctx.params.connectorID)) + }), + ) + .handle( + "connector.connect.key", + Effect.fn(function* (ctx) { + const service = yield* Connector.Service + yield* authorize( + service.connect.key({ + connectorID: ctx.params.connectorID, + methodID: ctx.payload.methodID, + key: ctx.payload.key, + inputs: ctx.payload.inputs, + label: ctx.payload.label, + }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "connector.connect.oauth.begin", + Effect.fn(function* (ctx) { + const service = yield* Connector.Service + return yield* response( + authorize( + service.connect.oauth.begin({ + connectorID: ctx.params.connectorID, + methodID: ctx.payload.methodID, + inputs: ctx.payload.inputs, + label: ctx.payload.label, + }), + ), + ) + }), + ) + .handle( + "connector.connect.oauth.status", + Effect.fn(function* (ctx) { + const service = yield* Connector.Service + return yield* response(service.connect.oauth.status(ctx.params.attemptID)) + }), + ) + .handle( + "connector.connect.oauth.complete", + Effect.fn(function* (ctx) { + const service = yield* Connector.Service + yield* service.connect.oauth + .complete({ attemptID: ctx.params.attemptID, code: ctx.payload.code }) + .pipe( + Effect.mapError( + (error) => + new InvalidRequestError({ + message: error._tag === "Connector.CodeRequired" ? "Authorization code is required" : "Authentication failed", + kind: error._tag === "Connector.CodeRequired" ? "connector_code_required" : "connector_authorization", + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "connector.connect.oauth.cancel", + Effect.fn(function* (ctx) { + const service = yield* Connector.Service + yield* service.connect.oauth.cancel(ctx.params.attemptID) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index bc398181f9..74457fb832 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -2,6 +2,7 @@ import { useEvent } from "./event" import type { AgentV2Info, CommandV2Info, + ConnectorInfo, Event, LocationRef, ModelV2Info, @@ -26,6 +27,7 @@ import { createSignal, onMount } from "solid-js" type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] + connector?: ConnectorInfo[] model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] @@ -119,7 +121,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, } - event.subscribe((event) => { + event.subscribe((event, metadata) => { switch (event.type) { case "session.next.agent.switched": message.update(event.properties.sessionID, (draft) => { @@ -421,6 +423,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "reference.updated": void result.location.reference.refresh() break + case "credential.switched": { + const location = { directory: metadata.directory, workspaceID: metadata.workspace } + void Promise.allSettled([ + result.location.model.refresh(location), + result.location.provider.refresh(location), + ]) + break + } + case "connector.updated": + void result.location.connector.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }) + break } }) @@ -503,6 +516,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("location", key, "command", result.data.data) }, }, + connector: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.connector + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.connector.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "connector", result.data.data) + }, + }, model: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.model @@ -550,6 +573,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ void Promise.allSettled([ result.location.refresh(), result.location.agent.refresh(), + result.location.connector.refresh(), result.location.model.refresh(), result.location.provider.refresh(), result.location.reference.refresh(), diff --git a/packages/tui/src/context/event.ts b/packages/tui/src/context/event.ts index 45e108a668..1060f3ab17 100644 --- a/packages/tui/src/context/event.ts +++ b/packages/tui/src/context/event.ts @@ -2,6 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2" import { useSDK } from "./sdk" type EventMetadata = { + directory: string workspace: string | undefined } @@ -14,7 +15,7 @@ export function useEvent() { return } - handler(event.payload, { workspace: event.workspace }) + handler(event.payload, { directory: event.directory, workspace: event.workspace }) }) } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index af9f65cbf1..2365b5ebf9 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -92,6 +92,63 @@ test("refreshes resources into reactive getters", async () => { } }) +test("refreshes connectors after connector updates", async () => { + const events = createEventSource() + let requests = 0 + const calls = createFetch((url) => { + if (url.pathname !== "/api/connector") return + requests++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: + requests === 1 + ? [] + : [ + { + id: "openai", + name: "OpenAI", + methods: [{ id: "api-key", type: "key", label: "API Key" }], + }, + ], + }) + }) + let data!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await wait(() => data.location.connector.list() !== undefined) + expect(data.location.connector.list()).toEqual([]) + + emitEvent(events, { id: "evt_connector", type: "connector.updated", properties: {} }) + await wait(() => data.location.connector.list()?.length === 1) + expect(data.location.connector.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" }) + } finally { + app.renderer.destroy() + } +}) + test("refreshes references after updates", async () => { const events = createEventSource() let requests = 0 diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index e0bd361636..a781291898 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -60,7 +60,7 @@ export function createFetch(override?: FetchHandler) { if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } }) - if (["/api/agent", "/api/model", "/api/provider", "/api/command", "/api/skill"].includes(url.pathname)) + if (["/api/agent", "/api/model", "/api/provider", "/api/connector", "/api/command", "/api/skill"].includes(url.pathname)) return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [],