Jelajahi Sumber

feat(core): add connector authentication (#31837)

Dax 2 bulan lalu
induk
melakukan
dac0dd5309
47 mengubah file dengan 5433 tambahan dan 862 penghapusan
  1. 12 0
      packages/core/migration/20260611035744_credential/migration.sql
  2. 2190 0
      packages/core/migration/20260611035744_credential/snapshot.json
  3. 0 340
      packages/core/src/auth.ts
  4. 41 18
      packages/core/src/catalog.ts
  5. 496 0
      packages/core/src/connector.ts
  6. 9 0
      packages/core/src/connector/schema.ts
  7. 329 0
      packages/core/src/credential.ts
  8. 21 0
      packages/core/src/credential/sql.ts
  9. 1 0
      packages/core/src/database/migration.gen.ts
  10. 23 0
      packages/core/src/database/migration/20260611035744_credential.ts
  11. 4 2
      packages/core/src/location-layer.ts
  12. 0 8
      packages/core/src/plugin.ts
  13. 0 45
      packages/core/src/plugin/account.ts
  14. 1 1
      packages/core/src/plugin/agent.ts
  15. 9 6
      packages/core/src/plugin/boot.ts
  16. 20 0
      packages/core/src/plugin/models-dev.ts
  17. 1 1
      packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
  18. 5 2
      packages/core/src/plugin/provider/cloudflare-workers-ai.ts
  19. 251 0
      packages/core/src/plugin/provider/openai-auth.ts
  20. 7 0
      packages/core/src/plugin/provider/openai.ts
  21. 1 1
      packages/core/src/plugin/provider/opencode.ts
  22. 3 2
      packages/core/src/provider.ts
  23. 0 284
      packages/core/test/account.test.ts
  24. 51 1
      packages/core/test/catalog.test.ts
  25. 403 0
      packages/core/test/connector.test.ts
  26. 207 0
      packages/core/test/credential.test.ts
  27. 6 2
      packages/core/test/location-layer.test.ts
  28. 14 0
      packages/core/test/plugin/fixtures/models-dev.json
  29. 70 0
      packages/core/test/plugin/models-dev.test.ts
  30. 15 18
      packages/core/test/plugin/provider-azure.test.ts
  31. 18 22
      packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts
  32. 19 31
      packages/core/test/plugin/provider-gitlab.test.ts
  33. 9 0
      packages/core/test/plugin/provider-helper.ts
  34. 33 6
      packages/core/test/plugin/provider-openai.test.ts
  35. 4 1
      packages/core/test/plugin/provider-opencode.test.ts
  36. 43 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  37. 24 0
      packages/opencode/test/server/httpapi-public-openapi.test.ts
  38. 310 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  39. 459 68
      packages/sdk/js/src/v2/gen/types.gen.ts
  40. 2 0
      packages/server/src/api.ts
  41. 133 0
      packages/server/src/groups/connector.ts
  42. 2 0
      packages/server/src/handlers.ts
  43. 102 0
      packages/server/src/handlers/connector.ts
  44. 25 1
      packages/tui/src/context/data.tsx
  45. 2 1
      packages/tui/src/context/event.ts
  46. 57 0
      packages/tui/test/cli/tui/data.test.tsx
  47. 1 1
      packages/tui/test/fixture/tui-sdk.ts

+ 12 - 0
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;

+ 2190 - 0
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": []
+}

+ 0 - 340
packages/core/src/auth.ts

@@ -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<OAuthCredential>("Auth.OAuthCredential")({
-  type: Schema.Literal("oauth"),
-  refresh: Schema.String,
-  access: Schema.String,
-  expires: NonNegativeInt,
-}) {}
-
-export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("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<typeof Credential>
-
-export class Info extends Schema.Class<Info>("Auth.Info")({
-  id: ID,
-  serviceID: ServiceID,
-  description: Schema.String,
-  credential: Credential,
-}) {}
-
-export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("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<string, Info>
-  active: Record<string, ID>
-}
-
-const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
-
-function migrate(old: Record<string, unknown>): Writable {
-  const accounts: Record<string, Info> = {}
-  const active: Record<string, ID> = {}
-  for (const [serviceID, value] of Object.entries(old)) {
-    const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
-    const parsed = (decoded as Record<string, Credential>)[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<Info | undefined, Error>
-  readonly all: () => Effect.Effect<Info[], Error>
-  readonly create: (input: {
-    serviceID: ServiceID
-    credential: Credential
-    description?: string
-  }) => Effect.Effect<Info | undefined, Error>
-  readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
-  readonly remove: (id: ID) => Effect.Effect<void, Error>
-  readonly activate: (id: ID) => Effect.Effect<void, Error>
-  readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
-  readonly activeAll: () => Effect.Effect<Map<ServiceID, Info>, Error>
-  readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@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<string, unknown>) {
-      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<Writable, Error> = 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<string, unknown>)
-        }
-        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<string, unknown>)
-
-      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<string, unknown>)
-      }
-
-      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<ServiceID, Info>()
-        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),
-)

+ 41 - 18
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<ConnectorSchema.ID, Credential.Info>) => {
+      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<ModelV2.Info>()
+          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,
             )
           }

+ 496 - 0
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<TextPrompt>("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<SelectPrompt>("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<OAuthMethod>("Connector.OAuthMethod")({
+  id: MethodID,
+  type: Schema.Literal("oauth"),
+  label: Schema.String,
+  prompts: Schema.optional(Schema.Array(Prompt)),
+}) {}
+
+export class KeyMethod extends Schema.Class<KeyMethod>("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<Info>("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<Credential.Value, unknown>
+    }
+  | {
+      readonly mode: "code"
+      readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
+    }
+)
+
+export interface OAuthImplementation {
+  readonly connectorID: ID
+  readonly method: OAuthMethod
+  readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
+  readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
+}
+
+export interface KeyImplementation {
+  readonly connectorID: ID
+  readonly method: KeyMethod
+  readonly authorize: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
+}
+
+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<Attempt>("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<CodeRequiredError>()("Connector.CodeRequired", {
+  attemptID: AttemptID,
+}) {}
+
+export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("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<MethodID, Implementation>
+}
+
+type Data = {
+  connectors: Map<ID, Entry>
+}
+
+export type Editor = {
+  list: () => readonly Info[]
+  get: (id: ID) => Info | undefined
+  update: (id: ID, update: (connector: Draft<Omit<Info, "methods">>) => 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<Data, Editor>["transform"]
+  /** Registers and immediately applies a scoped connector registry update. */
+  readonly update: State.Interface<Data, Editor>["update"]
+  /** Returns one connector with its serializable login methods. */
+  readonly get: (id: ID) => Effect.Effect<Info | undefined>
+  /** Returns all connectors with their serializable login methods. */
+  readonly list: () => Effect.Effect<Info[]>
+  /** Refreshes an OAuth credential with its originating method. */
+  readonly refresh: (credentialID: Credential.ID) => Effect.Effect<void, AuthorizationError>
+  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<void, AuthorizationError>
+    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<AttemptStatus>
+      /** 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<void, CodeRequiredError | AuthorizationError>
+      /** Cancels an attempt and releases its resources. */
+      readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
+    }
+  }
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<AttemptID, AttemptEntry>())
+    const refreshLocks = KeyedMutex.makeUnsafe<Credential.ID>()
+    const state = State.create<Data, Editor>({
+      initial: () => ({ connectors: new Map<ID, Entry>() }),
+      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<MethodID, Implementation>(),
+              })
+            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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
+      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<unknown>) => {
+      const error = Cause.squash(cause)
+      return error instanceof Error ? error.message : String(error)
+    }
+
+    const settle = Effect.fnUntraced(function* (
+      attemptID: AttemptID,
+      exit: Exit.Exit<Credential.Value, unknown>,
+    ) {
+      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)
+          }),
+        },
+      },
+    })
+  }),
+)

+ 9 - 0
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

+ 329 - 0
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<OAuth>("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<Key>("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<typeof Value>
+
+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<Info>("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<Info | undefined>
+  readonly all: () => Effect.Effect<Info[]>
+  readonly create: (input: {
+    connectorID: ConnectorSchema.ID
+    methodID: ConnectorSchema.MethodID
+    value: Value
+    label?: string
+  }) => Effect.Effect<Info>
+  readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
+  readonly remove: (id: ID) => Effect.Effect<void>
+  readonly activate: (id: ID) => Effect.Effect<void>
+  readonly active: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info | undefined>
+  readonly activeAll: () => Effect.Effect<Map<ConnectorSchema.ID, Info>>
+  readonly forConnector: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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),
+    ),
+  ),
+)

+ 21 - 0
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<Credential.ID>().primaryKey(),
+    connector_id: text().$type<ConnectorSchema.ID>().notNull(),
+    method_id: text().$type<ConnectorSchema.MethodID>().notNull(),
+    label: text().notNull(),
+    value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
+    active: integer({ mode: "boolean" }).notNull().default(false),
+    ...Timestamps,
+  },
+  (table) => [
+    uniqueIndex("credential_connector_active_idx").on(table.connector_id).where(sql`${table.active} = 1`),
+  ],
+)

+ 1 - 0
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[]

+ 23 - 0
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

+ 4 - 2
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<LocationServiceMap>()("
       Reference.locationLayer,
       PluginV2.locationLayer,
       Catalog.locationLayer,
+      Connector.locationLayer,
       CommandV2.locationLayer,
       AgentV2.locationLayer,
       PluginBoot.locationLayer,
@@ -114,7 +116,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
   dependencies: [
     Project.defaultLayer,
     EventV2.defaultLayer,
-    Auth.defaultLayer,
+    Credential.defaultLayer,
     Npm.defaultLayer,
     ModelsDev.defaultLayer,
     FSUtil.defaultLayer,

+ 0 - 8
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

+ 0 - 45
packages/core/src/plugin/account.ts

@@ -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* () {}),
-    }
-  }),
-})

+ 1 - 1
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

+ 9 - 6
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),

+ 20 - 0
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)

+ 1 - 1
packages/core/src/plugin/provider/cloudflare-ai-gateway.ts

@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
 
 function gatewayConfig(options: Record<string, unknown>): 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")

+ 5 - 2
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

+ 251 - 0
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<string, Error>()
+      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<void, Error>((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<TokenResponse>(`${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<TokenResponse>(`${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<A>(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<A>
+    },
+    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<Pkce> {
+  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 = "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
+const errorPage = (message: string) =>
+  `<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`

+ 7 - 0
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

+ 1 - 1
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"

+ 3 - 2
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<Info>("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"),

+ 0 - 284
packages/core/test/account.test.ts

@@ -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<ModelV2.ID, ModelV2.Info> }[],
-  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<ModelV2.ID, ModelV2.Info>() })
-        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<ModelV2.ID, ModelV2.Info>(),
-            },
-          ]
-          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<ModelV2.Info>()),
-              small: () => Effect.succeed(Option.none<ModelV2.Info>()),
-            },
-          })
-
-          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))),
-      ),
-    ),
-  )
-})

+ 51 - 1
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

+ 403 - 0
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)))
+  })
+})

+ 207 - 0
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))),
+      ),
+    ),
+  )
+
+})

+ 6 - 2
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,

+ 14 - 0
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": {}
+  }
+}

+ 70 - 0
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
+        }),
+    ),
+  )
+})

+ 15 - 18
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) => {

+ 18 - 22
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",
           })
         }),
     ),

+ 19 - 31
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"), () => {}))

+ 9 - 0
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),

+ 33 - 6
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")

+ 4 - 1
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
           })

+ 43 - 0
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

+ 24 - 0
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)

+ 310 - 0
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<ThrowOnError extends boolean = false>(
+    parameters: {
+      connectorID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+      methodID?: string
+      inputs?: {
+        [key: string]: string
+      }
+      label?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    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<ThrowOnError extends boolean = false>(
+    parameters: {
+      attemptID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    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<ThrowOnError extends boolean = false>(
+    parameters: {
+      attemptID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    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<ThrowOnError extends boolean = false>(
+    parameters: {
+      attemptID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+      code?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    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<ThrowOnError extends boolean = false>(
+    parameters: {
+      connectorID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+      methodID?: string
+      key?: string
+      inputs?: {
+        [key: string]: string
+      }
+      label?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    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<ThrowOnError extends boolean = false>(
+    parameters?: {
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
+    return (options?.client ?? this.client).get<V2ConnectorListResponses, V2ConnectorListErrors, ThrowOnError>({
+      url: "/api/connector",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Get connector
+   *
+   * Retrieve one connector and its authentication methods.
+   */
+  public get<ThrowOnError extends boolean = false>(
+    parameters: {
+      connectorID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "connectorID" },
+            { in: "query", key: "location" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<V2ConnectorGetResponses, V2ConnectorGetErrors, ThrowOnError>({
+      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 }))

+ 459 - 68
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"
-        properties: {
-          account: AuthInfo
-        }
-      }
-    | {
-        id: string
-        type: "account.removed"
-        properties: {
-          account: AuthInfo
-        }
-      }
-    | {
-        id: string
-        type: "account.switched"
+        type: "connector.updated"
         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<ConnectorTextPrompt | ConnectorSelectPrompt>
+}
+
+export type ConnectorKeyMethod = {
+  id: string
+  type: "key"
+  label: string
+  prompts?: Array<ConnectorTextPrompt | ConnectorSelectPrompt>
+}
+
+export type ConnectorInfo = {
+  id: string
+  name: string
+  methods: Array<ConnectorOAuthMethod | ConnectorKeyMethod>
+}
+
+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 = {
-  id: string
-  type: "account.added"
-  properties: {
-    account: AuthInfo
-  }
-}
-
-export type EventAccountRemoved = {
+export type EventConnectorUpdated = {
   id: string
-  type: "account.removed"
+  type: "connector.updated"
   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<ConnectorInfo>
+  }
+}
+
+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 = {
+  /**
+   * <No Content>
+   */
+  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 = {
+  /**
+   * <No Content>
+   */
+  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 = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2ConnectorConnectOauthCompleteResponse =
+  V2ConnectorConnectOauthCompleteResponses[keyof V2ConnectorConnectOauthCompleteResponses]
+
 export type V2PermissionRequestListData = {
   body?: never
   path?: never

+ 2 - 0
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)

+ 133 - 0
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)

+ 2 - 0
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,

+ 102 - 0
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 = <A, R>(effect: Effect.Effect<A, Connector.AuthorizationError, R>) =>
+  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()
+        }),
+      )
+  }),
+)

+ 25 - 1
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(),

+ 2 - 1
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 })
     })
   }
 

+ 57 - 0
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<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    data = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  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

+ 1 - 1
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: [],