History

refactor control-plane credentials

Move durable source auth into first-class credentials and resolve secrets through provider refs so local auth can grow into shared credential scopes cleanly.

Rhys Sullivan committed 8 months ago

Commit c1825511 parent b99d0b532 files · +1494 −534Browse files

Unified · Split

32 files
  1. packages/control-plane/drizzle/20260308041000_credentials_model/migration.sql +41 −0
  2. packages/control-plane/src/persistence/control-plane-rows.test.ts +0 −3
  3. packages/control-plane/src/persistence/index.ts +2 −0
  4. packages/control-plane/src/persistence/repos/credentials-repo.ts +69 −0
  5. packages/control-plane/src/persistence/repos/index.ts +1 −0
  6. packages/control-plane/src/persistence/repos/organizations-repo.ts +4 −0
  7. packages/control-plane/src/persistence/repos/sources-repo.ts +0 −12
  8. packages/control-plane/src/persistence/repos/workspaces-repo.ts +4 −0
  9. packages/control-plane/src/persistence/schema.ts +34 −12
  10. packages/control-plane/src/runtime/control-plane-runtime.test.ts +16 −10
  11. packages/control-plane/src/runtime/executor-tools.ts +8 −17
  12. packages/control-plane/src/runtime/index.ts +3 −1
  13. packages/control-plane/src/runtime/live-execution.test.ts +47 −0
  14. packages/control-plane/src/runtime/live-execution.ts +37 −1
  15. packages/control-plane/src/runtime/local-operations.ts +7 −8
  16. packages/control-plane/src/runtime/secret-material-providers.ts +471 −0
  17. packages/control-plane/src/runtime/source-auth-service.test.ts +57 −0
  18. packages/control-plane/src/runtime/source-auth-service.ts +100 −191
  19. packages/control-plane/src/runtime/source-credential-interactions.ts +31 −0
  20. packages/control-plane/src/runtime/source-definitions.ts +71 −47
  21. packages/control-plane/src/runtime/source-inspection.ts +4 −13
  22. packages/control-plane/src/runtime/source-store.ts +132 −0
  23. packages/control-plane/src/runtime/sources-operations.ts +82 −77
  24. packages/control-plane/src/runtime/tool-artifacts.ts +9 −23
  25. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +113 −9
  26. packages/control-plane/src/runtime/workspace-execution-environment.ts +84 −77
  27. packages/control-plane/src/schema/enums.ts +4 −0
  28. packages/control-plane/src/schema/ids.ts +4 −0
  29. packages/control-plane/src/schema/index.ts +1 −0
  30. packages/control-plane/src/schema/models/credential.ts +52 −0
  31. packages/control-plane/src/schema/models/source.ts +1 −33
  32. personal-notes.md +5 −0
packages/control-plane/drizzle/20260308041000_credentials_model/migration.sql+41−0
@@ -0,0 +1,41 @@
1+CREATE TABLE "credentials" (
2+ "id" text PRIMARY KEY NOT NULL,
3+ "workspace_id" text NOT NULL,
4+ "auth_kind" text NOT NULL,
5+ "auth_header_name" text NOT NULL,
6+ "auth_prefix" text NOT NULL,
7+ "token_provider_id" text NOT NULL,
8+ "token_handle" text NOT NULL,
9+ "refresh_token_provider_id" text,
10+ "refresh_token_handle" text,
11+ "created_at" bigint NOT NULL,
12+ "updated_at" bigint NOT NULL,
13+ CONSTRAINT "credentials_auth_kind_check" CHECK ("auth_kind" in ('bearer', 'oauth2'))
14+);
15+--> statement-breakpoint
16+CREATE INDEX "credentials_workspace_idx" ON "credentials" ("workspace_id","updated_at","id");
17+--> statement-breakpoint
18+ALTER TABLE "sources" DROP CONSTRAINT "sources_auth_kind_check";
19+--> statement-breakpoint
20+ALTER TABLE "sources" DROP COLUMN "auth_kind";
21+--> statement-breakpoint
22+ALTER TABLE "sources" DROP COLUMN "auth_header_name";
23+--> statement-breakpoint
24+ALTER TABLE "sources" DROP COLUMN "auth_prefix";
25+--> statement-breakpoint
26+DROP INDEX IF EXISTS "source_credential_bindings_workspace_idx";
27+--> statement-breakpoint
28+DROP TABLE "source_credential_bindings";
29+--> statement-breakpoint
30+CREATE TABLE "source_credential_bindings" (
31+ "id" text PRIMARY KEY NOT NULL,
32+ "workspace_id" text NOT NULL,
33+ "source_id" text NOT NULL,
34+ "credential_id" text NOT NULL,
35+ "created_at" bigint NOT NULL,
36+ "updated_at" bigint NOT NULL
37+);
38+--> statement-breakpoint
39+CREATE UNIQUE INDEX "source_credential_bindings_workspace_source_idx" ON "source_credential_bindings" ("workspace_id","source_id");
40+--> statement-breakpoint
41+CREATE INDEX "source_credential_bindings_workspace_idx" ON "source_credential_bindings" ("workspace_id","updated_at","source_id");
packages/control-plane/src/persistence/control-plane-rows.test.ts+0−3
@@ -66,9 +66,6 @@ describe("control-plane-persistence-drizzle", () => { · 65 unmodified lines
6666 headersJson: null,
6767 specUrl: "https://api.github.com/openapi.json",
6868 defaultHeadersJson: null,
69− authKind: "none",
70− authHeaderName: null,
71− authPrefix: null,
7269 sourceHash: null,
7370 sourceDocumentText: null,
7471 lastError: null,
packages/control-plane/src/persistence/index.ts+2−0
@@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; · 5 unmodified lines
66 import { createDrizzleClient, type DrizzleClient } from "./client";
77 import {
88 createAccountsRepo,
9+ createCredentialsRepo,
910 createExecutionInteractionsRepo,
1011 createExecutionsRepo,
1112 createLocalInstallationsRepo,
@@ -51,6 +52,7 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 39 unmodified lines
5152 organizationMemberships: createOrganizationMembershipsRepo(client, tables),
5253 workspaces: createWorkspacesRepo(client, tables),
5354 sources: createSourcesRepo(client, tables),
55+ credentials: createCredentialsRepo(client, tables),
5456 toolArtifacts: createToolArtifactsRepo(client, tables),
5557 sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables),
5658 secretMaterials: createSecretMaterialsRepo(client, tables),
packages/control-plane/src/persistence/repos/credentials-repo.ts+69−0
@@ -0,0 +1,69 @@
1+import {
2+ CredentialSchema,
3+ type Credential,
4+} from "#schema";
5+import * as Option from "effect/Option";
6+import { Schema } from "effect";
7+import { asc, eq } from "drizzle-orm";
8+
9+import type { DrizzleClient } from "../client";
10+import type { DrizzleTables } from "../schema";
11+import { firstOption, withoutCreatedAt } from "./shared";
12+
13+const decodeCredential = Schema.decodeUnknownSync(CredentialSchema);
14+
15+export const createCredentialsRepo = (
16+ client: DrizzleClient,
17+ tables: DrizzleTables,
18+) => ({
19+ listByWorkspaceId: (workspaceId: Credential["workspaceId"]) =>
20+ client.use("rows.credentials.list_by_workspace", async (db) => {
21+ const rows = await db
22+ .select()
23+ .from(tables.credentialsTable)
24+ .where(eq(tables.credentialsTable.workspaceId, workspaceId))
25+ .orderBy(
26+ asc(tables.credentialsTable.updatedAt),
27+ asc(tables.credentialsTable.id),
28+ );
29+
30+ return rows.map((row) => decodeCredential(row));
31+ }),
32+
33+ getById: (id: Credential["id"]) =>
34+ client.use("rows.credentials.get_by_id", async (db) => {
35+ const rows = await db
36+ .select()
37+ .from(tables.credentialsTable)
38+ .where(eq(tables.credentialsTable.id, id))
39+ .limit(1);
40+
41+ const row = firstOption(rows);
42+ return Option.isSome(row)
43+ ? Option.some(decodeCredential(row.value))
44+ : Option.none<Credential>();
45+ }),
46+
47+ upsert: (credential: Credential) =>
48+ client.use("rows.credentials.upsert", async (db) => {
49+ await db
50+ .insert(tables.credentialsTable)
51+ .values(credential)
52+ .onConflictDoUpdate({
53+ target: [tables.credentialsTable.id],
54+ set: {
55+ ...withoutCreatedAt(credential),
56+ },
57+ });
58+ }),
59+
60+ removeById: (id: Credential["id"]) =>
61+ client.use("rows.credentials.remove", async (db) => {
62+ const deleted = await db
63+ .delete(tables.credentialsTable)
64+ .where(eq(tables.credentialsTable.id, id))
65+ .returning();
66+
67+ return deleted.length > 0;
68+ }),
69+});
packages/control-plane/src/persistence/repos/index.ts+1−0
@@ -3,6 +3,7 @@ export { createOrganizationsRepo } from "./organizations-repo"; · 2 unmodified lines
33 export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";
44 export { createWorkspacesRepo } from "./workspaces-repo";
55 export { createSourcesRepo } from "./sources-repo";
6+export { createCredentialsRepo } from "./credentials-repo";
67 export { createToolArtifactsRepo } from "./tool-artifacts-repo";
78 export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo";
89 export { createSecretMaterialsRepo } from "./secret-materials-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+4−0
@@ -151,6 +151,10 @@ export const createOrganizationsRepo = ( · 150 unmodified lines
151151 .delete(tables.sourceCredentialBindingsTable)
152152 .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds));
153153
154+ await tx
155+ .delete(tables.credentialsTable)
156+ .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
157+
154158 await tx
155159 .delete(tables.sourcesTable)
156160 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
packages/control-plane/src/persistence/repos/sources-repo.ts+0−12
@@ -31,9 +31,6 @@ const toSourceUpdateSet = ( · 30 unmodified lines
3131 if (patch.defaultHeadersJson !== undefined) {
3232 set.defaultHeadersJson = patch.defaultHeadersJson;
3333 }
34− if (patch.authKind !== undefined) set.authKind = patch.authKind;
35− if (patch.authHeaderName !== undefined) set.authHeaderName = patch.authHeaderName;
36− if (patch.authPrefix !== undefined) set.authPrefix = patch.authPrefix;
3734 if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash;
3835 if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText;
3936 if (patch.lastError !== undefined) set.lastError = patch.lastError;
@@ -172,15 +169,6 @@ export const createSourcesRepo = ( · 132 unmodified lines
172169 ),
173170 );
174171
175− await tx
176− .delete(tables.sourceCredentialBindingsTable)
177− .where(
178− and(
179− eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId),
180− eq(tables.sourceCredentialBindingsTable.sourceId, sourceId),
181− ),
182− );
183−
184172 const deleted = await tx
185173 .delete(tables.sourcesTable)
186174 .where(
packages/control-plane/src/persistence/repos/workspaces-repo.ts+4−0
@@ -85,6 +85,10 @@ export const createWorkspacesRepo = ( · 84 unmodified lines
8585 .delete(tables.sourceCredentialBindingsTable)
8686 .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId));
8787
88+ await tx
89+ .delete(tables.credentialsTable)
90+ .where(eq(tables.credentialsTable.workspaceId, workspaceId));
91+
8892 await tx
8993 .delete(tables.sourcesTable)
9094 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
packages/control-plane/src/persistence/schema.ts+34−12
@@ -20,6 +20,7 @@ export const tableNames = { · 19 unmodified lines
2020 toolArtifactParameters: "tool_artifact_parameters",
2121 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",
2222 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",
23+ credentials: "credentials",
2324 sourceCredentialBindings: "source_credential_bindings",
2425 secretMaterials: "secret_materials",
2526 sourceAuthSessions: "source_auth_sessions",
@@ -131,9 +132,6 @@ export const sourcesTable = pgTable( · 105 unmodified lines
131132 headersJson: text("headers_json"),
132133 specUrl: text("spec_url"),
133134 defaultHeadersJson: text("default_headers_json"),
134− authKind: text("auth_kind").notNull(),
135− authHeaderName: text("auth_header_name"),
136− authPrefix: text("auth_prefix"),
137135 sourceHash: text("source_hash"),
138136 sourceDocumentText: text("source_document_text"),
139137 lastError: text("last_error"),
@@ -157,9 +155,33 @@ export const sourcesTable = pgTable( · 17 unmodified lines
157155 "sources_transport_check",
158156 sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`,
159157 ),
158+ ],
159+);
160+
161+export const credentialsTable = pgTable(
162+ tableNames.credentials,
163+ {
164+ id: text("id").notNull().primaryKey(),
165+ workspaceId: text("workspace_id").notNull(),
166+ authKind: text("auth_kind").notNull(),
167+ authHeaderName: text("auth_header_name").notNull(),
168+ authPrefix: text("auth_prefix").notNull(),
169+ tokenProviderId: text("token_provider_id").notNull(),
170+ tokenHandle: text("token_handle").notNull(),
171+ refreshTokenProviderId: text("refresh_token_provider_id"),
172+ refreshTokenHandle: text("refresh_token_handle"),
173+ createdAt: bigint("created_at", { mode: "number" }).notNull(),
174+ updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
175+ },
176+ (table) => [
177+ index("credentials_workspace_idx").on(
178+ table.workspaceId,
179+ table.updatedAt,
180+ table.id,
181+ ),
160182 check(
161− "sources_auth_kind_check",
162− sql`${table.authKind} in ('none', 'bearer', 'oauth2')`,
183+ "credentials_auth_kind_check",
184+ sql`${table.authKind} in ('bearer', 'oauth2')`,
163185 ),
164186 ],
165187 );
@@ -167,19 +189,18 @@ export const sourcesTable = pgTable( · 1 unmodified line
167189 export const sourceCredentialBindingsTable = pgTable(
168190 tableNames.sourceCredentialBindings,
169191 {
192+ id: text("id").notNull().primaryKey(),
170193 workspaceId: text("workspace_id").notNull(),
171194 sourceId: text("source_id").notNull(),
172− tokenProviderId: text("token_provider_id"),
173− tokenHandle: text("token_handle"),
174− refreshTokenProviderId: text("refresh_token_provider_id"),
175− refreshTokenHandle: text("refresh_token_handle"),
195+ credentialId: text("credential_id").notNull(),
176196 createdAt: bigint("created_at", { mode: "number" }).notNull(),
177197 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
178198 },
179199 (table) => [
180− primaryKey({
181− columns: [table.workspaceId, table.sourceId],
182− }),
200+ uniqueIndex("source_credential_bindings_workspace_source_idx").on(
201+ table.workspaceId,
202+ table.sourceId,
203+ ),
183204 index("source_credential_bindings_workspace_idx").on(
184205 table.workspaceId,
185206 table.updatedAt,
@@ -493,6 +514,7 @@ export const drizzleSchema = { · 307 unmodified lines
493514 organizationMembershipsTable,
494515 workspacesTable,
495516 sourcesTable,
517+ credentialsTable,
496518 toolArtifactsTable,
497519 toolArtifactParametersTable,
498520 toolArtifactRequestBodyContentTypesTable,
packages/control-plane/src/runtime/control-plane-runtime.test.ts+16−10
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 createSqlControlPlaneRuntime,
1818 LiveExecutionManagerService,
1919 } from "./index";
20+import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
2021 import { withControlPlaneClient } from "./test-http-client";
2122
2223 const makeRuntime = Effect.acquireRelease(
@@ -155,9 +156,6 @@ describe("control-plane-runtime", () => { · 132 unmodified lines
155156 headersJson: null,
156157 specUrl: "https://example.com/github-openapi.yaml",
157158 defaultHeadersJson: null,
158− authKind: "none",
159− authHeaderName: null,
160− authPrefix: null,
161159 sourceHash: null,
162160 sourceDocumentText: null,
163161 lastError: null,
@@ -242,10 +240,21 @@ describe("control-plane-runtime", () => { · 78 unmodified lines
242240 const response = yield* Fiber.join(interactionFiber);
243241 expect(response.action).toBe("accept");
244242 expect(response.content?.authKind).toBe("bearer");
245− expect(typeof response.content?.tokenSecretMaterialId).toBe("string");
243+
244+ const decodedCredentialSelection = decodeSourceCredentialSelectionContent(
245+ response.content,
246+ );
247+ expect(decodedCredentialSelection.authKind).toBe("bearer");
248+ expect(
249+ decodedCredentialSelection.authKind === "bearer"
250+ ? decodedCredentialSelection.tokenRef.providerId
251+ : null,
252+ ).toBe("postgres");
246253
247254 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
248− String(response.content?.tokenSecretMaterialId),
255+ decodedCredentialSelection.authKind === "bearer"
256+ ? decodedCredentialSelection.tokenRef.handle
257+ : "",
249258 );
250259 const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById(
251260 tokenSecretMaterialId,
@@ -258,7 +267,7 @@ describe("control-plane-runtime", () => { · 6 unmodified lines
258267 );
259268 assertTrue(Option.isSome(storedInteraction));
260269 expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"bearer\"");
261− expect(storedInteraction.value.responseJson).toContain("tokenSecretMaterialId");
270+ expect(storedInteraction.value.responseJson).not.toContain("tokenRef");
262271 expect(storedInteraction.value.responseJson).not.toContain("ghp_local_test_token");
263272 }),
264273 );
@@ -304,9 +313,6 @@ describe("control-plane-runtime", () => { · 39 unmodified lines
304313 headersJson: null,
305314 specUrl: "https://example.com/github-openapi.yaml",
306315 defaultHeadersJson: null,
307− authKind: "none",
308− authHeaderName: null,
309− authPrefix: null,
310316 sourceHash: null,
311317 sourceDocumentText: null,
312318 lastError: null,
@@ -381,7 +387,7 @@ describe("control-plane-runtime", () => { · 68 unmodified lines
381387 );
382388 assertTrue(Option.isSome(storedInteraction));
383389 expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"none\"");
384− expect(storedInteraction.value.responseJson).not.toContain("tokenSecretMaterialId");
390+ expect(storedInteraction.value.responseJson).not.toContain("tokenRef");
385391 }),
386392 );
387393
packages/control-plane/src/runtime/executor-tools.ts+8−17
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
2626 deriveSchemaJson,
2727 deriveSchemaTypeSignature,
2828 } from "./schema-type-signature";
29+import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
2930
3031 const ExecutorMcpSourceAddInputSchema = Schema.Struct({
3132 kind: Schema.optional(Schema.Literal("mcp")),
@@ -192,29 +193,19 @@ const promptForSourceCredentialSelection = (input: { · 160 unmodified lines
192193 );
193194 }
194195
195− const authKind =
196− response.content && typeof response.content.authKind === "string"
197− ? response.content.authKind.trim()
198− : "";
196+ const content = yield* Effect.try({
197+ try: () => decodeSourceCredentialSelectionContent(response.content),
198+ catch: () =>
199+ new Error("Credential capture did not return a valid source auth choice for executor.sources.add"),
200+ });
199201
200− if (authKind === "none") {
202+ if (content.authKind === "none") {
201203 return { kind: "none" } satisfies ExecutorOpenApiSourceAuthInput;
202204 }
203205
204− const tokenSecretMaterialId =
205− response.content && typeof response.content.tokenSecretMaterialId === "string"
206− ? response.content.tokenSecretMaterialId.trim()
207− : "";
208−
209− if (authKind !== "bearer" || tokenSecretMaterialId.length === 0) {
210− return yield* Effect.fail(
211− new Error("Credential capture did not return a valid source auth choice for executor.sources.add"),
212− );
213− }
214−
215206 return {
216207 kind: "bearer",
217− tokenSecretMaterialId,
208+ tokenRef: content.tokenRef,
218209 } satisfies ExecutorOpenApiSourceAuthInput;
219210 });
220211
packages/control-plane/src/runtime/index.ts+3−1
@@ -37,8 +37,8 @@ import { · 36 unmodified lines
3737 import {
3838 RuntimeSourceAuthServiceLive,
3939 RuntimeSourceAuthServiceTag,
40− type ResolveSecretMaterial,
4140 } from "./source-auth-service";
41+import type { ResolveSecretMaterial } from "./secret-material-providers";
4242 import {
4343 RuntimeExecutionResolverLive,
4444 RuntimeExecutionResolverService,
@@ -55,6 +55,8 @@ export * from "./live-execution"; · 10 unmodified lines
5555 export * from "./local-installation";
5656 export * from "./schema-type-signature";
5757 export * from "./source-auth-service";
58+export * from "./secret-material-providers";
59+export * from "./source-credential-interactions";
5860 export * from "./store";
5961 export * from "./workspace-execution-environment";
6062 export * from "./source-inspection";
packages/control-plane/src/runtime/live-execution.test.ts+47−0
@@ -0,0 +1,47 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { sanitizePersistedElicitationResponse } from "./live-execution";
4+
5+describe("live-execution", () => {
6+ it("redacts secret refs from persisted interaction responses", () => {
7+ const sanitized = sanitizePersistedElicitationResponse({
8+ action: "accept",
9+ content: {
10+ authKind: "bearer",
11+ tokenRef: {
12+ providerId: "postgres",
13+ handle: "sec_123",
14+ },
15+ },
16+ });
17+
18+ expect(sanitized).toEqual({
19+ action: "accept",
20+ content: {
21+ authKind: "bearer",
22+ },
23+ });
24+ });
25+
26+ it("keeps unrelated interaction response content intact", () => {
27+ const sanitized = sanitizePersistedElicitationResponse({
28+ action: "cancel",
29+ content: {
30+ reason: "User declined",
31+ nested: {
32+ keep: true,
33+ },
34+ },
35+ });
36+
37+ expect(sanitized).toEqual({
38+ action: "cancel",
39+ content: {
40+ reason: "User declined",
41+ nested: {
42+ keep: true,
43+ },
44+ },
45+ });
46+ });
47+});
packages/control-plane/src/runtime/live-execution.ts+37−1
@@ -58,6 +58,42 @@ const serializeJson = (value: unknown): string | null => { · 57 unmodified lines
5858 return JSON.stringify(value);
5959 };
6060
61+const SENSITIVE_INTERACTION_CONTENT_KEYS = new Set([
62+ "tokenRef",
63+ "tokenSecretMaterialId",
64+]);
65+
66+const redactSensitiveInteractionContent = (value: unknown): unknown => {
67+ if (Array.isArray(value)) {
68+ return value.map(redactSensitiveInteractionContent);
69+ }
70+
71+ if (!value || typeof value !== "object") {
72+ return value;
73+ }
74+
75+ const entries = Object.entries(value as Record<string, unknown>)
76+ .filter(([key]) => !SENSITIVE_INTERACTION_CONTENT_KEYS.has(key))
77+ .map(([key, entry]) => [key, redactSensitiveInteractionContent(entry)]);
78+
79+ return Object.fromEntries(entries);
80+};
81+
82+export const sanitizePersistedElicitationResponse = (
83+ response: ElicitationResponse,
84+): ElicitationResponse => {
85+ if (response.content === undefined) {
86+ return response;
87+ }
88+
89+ const redactedContent = redactSensitiveInteractionContent(response.content);
90+ return {
91+ ...response,
92+ content: redactedContent as Record<string, unknown>,
93+ };
94+};
95+
96+
6197 export const createLiveExecutionManager = () => {
6298 const runs = new Map<Execution["id"], LiveRunEntry>();
6399
@@ -141,7 +177,7 @@ export const createLiveExecutionManager = () => { · 77 unmodified lines
141177
142178 yield* rows.executionInteractions.update(interaction.id, {
143179 status: resolved.action === "cancel" ? "cancelled" : "resolved",
144− responseJson: serializeJson(resolved),
180+ responseJson: serializeJson(sanitizePersistedElicitationResponse(resolved)),
145181 updatedAt: resolvedAt,
146182 });
147183 yield* rows.executions.update(executionId, {
packages/control-plane/src/runtime/local-operations.ts+7−8
@@ -18,6 +18,10 @@ import { · 17 unmodified lines
1818 operationErrors,
1919 } from "./operation-errors";
2020 import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
21+import {
22+ createSourceCredentialSelectionBearerContent,
23+ createSourceCredentialSelectionNoneContent,
24+} from "./source-credential-interactions";
2125 import { ControlPlaneStore } from "./store";
2226
2327 const localOps = {
@@ -253,9 +257,7 @@ export const submitSourceCredentialInteraction = (input: { · 229 unmodified lines
253257 executionId: interaction.executionId,
254258 response: {
255259 action: "accept",
256− content: {
257− authKind: "none",
258− },
260+ content: createSourceCredentialSelectionNoneContent(),
259261 },
260262 });
261263
@@ -286,7 +288,7 @@ export const submitSourceCredentialInteraction = (input: { · 24 unmodified lines
286288 }
287289
288290 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
289− const tokenSecretMaterialId = yield* sourceAuthService.storeSecretMaterial({
291+ const tokenRef = yield* sourceAuthService.storeSecretMaterial({
290292 purpose: "auth_material",
291293 value: token,
292294 }).pipe(
@@ -303,10 +305,7 @@ export const submitSourceCredentialInteraction = (input: { · 10 unmodified lines
303305 executionId: interaction.executionId,
304306 response: {
305307 action: "accept",
306− content: {
307− authKind: "bearer",
308− tokenSecretMaterialId,
309− },
308+ content: createSourceCredentialSelectionBearerContent(tokenRef),
310309 },
311310 });
312311
packages/control-plane/src/runtime/secret-material-providers.ts+471−0
@@ -0,0 +1,471 @@
1+import { randomUUID } from "node:crypto";
2+import { spawn } from "node:child_process";
3+
4+import type { SqlControlPlaneRows } from "#persistence";
5+import {
6+ type SecretMaterialPurpose,
7+ SecretMaterialIdSchema,
8+ type SecretRef,
9+} from "#schema";
10+import * as Effect from "effect/Effect";
11+import * as Option from "effect/Option";
12+
13+export const ENV_SECRET_PROVIDER_ID = "env";
14+export const PARAMS_SECRET_PROVIDER_ID = "params";
15+export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
16+export const POSTGRES_SECRET_PROVIDER_ID = "postgres";
17+
18+export type SecretStoreProviderId =
19+ | typeof KEYCHAIN_SECRET_PROVIDER_ID
20+ | typeof POSTGRES_SECRET_PROVIDER_ID;
21+
22+export type SecretMaterialResolveContext = {
23+ params?: Readonly<Record<string, string | undefined>>;
24+};
25+
26+export type ResolveSecretMaterial = (input: {
27+ ref: SecretRef;
28+ context?: SecretMaterialResolveContext;
29+}) => Effect.Effect<string, Error, never>;
30+
31+export type StoreSecretMaterial = (input: {
32+ purpose: SecretMaterialPurpose;
33+ value: string;
34+}) => Effect.Effect<SecretRef, Error, never>;
35+
36+type SecretMaterialProviderRuntime = {
37+ rows: SqlControlPlaneRows;
38+ env: NodeJS.ProcessEnv;
39+ dangerouslyAllowEnvSecrets: boolean;
40+ keychainServiceName: string;
41+};
42+
43+type SecretMaterialProvider = {
44+ resolve: (input: {
45+ ref: SecretRef;
46+ context: SecretMaterialResolveContext;
47+ runtime: SecretMaterialProviderRuntime;
48+ }) => Effect.Effect<string, Error, never>;
49+ store?: (input: {
50+ purpose: SecretMaterialPurpose;
51+ value: string;
52+ runtime: SecretMaterialProviderRuntime;
53+ }) => Effect.Effect<SecretRef, Error, never>;
54+};
55+
56+type SecretMaterialProviderRegistry = ReadonlyMap<string, SecretMaterialProvider>;
57+
58+type SpawnResult = {
59+ exitCode: number;
60+ stdout: string;
61+ stderr: string;
62+};
63+
64+const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor-v3";
65+const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS";
66+const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
67+const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";
68+
69+const trimOrNull = (value: string | null | undefined): string | null => {
70+ if (value === null || value === undefined) {
71+ return null;
72+ }
73+
74+ const trimmed = value.trim();
75+ return trimmed.length > 0 ? trimmed : null;
76+};
77+
78+const parseBooleanEnv = (value: string | undefined): boolean => {
79+ const normalized = trimOrNull(value)?.toLowerCase();
80+ return normalized === "1" || normalized === "true" || normalized === "yes";
81+};
82+
83+export const parseSecretStoreProviderId = (value: string | undefined): SecretStoreProviderId | null => {
84+ const normalized = trimOrNull(value)?.toLowerCase();
85+ if (normalized === KEYCHAIN_SECRET_PROVIDER_ID) {
86+ return KEYCHAIN_SECRET_PROVIDER_ID;
87+ }
88+
89+ if (normalized === POSTGRES_SECRET_PROVIDER_ID) {
90+ return POSTGRES_SECRET_PROVIDER_ID;
91+ }
92+
93+ return null;
94+};
95+
96+const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean =>
97+ value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]);
98+
99+const resolveSecretStoreProviderId = (value: SecretStoreProviderId | undefined): SecretStoreProviderId =>
100+ value
101+ ?? parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV])
102+ ?? POSTGRES_SECRET_PROVIDER_ID;
103+
104+const resolveKeychainServiceName = (value: string | undefined): string =>
105+ trimOrNull(value)
106+ ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])
107+ ?? DEFAULT_KEYCHAIN_SERVICE_NAME;
108+
109+const ensureNonEmptyString = (value: string | undefined): string | null => {
110+ const trimmed = value?.trim();
111+ return trimmed && trimmed.length > 0 ? trimmed : null;
112+};
113+
114+const runCommand = (input: {
115+ command: string;
116+ args: ReadonlyArray<string>;
117+ stdin?: string;
118+ operation: string;
119+}): Effect.Effect<SpawnResult, Error, never> =>
120+ Effect.tryPromise({
121+ try: () =>
122+ new Promise<SpawnResult>((resolve, reject) => {
123+ const child = spawn(input.command, [...input.args], {
124+ stdio: "pipe",
125+ });
126+
127+ let stdout = "";
128+ let stderr = "";
129+
130+ child.stdout.on("data", (chunk) => {
131+ stdout += chunk.toString("utf8");
132+ });
133+
134+ child.stderr.on("data", (chunk) => {
135+ stderr += chunk.toString("utf8");
136+ });
137+
138+ child.on("error", (error) => {
139+ reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`));
140+ });
141+
142+ child.on("close", (code) => {
143+ resolve({
144+ exitCode: code ?? 0,
145+ stdout,
146+ stderr,
147+ });
148+ });
149+
150+ if (input.stdin !== undefined) {
151+ child.stdin.write(input.stdin);
152+ }
153+
154+ child.stdin.end();
155+ }),
156+ catch: (cause) =>
157+ cause instanceof Error
158+ ? cause
159+ : new Error(`${input.operation}: command execution failed: ${String(cause)}`),
160+ });
161+
162+const ensureCommandSuccess = (input: {
163+ result: SpawnResult;
164+ operation: string;
165+ message: string;
166+}): Effect.Effect<SpawnResult, Error, never> => {
167+ if (input.result.exitCode === 0) {
168+ return Effect.succeed(input.result);
169+ }
170+
171+ const details = ensureNonEmptyString(input.result.stderr)
172+ ?? ensureNonEmptyString(input.result.stdout)
173+ ?? "command returned non-zero exit code";
174+
175+ return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`));
176+};
177+
178+const parseKeychainHandle = (handle: string): string | null => {
179+ if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) {
180+ return null;
181+ }
182+
183+ return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length));
184+};
185+
186+const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({
187+ resolve: ({ ref, context }) => {
188+ const value = ensureNonEmptyString(context.params?.[ref.handle]);
189+ if (value === null) {
190+ return Effect.fail(new Error(`Secret parameter ${ref.handle} is not set`));
191+ }
192+
193+ return Effect.succeed(value);
194+ },
195+});
196+
197+const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({
198+ resolve: ({ ref, runtime }) => {
199+ if (!runtime.dangerouslyAllowEnvSecrets) {
200+ return Effect.fail(
201+ new Error(
202+ `Env-backed secrets are disabled. Set ${DANGEROUSLY_ALLOW_ENV_SECRETS_ENV}=true to allow provider '${ENV_SECRET_PROVIDER_ID}'.`,
203+ ),
204+ );
205+ }
206+
207+ const value = ensureNonEmptyString(runtime.env[ref.handle]);
208+ if (value === null) {
209+ return Effect.fail(new Error(`Environment variable ${ref.handle} is not set`));
210+ }
211+
212+ return Effect.succeed(value);
213+ },
214+});
215+
216+const createPostgresSecretMaterialProvider = (): SecretMaterialProvider => ({
217+ resolve: ({ ref, runtime }) =>
218+ Effect.gen(function* () {
219+ const materialId = SecretMaterialIdSchema.make(ref.handle);
220+ const stored = yield* runtime.rows.secretMaterials.getById(materialId);
221+ if (Option.isNone(stored)) {
222+ return yield* Effect.fail(new Error(`Secret material not found: ${ref.handle}`));
223+ }
224+
225+ return stored.value.value;
226+ }),
227+
228+ store: ({ purpose, value, runtime }) =>
229+ Effect.gen(function* () {
230+ const now = Date.now();
231+ const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
232+ yield* runtime.rows.secretMaterials.upsert({
233+ id,
234+ purpose,
235+ value,
236+ createdAt: now,
237+ updatedAt: now,
238+ });
239+
240+ return {
241+ providerId: POSTGRES_SECRET_PROVIDER_ID,
242+ handle: id,
243+ } satisfies SecretRef;
244+ }),
245+});
246+
247+const keychainStoreWithSecurityCli = (): SecretMaterialProvider => ({
248+ resolve: ({ ref, runtime }) => {
249+ const id = parseKeychainHandle(ref.handle);
250+ if (id === null) {
251+ return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
252+ }
253+
254+ return runCommand({
255+ command: "security",
256+ args: [
257+ "find-generic-password",
258+ "-a",
259+ id,
260+ "-s",
261+ runtime.keychainServiceName,
262+ "-w",
263+ ],
264+ operation: "keychain.get",
265+ }).pipe(
266+ Effect.flatMap((result) =>
267+ ensureCommandSuccess({
268+ result,
269+ operation: "keychain.get",
270+ message: "Failed loading secret from macOS keychain",
271+ }),
272+ ),
273+ Effect.map((result) => result.stdout.trimEnd()),
274+ );
275+ },
276+
277+ store: ({ value, runtime }) => {
278+ const id = randomUUID();
279+
280+ return runCommand({
281+ command: "security",
282+ args: [
283+ "add-generic-password",
284+ "-a",
285+ id,
286+ "-s",
287+ runtime.keychainServiceName,
288+ "-w",
289+ value,
290+ "-U",
291+ ],
292+ operation: "keychain.put",
293+ }).pipe(
294+ Effect.flatMap((result) =>
295+ ensureCommandSuccess({
296+ result,
297+ operation: "keychain.put",
298+ message: "Failed storing secret in macOS keychain",
299+ }),
300+ ),
301+ Effect.as({
302+ providerId: KEYCHAIN_SECRET_PROVIDER_ID,
303+ handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
304+ } satisfies SecretRef),
305+ );
306+ },
307+});
308+
309+const keychainStoreWithSecretTool = (): SecretMaterialProvider => ({
310+ resolve: ({ ref, runtime }) => {
311+ const id = parseKeychainHandle(ref.handle);
312+ if (id === null) {
313+ return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
314+ }
315+
316+ return runCommand({
317+ command: "secret-tool",
318+ args: [
319+ "lookup",
320+ "service",
321+ runtime.keychainServiceName,
322+ "account",
323+ id,
324+ ],
325+ operation: "keychain.get",
326+ }).pipe(
327+ Effect.flatMap((result) =>
328+ ensureCommandSuccess({
329+ result,
330+ operation: "keychain.get",
331+ message: "Failed loading secret from desktop keyring",
332+ }),
333+ ),
334+ Effect.map((result) => result.stdout.trimEnd()),
335+ );
336+ },
337+
338+ store: ({ value, runtime }) => {
339+ const id = randomUUID();
340+
341+ return runCommand({
342+ command: "secret-tool",
343+ args: [
344+ "store",
345+ "--label",
346+ runtime.keychainServiceName,
347+ "service",
348+ runtime.keychainServiceName,
349+ "account",
350+ id,
351+ ],
352+ stdin: value,
353+ operation: "keychain.put",
354+ }).pipe(
355+ Effect.flatMap((result) =>
356+ ensureCommandSuccess({
357+ result,
358+ operation: "keychain.put",
359+ message: "Failed storing secret in desktop keyring",
360+ }),
361+ ),
362+ Effect.as({
363+ providerId: KEYCHAIN_SECRET_PROVIDER_ID,
364+ handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
365+ } satisfies SecretRef),
366+ );
367+ },
368+});
369+
370+const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => {
371+ if (process.platform === "darwin") {
372+ return keychainStoreWithSecurityCli();
373+ }
374+
375+ if (process.platform === "linux") {
376+ return keychainStoreWithSecretTool();
377+ }
378+
379+ const unsupported = (operation: string) =>
380+ Effect.fail(new Error(`${operation}: keychain provider is unsupported on platform '${process.platform}'`));
381+
382+ return {
383+ resolve: () => unsupported("keychain.get"),
384+ store: () => unsupported("keychain.put"),
385+ } satisfies SecretMaterialProvider;
386+};
387+
388+const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry =>
389+ new Map([
390+ [PARAMS_SECRET_PROVIDER_ID, createParamsSecretMaterialProvider()],
391+ [ENV_SECRET_PROVIDER_ID, createEnvSecretMaterialProvider()],
392+ [KEYCHAIN_SECRET_PROVIDER_ID, createKeychainSecretMaterialProvider()],
393+ [POSTGRES_SECRET_PROVIDER_ID, createPostgresSecretMaterialProvider()],
394+ ]);
395+
396+const getSecretMaterialProvider = (input: {
397+ providers: SecretMaterialProviderRegistry;
398+ providerId: string;
399+}): Effect.Effect<SecretMaterialProvider, Error, never> => {
400+ const provider = input.providers.get(input.providerId);
401+ if (provider) {
402+ return Effect.succeed(provider);
403+ }
404+
405+ return Effect.fail(new Error(`Unsupported secret provider: ${input.providerId}`));
406+};
407+
408+const createSecretMaterialProviderRuntime = (input: {
409+ rows: SqlControlPlaneRows;
410+ dangerouslyAllowEnvSecrets?: boolean;
411+ keychainServiceName?: string;
412+}): SecretMaterialProviderRuntime => ({
413+ rows: input.rows,
414+ env: process.env,
415+ dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets),
416+ keychainServiceName: resolveKeychainServiceName(input.keychainServiceName),
417+});
418+
419+export const createDefaultSecretMaterialResolver = (input: {
420+ rows: SqlControlPlaneRows;
421+ dangerouslyAllowEnvSecrets?: boolean;
422+ keychainServiceName?: string;
423+}): ResolveSecretMaterial => {
424+ const providers = createSecretMaterialProviderRegistry();
425+ const runtime = createSecretMaterialProviderRuntime(input);
426+
427+ return ({ ref, context }) =>
428+ Effect.gen(function* () {
429+ const provider = yield* getSecretMaterialProvider({
430+ providers,
431+ providerId: ref.providerId,
432+ });
433+
434+ return yield* provider.resolve({
435+ ref,
436+ context: context ?? {},
437+ runtime,
438+ });
439+ });
440+};
441+
442+export const createDefaultSecretMaterialStorer = (input: {
443+ rows: SqlControlPlaneRows;
444+ storeProviderId?: SecretStoreProviderId;
445+ dangerouslyAllowEnvSecrets?: boolean;
446+ keychainServiceName?: string;
447+}): StoreSecretMaterial => {
448+ const providers = createSecretMaterialProviderRegistry();
449+ const runtime = createSecretMaterialProviderRuntime(input);
450+ const defaultStoreProviderId = resolveSecretStoreProviderId(input.storeProviderId);
451+
452+ return ({ purpose, value }) =>
453+ Effect.gen(function* () {
454+ const provider = yield* getSecretMaterialProvider({
455+ providers,
456+ providerId: defaultStoreProviderId,
457+ });
458+
459+ if (!provider.store) {
460+ return yield* Effect.fail(
461+ new Error(`Secret provider ${defaultStoreProviderId} does not support storing secret material`),
462+ );
463+ }
464+
465+ return yield* provider.store({
466+ purpose,
467+ value,
468+ runtime,
469+ });
470+ });
471+};
packages/control-plane/src/runtime/source-auth-service.test.ts+57−0
@@ -0,0 +1,57 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { createTerminalSourceAuthSessionPatch } from "./source-auth-service";
4+
5+describe("source-auth-service", () => {
6+ it("clears ephemeral OAuth session fields when failing a session", () => {
7+ const patch = createTerminalSourceAuthSessionPatch({
8+ status: "failed",
9+ now: 123,
10+ errorText: "OAuth authorization failed",
11+ resourceMetadataUrl: "https://example.com/resource",
12+ authorizationServerUrl: "https://example.com/as",
13+ resourceMetadataJson: '{"issuer":"https://example.com"}',
14+ authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
15+ });
16+
17+ expect(patch).toMatchObject({
18+ status: "failed",
19+ errorText: "OAuth authorization failed",
20+ completedAt: 123,
21+ updatedAt: 123,
22+ codeVerifier: null,
23+ authorizationUrl: null,
24+ clientInformationJson: null,
25+ resourceMetadataUrl: "https://example.com/resource",
26+ authorizationServerUrl: "https://example.com/as",
27+ resourceMetadataJson: '{"issuer":"https://example.com"}',
28+ authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
29+ });
30+ });
31+
32+ it("clears ephemeral OAuth session fields when completing a session", () => {
33+ const patch = createTerminalSourceAuthSessionPatch({
34+ status: "completed",
35+ now: 456,
36+ errorText: null,
37+ resourceMetadataUrl: "https://example.com/resource",
38+ authorizationServerUrl: "https://example.com/as",
39+ resourceMetadataJson: '{"issuer":"https://example.com"}',
40+ authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
41+ });
42+
43+ expect(patch).toMatchObject({
44+ status: "completed",
45+ errorText: null,
46+ completedAt: 456,
47+ updatedAt: 456,
48+ codeVerifier: null,
49+ authorizationUrl: null,
50+ clientInformationJson: null,
51+ resourceMetadataUrl: "https://example.com/resource",
52+ authorizationServerUrl: "https://example.com/as",
53+ resourceMetadataJson: '{"issuer":"https://example.com"}',
54+ authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
55+ });
56+ });
57+});
packages/control-plane/src/runtime/source-auth-service.ts+100−191
@@ -18,8 +18,7 @@ import { · 17 unmodified lines
1818 } from "#persistence";
1919 import {
2020 ExecutionIdSchema,
21− type SecretMaterialId,
22− SecretMaterialIdSchema,
21+ type SecretMaterialPurpose,
2322 Source,
2423 SourceAuthSession,
2524 SourceAuthSessionIdSchema,
@@ -41,18 +40,23 @@ import { · 15 unmodified lines
4140 } from "./live-execution";
4241 import {
4342 createSourceFromPayload,
44− projectSourceFromStorage,
45− projectSourcesFromStorage,
46− splitSourceForStorage,
4743 updateSourceFromPayload,
4844 } from "./source-definitions";
4945 import {
50− createEnvSecretMaterialResolver,
46+ createDefaultSecretMaterialResolver,
47+ createDefaultSecretMaterialStorer,
48+ type ResolveSecretMaterial,
49+ type StoreSecretMaterial,
50+} from "./secret-material-providers";
51+import {
5152 persistMcpToolArtifactsFromManifest,
5253 syncSourceToolArtifacts,
5354 } from "./tool-artifacts";
54−
55−export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane";
55+import {
56+ loadSourceById,
57+ loadSourcesInWorkspace,
58+ persistSource,
59+} from "./source-store";
5660
5761 const trimOrNull = (value: string | null | undefined): string | null => {
5862 if (value === null || value === undefined) {
@@ -119,67 +123,7 @@ const normalizeEndpoint = (endpoint: string): string => { · 60 unmodified lines
119123 const toError = (cause: unknown): Error =>
120124 cause instanceof Error ? cause : new Error(String(cause));
121125
122−const loadSourcesInWorkspace = (rows: SqlControlPlaneRows, workspaceId: WorkspaceId) =>
123− Effect.gen(function* () {
124− const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
125− const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId);
126−
127− return yield* projectSourcesFromStorage({
128− sourceRecords,
129− credentialBindings,
130− });
131− });
132−
133−const loadSourceById = (rows: SqlControlPlaneRows, input: {
134− workspaceId: WorkspaceId;
135− sourceId: Source["id"];
136−}) =>
137− Effect.gen(function* () {
138− const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
139− input.workspaceId,
140− input.sourceId,
141− );
142−
143− if (Option.isNone(sourceRecord)) {
144− return yield* Effect.fail(
145− new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
146− );
147− }
148−
149− const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
150− input.workspaceId,
151− input.sourceId,
152− );
153−
154− return yield* projectSourceFromStorage({
155− sourceRecord: sourceRecord.value,
156− credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
157− });
158− });
159−
160−const persistSource = (rows: SqlControlPlaneRows, source: Source) =>
161− Effect.gen(function* () {
162− const { sourceRecord, credentialBinding } = splitSourceForStorage({ source });
163− const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
164−
165− if (Option.isNone(existing)) {
166− yield* rows.sources.insert(sourceRecord);
167− } else {
168− const { id: _id, workspaceId: _workspaceId, createdAt: _createdAt, sourceDocumentText: _sourceDocumentText, ...patch } = sourceRecord;
169− yield* rows.sources.update(source.workspaceId, source.id, patch);
170− }
171−
172− if (credentialBinding === null) {
173− yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
174− source.workspaceId,
175− source.id,
176− );
177− } else {
178− yield* rows.sourceCredentialBindings.upsert(credentialBinding);
179− }
180126
181− return source;
182− });
183127
184128 const probeMcpSourceWithoutAuth = (
185129 source: Source,
@@ -303,7 +247,6 @@ const startOAuthAuthorization = (input: { · 117 unmodified lines
303247
304248 type ExchangedTokens = {
305249 tokens: OAuthTokens;
306− clientInformationJson: string | null;
307250 resourceMetadataUrl: string | null;
308251 authorizationServerUrl: string | null;
309252 resourceMetadataJson: string | null;
@@ -385,7 +328,6 @@ const exchangeOAuthAuthorizationCode = (input: { · 75 unmodified lines
385328
386329 return {
387330 tokens: captured.tokens,
388− clientInformationJson: serializeJson(captured.clientInformation),
389331 resourceMetadataUrl: captured.discoveryState?.resourceMetadataUrl ?? null,
390332 authorizationServerUrl: captured.discoveryState?.authorizationServerUrl ?? null,
391333 resourceMetadataJson: serializeJson(captured.discoveryState?.resourceMetadata),
@@ -395,6 +337,29 @@ const exchangeOAuthAuthorizationCode = (input: { · 3 unmodified lines
395337 } satisfies ExchangedTokens;
396338 });
397339
340+export const createTerminalSourceAuthSessionPatch = (input: {
341+ status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">;
342+ now: number;
343+ errorText: string | null;
344+ resourceMetadataUrl?: string | null;
345+ authorizationServerUrl?: string | null;
346+ resourceMetadataJson?: string | null;
347+ authorizationServerMetadataJson?: string | null;
348+}) => ({
349+ status: input.status,
350+ errorText: input.errorText,
351+ completedAt: input.now,
352+ updatedAt: input.now,
353+ codeVerifier: null,
354+ authorizationUrl: null,
355+ clientInformationJson: null,
356+ resourceMetadataUrl: input.resourceMetadataUrl ?? null,
357+ authorizationServerUrl: input.authorizationServerUrl ?? null,
358+ resourceMetadataJson: input.resourceMetadataJson ?? null,
359+ authorizationServerMetadataJson: input.authorizationServerMetadataJson ?? null,
360+}) satisfies Partial<SourceAuthSession>;
361+
362+
398363 const completeLiveInteraction = (input: {
399364 liveExecutionManager: LiveExecutionManager;
400365 session: SourceAuthSession;
@@ -446,24 +411,6 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 45 unmodified lines
446411 });
447412 });
448413
449−const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: {
450− purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token";
451− value: string;
452−}) =>
453− Effect.gen(function* () {
454− const now = Date.now();
455− const materialId = SecretMaterialIdSchema.make(`sec_${crypto.randomUUID()}`);
456− yield* rows.secretMaterials.upsert({
457− id: materialId,
458− purpose: input.purpose,
459− value: input.value,
460− createdAt: now,
461− updatedAt: now,
462− });
463−
464− return materialId;
465− });
466−
467414 export type ExecutorSourceAddResult =
468415 | {
469416 kind: "connected";
@@ -489,8 +436,7 @@ export type ExecutorOpenApiSourceAuthInput = · 19 unmodified lines
489436 headerName?: string | null;
490437 prefix?: string | null;
491438 token?: string | null;
492− tokenEnvVar?: string | null;
493− tokenSecretMaterialId?: string | null;
439+ tokenRef?: SecretRef | null;
494440 };
495441
496442 export type ExecutorAddSourceInput =
@@ -527,9 +473,9 @@ const shouldPromptForOpenApiCredentialSetup = (input: { · 30 unmodified lines
527473 };
528474
529475 const materializeExecutorOpenApiAuth = (input: {
530− rows: SqlControlPlaneRows;
531476 existing?: Source;
532477 auth?: ExecutorOpenApiSourceAuthInput | null;
478+ storeSecretMaterial: StoreSecretMaterial;
533479 }): Effect.Effect<Source["auth"], Error, never> =>
534480 Effect.gen(function* () {
535481 if (input.auth === undefined && input.existing?.kind === "openapi") {
@@ -544,42 +490,46 @@ const materializeExecutorOpenApiAuth = (input: { · 8 unmodified lines
544490 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
545491 const prefix = auth.prefix ?? "Bearer ";
546492 const token = trimOrNull(auth.token);
547− const tokenEnvVar = trimOrNull(auth.tokenEnvVar);
548− const tokenSecretMaterialId = trimOrNull(auth.tokenSecretMaterialId);
493+ const tokenRefInput = auth.tokenRef ?? null;
549494
550495 if (
551496 token === null
552− && tokenEnvVar === null
553− && tokenSecretMaterialId === null
497+ && tokenRefInput === null
554498 && input.existing?.kind === "openapi"
555499 && input.existing.auth.kind === "bearer"
556500 ) {
557501 return input.existing.auth;
558502 }
559503
560− if (token === null && tokenEnvVar === null && tokenSecretMaterialId === null) {
504+ if (token === null && tokenRefInput === null) {
561505 return yield* Effect.fail(
562− new Error("Bearer auth requires token, tokenEnvVar, or tokenSecretMaterialId"),
506+ new Error("Bearer auth requires token or tokenRef"),
563507 );
564508 }
565509
566− const tokenRef: SecretRef = token !== null
567− ? {
568− providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
569− handle: yield* upsertSecretMaterial(input.rows, {
570− purpose: "auth_material",
571− value: token,
572− }),
573− }
574− : tokenSecretMaterialId !== null
575− ? {
576− providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
577− handle: tokenSecretMaterialId,
510+ const tokenRef = token !== null
511+ ? yield* input.storeSecretMaterial({
512+ purpose: "auth_material",
513+ value: token,
514+ })
515+ : (() => {
516+ const providerId = trimOrNull(tokenRefInput?.providerId);
517+ const handle = trimOrNull(tokenRefInput?.handle);
518+ if (providerId === null || handle === null) {
519+ return null;
578520 }
579− : {
580− providerId: "env",
581− handle: tokenEnvVar!,
582− };
521+
522+ return {
523+ providerId,
524+ handle,
525+ } satisfies SecretRef;
526+ })();
527+
528+ if (tokenRef === null) {
529+ return yield* Effect.fail(
530+ new Error("Bearer auth tokenRef must include providerId and handle"),
531+ );
532+ }
583533
584534 return {
585535 kind: "bearer",
@@ -596,9 +546,9 @@ type RuntimeSourceAuthServiceShape = { · 10 unmodified lines
596546 }) => Effect.Effect<Source, Error, never>;
597547 getLocalServerBaseUrl: () => string | null;
598548 storeSecretMaterial: (input: {
599− purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token";
549+ purpose: SecretMaterialPurpose;
600550 value: string;
601− }) => Effect.Effect<SecretMaterialId, Error, never>;
551+ }) => Effect.Effect<SecretRef, Error, never>;
602552 addExecutorSource: (
603553 input: ExecutorAddSourceInput,
604554 options?: {
@@ -615,50 +565,23 @@ type RuntimeSourceAuthServiceShape = { · 10 unmodified lines
615565 }) => Effect.Effect<Source, Error, never>;
616566 };
617567
618−export type ResolveSecretMaterial = (ref: SecretRef) => Effect.Effect<string, Error, never>;
619−
620−export const createDbBackedSecretMaterialResolver = (input: {
621− rows: SqlControlPlaneRows;
622− fallback?: ResolveSecretMaterial;
623−}): ResolveSecretMaterial =>
624− (ref) =>
625− Effect.gen(function* () {
626− if (ref.providerId === CONTROL_PLANE_SECRET_PROVIDER_ID) {
627− const materialId = SecretMaterialIdSchema.make(ref.handle);
628− const stored = yield* input.rows.secretMaterials.getById(materialId);
629− if (Option.isNone(stored)) {
630− return yield* Effect.fail(
631− new Error(`Secret material not found: ${ref.handle}`),
632− );
633− }
634−
635− return stored.value.value;
636− }
637−
638− if (input.fallback) {
639− return yield* input.fallback(ref);
640− }
641−
642− return yield* Effect.fail(
643− new Error(`Unsupported secret provider ${ref.providerId}`),
644− );
645− });
646−
647568 export const createRuntimeSourceAuthService = (input: {
648569 rows: SqlControlPlaneRows;
649570 liveExecutionManager: LiveExecutionManager;
650571 getLocalServerBaseUrl?: () => string | undefined;
651572 }) => {
652− const resolveSecretMaterial = createDbBackedSecretMaterialResolver({
573+ const resolveSecretMaterial = createDefaultSecretMaterialResolver({
574+ rows: input.rows,
575+ });
576+ const storeSecretMaterial = createDefaultSecretMaterialStorer({
653577 rows: input.rows,
654− fallback: createEnvSecretMaterialResolver(),
655578 });
656579
657580 return {
658581 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,
659582
660583 storeSecretMaterial: ({ purpose, value }) =>
661− upsertSecretMaterial(input.rows, {
584+ storeSecretMaterial({
662585 purpose,
663586 value,
664587 }),
@@ -739,9 +662,9 @@ export const createRuntimeSourceAuthService = (input: { · 74 unmodified lines
739662 }
740663
741664 const auth = yield* materializeExecutorOpenApiAuth({
742− rows: input.rows,
743665 existing,
744666 auth: sourceInput.auth,
667+ storeSecretMaterial,
745668 });
746669
747670 const draftSource = existing
@@ -1015,12 +938,18 @@ export const createRuntimeSourceAuthService = (input: { · 267 unmodified lines
1015938 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";
1016939 const failedAt = Date.now();
1017940
1018− yield* input.rows.sourceAuthSessions.update(session.id, {
1019− status: "failed",
1020− errorText: reason,
1021− completedAt: failedAt,
1022− updatedAt: failedAt,
1023− });
941+ yield* input.rows.sourceAuthSessions.update(
942+ session.id,
943+ createTerminalSourceAuthSessionPatch({
944+ status: "failed",
945+ now: failedAt,
946+ errorText: reason,
947+ resourceMetadataUrl: session.resourceMetadataUrl,
948+ authorizationServerUrl: session.authorizationServerUrl,
949+ resourceMetadataJson: session.resourceMetadataJson,
950+ authorizationServerMetadataJson: session.authorizationServerMetadataJson,
951+ }),
952+ );
1024953 const failedSource = yield* updateSourceStatus(input.rows, source, {
1025954 status: "error",
1026955 lastError: reason,
@@ -1052,30 +981,18 @@ export const createRuntimeSourceAuthService = (input: { · 25 unmodified lines
1052981 code: authorizationCode,
1053982 });
1054983
1055− const accessTokenId = yield* upsertSecretMaterial(input.rows, {
984+ const accessTokenRef = yield* storeSecretMaterial({
1056985 purpose: "oauth_access_token",
1057986 value: exchanged.tokens.access_token,
1058987 });
1059− const refreshTokenId = exchanged.tokens.refresh_token
1060− ? yield* upsertSecretMaterial(input.rows, {
988+ const refreshTokenRef = exchanged.tokens.refresh_token
989+ ? yield* storeSecretMaterial({
1061990 purpose: "oauth_refresh_token",
1062991 value: exchanged.tokens.refresh_token,
1063992 })
1064993 : null;
1065994
1066995 const now = Date.now();
1067− yield* input.rows.sourceCredentialBindings.upsert({
1068− workspaceId: session.workspaceId,
1069− sourceId: session.sourceId,
1070− tokenProviderId: CONTROL_PLANE_SECRET_PROVIDER_ID,
1071− tokenHandle: accessTokenId,
1072− refreshTokenProviderId:
1073− refreshTokenId === null ? null : CONTROL_PLANE_SECRET_PROVIDER_ID,
1074− refreshTokenHandle: refreshTokenId,
1075− createdAt: now,
1076− updatedAt: now,
1077− });
1078−
1079996 const connectedSource = yield* updateSourceStatus(input.rows, source, {
1080997 status: "connected",
1081998 lastError: null,
@@ -1083,17 +1000,8 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line
10831000 kind: "oauth2",
10841001 headerName: "Authorization",
10851002 prefix: "Bearer ",
1086− accessToken: {
1087− providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
1088− handle: accessTokenId,
1089− },
1090− refreshToken:
1091− refreshTokenId === null
1092− ? null
1093− : {
1094− providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
1095− handle: refreshTokenId,
1096− },
1003+ accessToken: accessTokenRef,
1004+ refreshToken: refreshTokenRef,
10971005 },
10981006 });
10991007 const indexed = yield* Effect.either(
@@ -1114,17 +1022,18 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines
11141022 onRight: () => Effect.succeed(undefined),
11151023 });
11161024
1117− yield* input.rows.sourceAuthSessions.update(session.id, {
1118− status: "completed",
1119− errorText: null,
1120− completedAt: now,
1121− updatedAt: now,
1122− resourceMetadataUrl: exchanged.resourceMetadataUrl,
1123− authorizationServerUrl: exchanged.authorizationServerUrl,
1124− resourceMetadataJson: exchanged.resourceMetadataJson,
1125− authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1126− clientInformationJson: exchanged.clientInformationJson,
1127− });
1025+ yield* input.rows.sourceAuthSessions.update(
1026+ session.id,
1027+ createTerminalSourceAuthSessionPatch({
1028+ status: "completed",
1029+ now,
1030+ errorText: null,
1031+ resourceMetadataUrl: exchanged.resourceMetadataUrl,
1032+ authorizationServerUrl: exchanged.authorizationServerUrl,
1033+ resourceMetadataJson: exchanged.resourceMetadataJson,
1034+ authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1035+ }),
1036+ );
11281037
11291038 yield* completeLiveInteraction({
11301039 liveExecutionManager: input.liveExecutionManager,
packages/control-plane/src/runtime/source-credential-interactions.ts+31−0
@@ -0,0 +1,31 @@
1+import { SecretRefSchema, type SecretRef } from "#schema";
2+import * as Schema from "effect/Schema";
3+
4+export const SourceCredentialSelectionContentSchema = Schema.Union(
5+ Schema.Struct({
6+ authKind: Schema.Literal("none"),
7+ }),
8+ Schema.Struct({
9+ authKind: Schema.Literal("bearer"),
10+ tokenRef: SecretRefSchema,
11+ }),
12+);
13+
14+export type SourceCredentialSelectionContent =
15+ typeof SourceCredentialSelectionContentSchema.Type;
16+
17+export const decodeSourceCredentialSelectionContent = Schema.decodeUnknownSync(
18+ SourceCredentialSelectionContentSchema,
19+);
20+
21+export const createSourceCredentialSelectionNoneContent =
22+ (): SourceCredentialSelectionContent => ({
23+ authKind: "none",
24+ });
25+
26+export const createSourceCredentialSelectionBearerContent = (
27+ tokenRef: SecretRef,
28+): SourceCredentialSelectionContent => ({
29+ authKind: "bearer",
30+ tokenRef,
31+});
packages/control-plane/src/runtime/source-definitions.ts+71−47
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
33 UpdateSourcePayload,
44 } from "#api";
55 import type {
6+ Credential,
67 Source,
78 SourceAuth,
89 SourceCredentialBinding,
@@ -11,6 +12,7 @@ import type { · 2 unmodified lines
1112 StringMap,
1213 WorkspaceId,
1314 } from "#schema";
15+import { CredentialIdSchema } from "#schema";
1416 import * as Effect from "effect/Effect";
1517 import * as Schema from "effect/Schema";
1618
@@ -232,8 +234,11 @@ export const updateSourceFromPayload = (input: { · 215 unmodified lines
232234
233235 export const splitSourceForStorage = (input: {
234236 source: Source;
237+ existingCredentialId?: Credential["id"] | null;
238+ existingBindingId?: SourceCredentialBinding["id"] | null;
235239 }): {
236240 sourceRecord: StoredSourceRecord;
241+ credential: Credential | null;
237242 credentialBinding: SourceCredentialBinding | null;
238243 } => {
239244 const sourceRecord: StoredSourceRecord = {
@@ -250,10 +255,6 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines
250255 headersJson: serializeStringMap(input.source.headers),
251256 specUrl: input.source.specUrl,
252257 defaultHeadersJson: serializeStringMap(input.source.defaultHeaders),
253− authKind: input.source.auth.kind,
254− authHeaderName:
255− input.source.auth.kind === "none" ? null : input.source.auth.headerName,
256− authPrefix: input.source.auth.kind === "none" ? null : input.source.auth.prefix,
257258 sourceHash: input.source.sourceHash,
258259 sourceDocumentText: null,
259260 lastError: input.source.lastError,
@@ -264,31 +265,49 @@ export const splitSourceForStorage = (input: { · 4 unmodified lines
264265 if (input.source.auth.kind === "none") {
265266 return {
266267 sourceRecord,
268+ credential: null,
267269 credentialBinding: null,
268270 };
269271 }
270272
273+ const credentialId = input.existingCredentialId
274+ ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
275+ const bindingId = input.existingBindingId ?? `src_cred_bind_${crypto.randomUUID()}`;
276+
277+ const credential: Credential = {
278+ id: credentialId,
279+ workspaceId: input.source.workspaceId,
280+ authKind: input.source.auth.kind,
281+ authHeaderName: input.source.auth.headerName,
282+ authPrefix: input.source.auth.prefix,
283+ tokenProviderId:
284+ input.source.auth.kind === "bearer"
285+ ? input.source.auth.token.providerId
286+ : input.source.auth.accessToken.providerId,
287+ tokenHandle:
288+ input.source.auth.kind === "bearer"
289+ ? input.source.auth.token.handle
290+ : input.source.auth.accessToken.handle,
291+ refreshTokenProviderId:
292+ input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
293+ ? input.source.auth.refreshToken.providerId
294+ : null,
295+ refreshTokenHandle:
296+ input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
297+ ? input.source.auth.refreshToken.handle
298+ : null,
299+ createdAt: input.source.createdAt,
300+ updatedAt: input.source.updatedAt,
301+ };
302+
271303 return {
272304 sourceRecord,
305+ credential,
273306 credentialBinding: {
307+ id: bindingId,
274308 workspaceId: input.source.workspaceId,
275309 sourceId: input.source.id,
276− tokenProviderId:
277− input.source.auth.kind === "bearer"
278− ? input.source.auth.token.providerId
279− : input.source.auth.accessToken.providerId,
280− tokenHandle:
281− input.source.auth.kind === "bearer"
282− ? input.source.auth.token.handle
283− : input.source.auth.accessToken.handle,
284− refreshTokenProviderId:
285− input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
286− ? input.source.auth.refreshToken.providerId
287− : null,
288− refreshTokenHandle:
289− input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
290− ? input.source.auth.refreshToken.handle
291− : null,
310+ credentialId,
292311 createdAt: input.source.createdAt,
293312 updatedAt: input.source.updatedAt,
294313 },
@@ -298,6 +317,7 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines
298317 export const projectSourceFromStorage = (input: {
299318 sourceRecord: StoredSourceRecord;
300319 credentialBinding: SourceCredentialBinding | null;
320+ credential: Credential | null;
301321 }): Effect.Effect<Source, Error, never> =>
302322 Effect.gen(function* () {
303323 const queryParams = yield* parseStringMapJson(
@@ -313,44 +333,39 @@ export const projectSourceFromStorage = (input: { · 9 unmodified lines
313333 input.sourceRecord.defaultHeadersJson,
314334 );
315335
316− let auth: SourceAuth;
317− if (input.sourceRecord.authKind === "none") {
318− auth = { kind: "none" };
319− } else {
320− const binding = input.credentialBinding;
321− if (binding === null || binding.tokenProviderId === null || binding.tokenHandle === null) {
336+ let auth: SourceAuth = { kind: "none" };
337+ if (input.credentialBinding !== null) {
338+ const credential = input.credential;
339+ if (credential === null) {
322340 return yield* Effect.fail(
323− new Error(`Missing credential binding for source ${input.sourceRecord.id}`),
341+ new Error(`Missing credential ${input.credentialBinding.credentialId} for source ${input.sourceRecord.id}`),
324342 );
325343 }
326344
327− const headerName = trimOrNull(input.sourceRecord.authHeaderName) ?? "Authorization";
328− const prefix = input.sourceRecord.authPrefix ?? "Bearer ";
329−
330− if (input.sourceRecord.authKind === "bearer") {
345+ if (credential.authKind === "bearer") {
331346 auth = {
332347 kind: "bearer",
333− headerName,
334− prefix,
348+ headerName: credential.authHeaderName,
349+ prefix: credential.authPrefix,
335350 token: {
336− providerId: binding.tokenProviderId,
337− handle: binding.tokenHandle,
351+ providerId: credential.tokenProviderId,
352+ handle: credential.tokenHandle,
338353 },
339354 };
340355 } else {
341356 auth = {
342357 kind: "oauth2",
343− headerName,
344− prefix,
358+ headerName: credential.authHeaderName,
359+ prefix: credential.authPrefix,
345360 accessToken: {
346− providerId: binding.tokenProviderId,
347− handle: binding.tokenHandle,
361+ providerId: credential.tokenProviderId,
362+ handle: credential.tokenHandle,
348363 },
349364 refreshToken:
350− binding.refreshTokenProviderId !== null && binding.refreshTokenHandle !== null
365+ credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null
351366 ? {
352− providerId: binding.refreshTokenProviderId,
353− handle: binding.refreshTokenHandle,
367+ providerId: credential.refreshTokenProviderId,
368+ handle: credential.refreshTokenHandle,
354369 }
355370 : null,
356371 };
@@ -386,15 +401,24 @@ export const projectSourceFromStorage = (input: { · 29 unmodified lines
386401 export const projectSourcesFromStorage = (input: {
387402 sourceRecords: ReadonlyArray<StoredSourceRecord>;
388403 credentialBindings: ReadonlyArray<SourceCredentialBinding>;
404+ credentials: ReadonlyArray<Credential>;
389405 }): Effect.Effect<ReadonlyArray<Source>, Error, never> => {
390406 const bindingsBySourceId = new Map(
391407 input.credentialBindings.map((binding) => [binding.sourceId, binding]),
392408 );
409+ const credentialsById = new Map(
410+ input.credentials.map((credential) => [credential.id, credential]),
411+ );
393412
394− return Effect.forEach(input.sourceRecords, (sourceRecord) =>
395− projectSourceFromStorage({
413+ return Effect.forEach(input.sourceRecords, (sourceRecord) => {
414+ const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null;
415+ return projectSourceFromStorage({
396416 sourceRecord,
397− credentialBinding: bindingsBySourceId.get(sourceRecord.id) ?? null,
398− }),
399− );
417+ credentialBinding,
418+ credential:
419+ credentialBinding === null
420+ ? null
421+ : credentialsById.get(credentialBinding.credentialId) ?? null,
422+ });
423+ });
400424 };
packages/control-plane/src/runtime/source-inspection.ts+4−13
@@ -27,7 +27,7 @@ import * as Effect from "effect/Effect"; · 26 unmodified lines
2727 import * as Option from "effect/Option";
2828
2929 import { operationErrors } from "./operation-errors";
30−import { projectSourceFromStorage } from "./source-definitions";
30+import { loadSourceById as loadStoredSourceById } from "./source-store";
3131 import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
3232 import { namespaceFromSourceName } from "./tool-artifacts";
3333
@@ -97,18 +97,9 @@ const loadSourceRecord = (input: { · 63 unmodified lines
9797 );
9898 }
9999
100− const credentialBinding = yield* sourceInspectOps.bundle.child("binding").mapStorage(
101− store.sourceCredentialBindings.getByWorkspaceAndSourceId(
102− input.workspaceId,
103− input.sourceId,
104− ),
105− );
106−
107− const source = yield* projectSourceFromStorage({
108− sourceRecord: sourceRecord.value,
109− credentialBinding: Option.isSome(credentialBinding)
110− ? credentialBinding.value
111− : null,
100+ const source = yield* loadStoredSourceById(store, {
101+ workspaceId: input.workspaceId,
102+ sourceId: input.sourceId,
112103 }).pipe(
113104 Effect.mapError((cause) =>
114105 sourceInspectOps.bundle.unknownStorage(
packages/control-plane/src/runtime/source-store.ts+132−0
@@ -0,0 +1,132 @@
1+import type {
2+ Credential,
3+ Source,
4+ WorkspaceId,
5+} from "#schema";
6+import { type SqlControlPlaneRows } from "#persistence";
7+import * as Effect from "effect/Effect";
8+import * as Option from "effect/Option";
9+
10+import {
11+ projectSourceFromStorage,
12+ projectSourcesFromStorage,
13+ splitSourceForStorage,
14+} from "./source-definitions";
15+
16+export const loadSourcesInWorkspace = (
17+ rows: SqlControlPlaneRows,
18+ workspaceId: WorkspaceId,
19+) =>
20+ Effect.gen(function* () {
21+ const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
22+ const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId);
23+ const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId);
24+
25+ return yield* projectSourcesFromStorage({
26+ sourceRecords,
27+ credentialBindings,
28+ credentials,
29+ });
30+ });
31+
32+export const loadSourceById = (rows: SqlControlPlaneRows, input: {
33+ workspaceId: WorkspaceId;
34+ sourceId: Source["id"];
35+}) =>
36+ Effect.gen(function* () {
37+ const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
38+ input.workspaceId,
39+ input.sourceId,
40+ );
41+
42+ if (Option.isNone(sourceRecord)) {
43+ return yield* Effect.fail(
44+ new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
45+ );
46+ }
47+
48+ const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
49+ input.workspaceId,
50+ input.sourceId,
51+ );
52+ const credential =
53+ Option.isSome(credentialBinding)
54+ ? yield* rows.credentials.getById(credentialBinding.value.credentialId)
55+ : Option.none<Credential>();
56+
57+ return yield* projectSourceFromStorage({
58+ sourceRecord: sourceRecord.value,
59+ credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
60+ credential: Option.isSome(credential) ? credential.value : null,
61+ });
62+ });
63+
64+export const removeCredentialBindingForSource = (rows: SqlControlPlaneRows, input: {
65+ workspaceId: WorkspaceId;
66+ sourceId: Source["id"];
67+}) =>
68+ Effect.gen(function* () {
69+ const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
70+ input.workspaceId,
71+ input.sourceId,
72+ );
73+
74+ if (Option.isNone(existingBinding)) {
75+ return false;
76+ }
77+
78+ yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
79+ input.workspaceId,
80+ input.sourceId,
81+ );
82+ yield* rows.credentials.removeById(existingBinding.value.credentialId);
83+ return true;
84+ });
85+
86+export const persistSource = (rows: SqlControlPlaneRows, source: Source) =>
87+ Effect.gen(function* () {
88+ const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
89+ const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
90+ source.workspaceId,
91+ source.id,
92+ );
93+ const existingCredentialId = Option.isSome(existingBinding)
94+ ? existingBinding.value.credentialId
95+ : null;
96+ const existingBindingId = Option.isSome(existingBinding)
97+ ? existingBinding.value.id
98+ : null;
99+ const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
100+ source,
101+ existingCredentialId,
102+ existingBindingId,
103+ });
104+
105+ if (Option.isNone(existing)) {
106+ yield* rows.sources.insert(sourceRecord);
107+ } else {
108+ const {
109+ id: _id,
110+ workspaceId: _workspaceId,
111+ createdAt: _createdAt,
112+ sourceDocumentText: _sourceDocumentText,
113+ ...patch
114+ } = sourceRecord;
115+ yield* rows.sources.update(source.workspaceId, source.id, patch);
116+ }
117+
118+ if (credential === null || credentialBinding === null) {
119+ if (Option.isSome(existingBinding)) {
120+ yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
121+ source.workspaceId,
122+ source.id,
123+ );
124+ yield* rows.credentials.removeById(existingBinding.value.credentialId);
125+ }
126+ } else {
127+ yield* rows.credentials.upsert(credential);
128+ yield* rows.sourceCredentialBindings.upsert(credentialBinding);
129+ }
130+
131+ return source;
132+ });
packages/control-plane/src/runtime/sources-operations.ts+82−77
@@ -25,14 +25,14 @@ import { · 24 unmodified lines
2525 import {
2626 operationErrors,
2727 } from "./operation-errors";
28−import {
29− createDbBackedSecretMaterialResolver,
30−} from "./source-auth-service";
28+import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
3129 import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
30+import { syncSourceToolArtifacts } from "./tool-artifacts";
3231 import {
33− createEnvSecretMaterialResolver,
34− syncSourceToolArtifacts,
35−} from "./tool-artifacts";
32+ loadSourceById,
33+ loadSourcesInWorkspace,
34+ removeCredentialBindingForSource,
35+} from "./source-store";
3636
3737 const sourceOps = {
3838 list: operationErrors("sources.list"),
@@ -50,9 +50,8 @@ const syncArtifactsForSource = (input: { · 11 unmodified lines
5050 | typeof sourceOps.update;
5151 }) =>
5252 Effect.gen(function* () {
53− const resolveSecretMaterial = createDbBackedSecretMaterialResolver({
53+ const resolveSecretMaterial = createDefaultSecretMaterialResolver({
5454 rows: input.store,
55− fallback: createEnvSecretMaterialResolver(),
5655 });
5756
5857 const synced = yield* Effect.either(
@@ -106,26 +105,14 @@ const syncArtifactsForSource = (input: { · 47 unmodified lines
106105
107106 export const listSources = (workspaceId: WorkspaceId) =>
108107 Effect.flatMap(ControlPlaneStore, (store) =>
109− Effect.gen(function* () {
110− const sourceRecords = yield* sourceOps.list.child("records").mapStorage(
111− store.sources.listByWorkspaceId(workspaceId),
112− );
113− const credentialBindings = yield* sourceOps.list.child("bindings").mapStorage(
114− store.sourceCredentialBindings.listByWorkspaceId(workspaceId),
115− );
116−
117− return yield* projectSourcesFromStorage({
118− sourceRecords,
119− credentialBindings,
120− }).pipe(
121− Effect.mapError((error) =>
122− sourceOps.list.unknownStorage(
123− error,
124− "Failed projecting stored sources",
125− ),
108+ loadSourcesInWorkspace(store, workspaceId).pipe(
109+ Effect.mapError((error) =>
110+ sourceOps.list.unknownStorage(
111+ error,
112+ "Failed projecting stored sources",
126113 ),
127− );
128− }));
114+ ),
115+ ));
129116
130117 export const createSource = (input: {
131118 workspaceId: WorkspaceId;
@@ -149,7 +136,7 @@ export const createSource = (input: { · 17 unmodified lines
149136 ),
150137 );
151138
152− const { sourceRecord, credentialBinding } = splitSourceForStorage({
139+ const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
153140 source,
154141 });
155142
@@ -157,7 +144,11 @@ export const createSource = (input: { · 1 unmodified line
157144 sourceOps.create.child("source"),
158145 store.sources.insert(sourceRecord),
159146 );
160− if (credentialBinding !== null) {
147+ if (credential !== null && credentialBinding !== null) {
148+ yield* mapPersistenceError(
149+ sourceOps.create.child("credential"),
150+ store.credentials.upsert(credential),
151+ );
161152 yield* mapPersistenceError(
162153 sourceOps.create.child("binding"),
163154 store.sourceCredentialBindings.upsert(credentialBinding),
@@ -176,39 +167,22 @@ export const getSource = (input: { · 12 unmodified lines
176167 sourceId: SourceId;
177168 }) =>
178169 Effect.flatMap(ControlPlaneStore, (store) =>
179− Effect.gen(function* () {
180− const existing = yield* sourceOps.get.mapStorage(
181− store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
182− );
183−
184− if (Option.isNone(existing)) {
185− return yield* Effect.fail(
186− sourceOps.get.notFound(
187− "Source not found",
188− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
189− ),
190− );
191− }
192−
193− const credentialBinding = yield* sourceOps.get.child("binding").mapStorage(
194− store.sourceCredentialBindings.getByWorkspaceAndSourceId(
195− input.workspaceId,
196− input.sourceId,
197− ),
198− );
199−
200− return yield* projectSourceFromStorage({
201− sourceRecord: existing.value,
202− credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
203− }).pipe(
204− Effect.mapError((cause) =>
205− sourceOps.get.unknownStorage(
206− cause,
207− "Failed projecting stored source",
208− ),
209− ),
210− );
211− }));
170+ loadSourceById(store, {
171+ workspaceId: input.workspaceId,
172+ sourceId: input.sourceId,
173+ }).pipe(
174+ Effect.mapError((cause) =>
175+ cause instanceof Error && cause.message.startsWith("Source not found:")
176+ ? sourceOps.get.notFound(
177+ "Source not found",
178+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
179+ )
180+ : sourceOps.get.unknownStorage(
181+ cause,
182+ "Failed projecting stored source",
183+ ),
184+ ),
185+ ));
212186
213187 export const updateSource = (input: {
214188 workspaceId: WorkspaceId;
@@ -237,15 +211,20 @@ export const updateSource = (input: { · 22 unmodified lines
237211 ),
238212 );
239213
240− const existingSource = yield* projectSourceFromStorage({
241− sourceRecord: existing.value,
242− credentialBinding: Option.isSome(existingBinding) ? existingBinding.value : null,
214+ const existingSource = yield* loadSourceById(store, {
215+ workspaceId: input.workspaceId,
216+ sourceId: input.sourceId,
243217 }).pipe(
244218 Effect.mapError((cause) =>
245− sourceOps.update.unknownStorage(
246− cause,
247− "Failed projecting stored source",
248− ),
219+ cause instanceof Error && cause.message.startsWith("Source not found:")
220+ ? sourceOps.update.notFound(
221+ "Source not found",
222+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
223+ )
224+ : sourceOps.update.unknownStorage(
225+ cause,
226+ "Failed projecting stored source",
227+ ),
249228 ),
250229 );
251230
@@ -262,8 +241,14 @@ export const updateSource = (input: { · 10 unmodified lines
262241 ),
263242 );
264243
265− const { sourceRecord, credentialBinding } = splitSourceForStorage({
244+ const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
266245 source: updatedSource,
246+ existingCredentialId: Option.isSome(existingBinding)
247+ ? existingBinding.value.credentialId
248+ : null,
249+ existingBindingId: Option.isSome(existingBinding)
250+ ? existingBinding.value.id
251+ : null,
267252 });
268253 const { sourceDocumentText: _sourceDocumentText, ...sourcePatch } = sourceRecord;
269254
@@ -284,14 +269,23 @@ export const updateSource = (input: { · 14 unmodified lines
284269 );
285270 }
286271
287− if (credentialBinding === null) {
288− yield* sourceOps.update.child("binding.remove").mapStorage(
289− store.sourceCredentialBindings.removeByWorkspaceAndSourceId(
290− input.workspaceId,
291− input.sourceId,
292− ),
293− );
272+ if (credential === null || credentialBinding === null) {
273+ if (Option.isSome(existingBinding)) {
274+ yield* sourceOps.update.child("binding.remove").mapStorage(
275+ store.sourceCredentialBindings.removeByWorkspaceAndSourceId(
276+ input.workspaceId,
277+ input.sourceId,
278+ ),
279+ );
280+ yield* sourceOps.update.child("credential.remove").mapStorage(
281+ store.credentials.removeById(existingBinding.value.credentialId),
282+ );
283+ }
294284 } else {
285+ yield* mapPersistenceError(
286+ sourceOps.update.child("credential"),
287+ store.credentials.upsert(credential),
288+ );
295289 yield* mapPersistenceError(
296290 sourceOps.update.child("binding"),
297291 store.sourceCredentialBindings.upsert(credentialBinding),
@@ -311,6 +305,17 @@ export const removeSource = (input: { · 13 unmodified lines
311305 }) =>
312306 Effect.flatMap(ControlPlaneStore, (store) =>
313307 Effect.gen(function* () {
308+ yield* sourceOps.remove.child("credential.remove").mapStorage(
309+ removeCredentialBindingForSource(store, {
310+ workspaceId: input.workspaceId,
311+ sourceId: input.sourceId,
312+ }),
313+ );
314+
315+ yield* sourceOps.remove.child("artifacts").mapStorage(
316+ store.toolArtifacts.removeByWorkspaceAndSourceId(input.workspaceId, input.sourceId),
317+ );
318+
314319 yield* sourceOps.remove.child("artifacts").mapStorage(
315320 store.toolArtifacts.removeByWorkspaceAndSourceId(input.workspaceId, input.sourceId),
316321 );
packages/control-plane/src/runtime/tool-artifacts.ts+9−23
@@ -21,9 +21,10 @@ import { · 20 unmodified lines
2121 import * as Effect from "effect/Effect";
2222 import * as Option from "effect/Option";
2323
24−export type ResolveSourceSecretMaterial = (
25− ref: SecretRef,
26−) => Effect.Effect<string, Error, never>;
24+import type {
25+ ResolveSecretMaterial as ResolveSourceSecretMaterial,
26+ SecretMaterialResolveContext,
27+} from "./secret-material-providers";
2728
2829 export type ResolvedSourceAuthMaterial = {
2930 headers: Readonly<Record<string, string>>;
@@ -122,28 +123,10 @@ export const namespaceFromSourceName = (name: string): string => { · 92 unmodified lines
122123 return normalized.length > 0 ? normalized : "source";
123124 };
124125
125−export const createEnvSecretMaterialResolver = (): ResolveSourceSecretMaterial =>
126− (ref) =>
127− Effect.gen(function* () {
128− if (ref.providerId !== "env") {
129− return yield* Effect.fail(
130− new Error(`Unsupported secret provider ${ref.providerId}`),
131− );
132− }
133−
134− const value = process.env[ref.handle]?.trim();
135− if (!value) {
136− return yield* Effect.fail(
137− new Error(`Environment variable ${ref.handle} is not set`),
138− );
139− }
140−
141− return value;
142− });
143−
144126 export const resolveSourceAuthMaterial = (input: {
145127 source: Source;
146128 resolveSecretMaterial: ResolveSourceSecretMaterial;
129+ context?: SecretMaterialResolveContext;
147130 }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>
148131 Effect.gen(function* () {
149132 if (input.source.auth.kind === "none") {
@@ -155,7 +138,10 @@ export const resolveSourceAuthMaterial = (input: { · 5 unmodified lines
155138 ? input.source.auth.token
156139 : input.source.auth.accessToken;
157140
158− const token = yield* input.resolveSecretMaterial(tokenRef);
141+ const token = yield* input.resolveSecretMaterial({
142+ ref: tokenRef,
143+ context: input.context,
144+ });
159145
160146 return {
161147 headers: {
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+113−9
@@ -15,6 +15,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ · 14 unmodified lines
1515 import { describe, expect, it } from "@effect/vitest";
1616 import {
1717 AccountIdSchema,
18+ CredentialIdSchema,
1819 ExecutionIdSchema,
1920 SecretMaterialIdSchema,
2021 SourceIdSchema,
@@ -369,9 +370,6 @@ const persistConnectedEchoTool = (input: { · 348 unmodified lines
369370 headersJson: null,
370371 specUrl: null,
371372 defaultHeadersJson: null,
372− authKind: "none",
373− authHeaderName: null,
374− authPrefix: null,
375373 sourceHash: null,
376374 sourceDocumentText: null,
377375 lastError: null,
@@ -423,6 +421,11 @@ const persistConnectedGithubOpenApiSource = (input: { · 45 unmodified lines
423421 persistence: SqlControlPlanePersistence;
424422 workspaceId: WorkspaceId;
425423 sourceId: SourceId;
424+ endpoint?: string;
425+ auth?: {
426+ providerId: string;
427+ handle: string;
428+ } | null;
426429 }) =>
427430 Effect.gen(function* () {
428431 const now = Date.now();
@@ -481,6 +484,28 @@ const persistConnectedGithubOpenApiSource = (input: { · 52 unmodified lines
481484 },
482485 },
483486 },
487+ "/repos/{owner}/{repo}": {
488+ get: {
489+ operationId: "repos/get-repo",
490+ tags: ["repos"],
491+ summary: "Get a repository",
492+ description: "Gets a repository by owner and name.",
493+ parameters: [
494+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
495+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
496+ ],
497+ responses: {
498+ 200: {
499+ description: "ok",
500+ content: {
501+ "application/json": {
502+ schema: { type: "object" },
503+ },
504+ },
505+ },
506+ },
507+ },
508+ },
484509 "/user": {
485510 get: {
486511 operationId: "users/get-authenticated",
@@ -508,7 +533,7 @@ const persistConnectedGithubOpenApiSource = (input: { · 21 unmodified lines
508533 workspaceId: input.workspaceId,
509534 name: "GitHub",
510535 kind: "openapi",
511− endpoint: "https://api.github.com",
536+ endpoint: input.endpoint ?? "https://api.github.com",
512537 status: "connected",
513538 enabled: true,
514539 namespace: "github",
@@ -517,15 +542,38 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines
517542 headersJson: null,
518543 specUrl: "https://example.com/github-openapi.json",
519544 defaultHeadersJson: null,
520− authKind: "none",
521− authHeaderName: null,
522− authPrefix: null,
523545 sourceHash: null,
524546 sourceDocumentText: openApiDocumentText,
525547 lastError: null,
526548 createdAt: now,
527549 updatedAt: now,
528550 });
551+
552+ if (input.auth) {
553+ const credentialId = CredentialIdSchema.make(`cred_${randomUUID()}`);
554+ yield* input.persistence.rows.credentials.upsert({
555+ id: credentialId,
556+ workspaceId: input.workspaceId,
557+ authKind: "bearer",
558+ authHeaderName: "Authorization",
559+ authPrefix: "Bearer ",
560+ tokenProviderId: input.auth.providerId,
561+ tokenHandle: input.auth.handle,
562+ refreshTokenProviderId: null,
563+ refreshTokenHandle: null,
564+ createdAt: now,
565+ updatedAt: now,
566+ });
567+ yield* input.persistence.rows.sourceCredentialBindings.upsert({
568+ id: `src_cred_bind_${randomUUID()}`,
569+ workspaceId: input.workspaceId,
570+ sourceId: input.sourceId,
571+ credentialId,
572+ createdAt: now,
573+ updatedAt: now,
574+ });
575+ };
576+
529577 });
530578
531579 const makeResolver = (persistence: SqlControlPlanePersistence) =>
@@ -719,7 +767,10 @@ describe("workspace-execution-environment", () => { · 187 unmodified lines
719767 action: "accept" as const,
720768 content: {
721769 authKind: "bearer",
722− tokenSecretMaterialId,
770+ tokenRef: {
771+ providerId: "postgres",
772+ handle: tokenSecretMaterialId,
773+ },
723774 },
724775 };
725776 }),
@@ -752,7 +803,7 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines
752803 expect(added.kind).toBe("openapi");
753804 expect(added.status).toBe("connected");
754805 expect(added.auth.kind).toBe("bearer");
755− expect(added.auth.token?.providerId).toBe("control-plane");
806+ expect(added.auth.token?.providerId).toBe("postgres");
756807 expect(capturedElicitation).toMatchObject({
757808 mode: "url",
758809 url: expect.stringContaining("/v1/workspaces/"),
@@ -812,6 +863,59 @@ describe("workspace-execution-environment", () => { · 53 unmodified lines
812863 }),
813864 );
814865
866+ it.scoped("resolves params-backed source auth from invocation context", () =>
867+ Effect.gen(function* () {
868+ const specServer = yield* makeOpenApiSpecServer;
869+ const persistence = yield* makePersistence;
870+ const workspaceId = WorkspaceIdSchema.make("ws_params_auth");
871+ const sourceId = SourceIdSchema.make("src_params_auth");
872+
873+ yield* persistConnectedGithubOpenApiSource({
874+ persistence,
875+ workspaceId,
876+ sourceId,
877+ endpoint: specServer.baseUrl,
878+ auth: {
879+ providerId: "params",
880+ handle: "githubToken",
881+ },
882+ });
883+
884+ const resolveEnvironment = makeResolver(persistence);
885+ const environment = yield* resolveEnvironment({
886+ workspaceId,
887+ accountId: AccountIdSchema.make("acc_params_auth"),
888+ executionId: ExecutionIdSchema.make("exec_params_auth"),
889+ });
890+
891+ const invoked = (yield* environment.toolInvoker.invoke({
892+ path: "github.repos.getRepo",
893+ args: {
894+ owner: "vercel",
895+ repo: "ai",
896+ },
897+ context: {
898+ params: {
899+ githubToken: "ghp_from_params",
900+ },
901+ },
902+ })) as {
903+ status: number;
904+ body: {
905+ ok: boolean;
906+ full_name: string;
907+ };
908+ };
909+
910+ expect(invoked.status).toBe(200);
911+ expect(invoked.body).toEqual({
912+ ok: true,
913+ full_name: "vercel/ai",
914+ });
915+ expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_from_params"]);
916+ }),
917+ );
918+
815919 it.scoped("describes executor.sources.add with derived type info and schemas", () =>
816920 Effect.gen(function* () {
817921 const persistence = yield* makePersistence;
packages/control-plane/src/runtime/workspace-execution-environment.ts+84−77
@@ -33,24 +33,31 @@ import type { · 32 unmodified lines
3333 StoredToolArtifactRecord,
3434 } from "#schema";
3535 import * as Context from "effect/Context";
36+import * as Either from "effect/Either";
3637 import * as Effect from "effect/Effect";
3738 import * as Layer from "effect/Layer";
3839 import * as Option from "effect/Option";
40+import * as Schema from "effect/Schema";
3941
4042 import type {
4143 ExecutionEnvironment,
4244 ResolveExecutionEnvironment,
4345 } from "./execution-state";
4446 import { createExecutorToolMap } from "./executor-tools";
45−import { projectSourceFromStorage, projectSourcesFromStorage } from "./source-definitions";
47+import {
48+ loadSourceById as loadStoredSourceById,
49+ loadSourcesInWorkspace,
50+} from "./source-store";
4651 import {
4752 RuntimeSourceAuthServiceTag,
48− createDbBackedSecretMaterialResolver,
49− type ResolveSecretMaterial,
5053 type RuntimeSourceAuthService,
5154 } from "./source-auth-service";
5255 import {
53− createEnvSecretMaterialResolver,
56+ createDefaultSecretMaterialResolver,
57+ type ResolveSecretMaterial,
58+ type SecretMaterialResolveContext,
59+} from "./secret-material-providers";
60+import {
5461 namespaceFromSourceName,
5562 resolveSourceAuthMaterial,
5663 storedToolIdFromArtifact,
@@ -109,6 +116,32 @@ const hasSubstringMatch = (value: string, queryToken: string): boolean => { · 52 unmodified lines
109116 return singular !== queryToken && value.includes(singular);
110117 };
111118
119+const SecretResolutionContextEnvelopeSchema = Schema.Struct({
120+ params: Schema.optional(
121+ Schema.Record({
122+ key: Schema.String,
123+ value: Schema.String,
124+ }),
125+ ),
126+});
127+
128+const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither(
129+ SecretResolutionContextEnvelopeSchema,
130+);
131+
132+const toSecretResolutionContext = (
133+ value: unknown,
134+): SecretMaterialResolveContext | undefined => {
135+ const decoded = decodeSecretResolutionContextEnvelope(value);
136+ if (Either.isLeft(decoded) || decoded.right.params === undefined) {
137+ return undefined;
138+ }
139+
140+ return {
141+ params: decoded.right.params,
142+ };
143+};
144+
112145 const queryTokenWeight = (token: string): number =>
113146 LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1;
114147
@@ -236,14 +269,14 @@ const toPersistedDescriptor = (input: { · 121 unmodified lines
236269 outputType:
237270 input.artifact.providerKind === "openapi"
238271 ? openApiOutputTypeSignatureFromSchemaJson(
239− input.artifact.outputSchemaJson ?? undefined,
240− 320,
241− )
272+ input.artifact.outputSchemaJson ?? undefined,
273+ 320,
274+ )
242275 : typeSignatureFromSchemaJson(
243− input.artifact.outputSchemaJson ?? undefined,
244− "unknown",
245− 320,
246− ),
276+ input.artifact.outputSchemaJson ?? undefined,
277+ "unknown",
278+ 320,
279+ ),
247280 inputSchemaJson: input.includeSchemas ? input.artifact.inputSchemaJson ?? undefined : undefined,
248281 outputSchemaJson: input.includeSchemas ? input.artifact.outputSchemaJson ?? undefined : undefined,
249282 ...(input.artifact.providerKind ? { providerKind: input.artifact.providerKind } : {}),
@@ -265,22 +298,15 @@ const loadOpenApiWorkspaceTools = (input: { · 15 unmodified lines
265298 includeSchemas: boolean;
266299 }): Effect.Effect<ReadonlyArray<OpenApiWorkspaceTool>, Error, never> =>
267300 Effect.gen(function* () {
268− const [sourceRecords, credentialBindings] = yield* Effect.all([
269− input.rows.sources.listByWorkspaceId(input.workspaceId),
270− input.rows.sourceCredentialBindings.listByWorkspaceId(input.workspaceId),
271− ]).pipe(
301+ const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId).pipe(
272302 Effect.mapError((cause) =>
273303 cause instanceof Error ? cause : new Error(String(cause)),
274304 ),
275305 );
276−
277306 const documentBySourceId = new Map(
278307 sourceRecords.map((record) => [record.id, record.sourceDocumentText]),
279308 );
280− const sources = yield* projectSourcesFromStorage({
281− sourceRecords,
282− credentialBindings,
283− });
309+ const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId);
284310
285311 const connectedOpenApiSources = sources.filter((source) =>
286312 source.enabled
@@ -373,35 +399,14 @@ const loadSourceById = (input: { · 86 unmodified lines
373399 workspaceId: Source["workspaceId"];
374400 sourceId: Source["id"];
375401 }): Effect.Effect<Source, Error, never> =>
376− Effect.gen(function* () {
377− const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
378− input.workspaceId,
379− input.sourceId,
380− ).pipe(
381− Effect.mapError((cause) =>
382− cause instanceof Error ? cause : new Error(String(cause)),
383− ),
384− );
385−
386− if (Option.isNone(sourceRecord)) {
387− return yield* Effect.fail(
388− new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
389− );
390− }
391−
392− const credentialBinding = yield* input.rows.sourceCredentialBindings
393− .getByWorkspaceAndSourceId(input.workspaceId, input.sourceId)
394− .pipe(
395− Effect.mapError((cause) =>
396− cause instanceof Error ? cause : new Error(String(cause)),
397− ),
398− );
399−
400− return yield* projectSourceFromStorage({
401− sourceRecord: sourceRecord.value,
402− credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
403− });
404− });
402+ loadStoredSourceById(input.rows, {
403+ workspaceId: input.workspaceId,
404+ sourceId: input.sourceId,
405+ }).pipe(
406+ Effect.mapError((cause) =>
407+ cause instanceof Error ? cause : new Error(String(cause)),
408+ ),
409+ );
405410
406411 const createWorkspaceToolCatalog = (input: {
407412 workspaceId: Source["workspaceId"];
@@ -460,14 +465,14 @@ const createWorkspaceToolCatalog = (input: { · 52 unmodified lines
460465 namespace?.startsWith("executor")
461466 ? Effect.succeed([] as readonly StoredToolArtifactRecord[])
462467 : input.rows.toolArtifacts.listByWorkspaceId(input.workspaceId, {
463− namespace,
464− query,
465− limit,
466− }).pipe(
467− Effect.mapError((cause) =>
468− cause instanceof Error ? cause : new Error(String(cause)),
469− ),
468+ namespace,
469+ query,
470+ limit,
471+ }).pipe(
472+ Effect.mapError((cause) =>
473+ cause instanceof Error ? cause : new Error(String(cause)),
470474 ),
475+ ),
471476 loadOpenApiWorkspaceTools({
472477 rows: input.rows,
473478 workspaceId: input.workspaceId,
@@ -555,13 +560,13 @@ const createWorkspaceToolCatalog = (input: { · 81 unmodified lines
555560 namespace?.startsWith("executor")
556561 ? Effect.succeed([] as readonly StoredToolArtifactRecord[])
557562 : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, {
558− namespace,
559− query,
560− }).pipe(
561− Effect.mapError((cause) =>
562− cause instanceof Error ? cause : new Error(String(cause)),
563− ),
563+ namespace,
564+ query,
565+ }).pipe(
566+ Effect.mapError((cause) =>
567+ cause instanceof Error ? cause : new Error(String(cause)),
564568 ),
569+ ),
565570 loadOpenApiWorkspaceTools({
566571 rows: input.rows,
567572 workspaceId: input.workspaceId,
@@ -653,6 +658,7 @@ const createWorkspaceToolInvoker = (input: { · 85 unmodified lines
653658 const auth = yield* resolveSourceAuthMaterial({
654659 source: openApiTool.source,
655660 resolveSecretMaterial: input.resolveSecretMaterial,
661+ context: toSecretResolutionContext(invocation.context),
656662 });
657663
658664 const tools = createOpenApiToolsFromManifest({
@@ -702,6 +708,7 @@ const createWorkspaceToolInvoker = (input: { · 43 unmodified lines
702708 const auth = yield* resolveSourceAuthMaterial({
703709 source,
704710 resolveSecretMaterial: input.resolveSecretMaterial,
711+ context: toSecretResolutionContext(invocation.context),
705712 });
706713
707714 if (artifact.providerKind === "mcp") {
@@ -764,9 +771,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 56 unmodified lines
764771 }): ResolveExecutionEnvironment => {
765772 const resolveSecretMaterial =
766773 input.resolveSecretMaterial
767− ?? createDbBackedSecretMaterialResolver({
774+ ?? createDefaultSecretMaterialResolver({
768775 rows: input.rows,
769− fallback: createEnvSecretMaterialResolver(),
770776 });
771777
772778 return ({ workspaceId, onElicitation }) =>
@@ -792,7 +798,8 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 19 unmodified lines
792798 )<
793799 RuntimeExecutionResolverService,
794800 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
795−>() {}
801+>() {
802+}
796803
797804 export const RuntimeExecutionResolverLive = (input: {
798805 executionResolver?: ResolveExecutionEnvironment;
@@ -801,15 +808,15 @@ export const RuntimeExecutionResolverLive = (input: { · 2 unmodified lines
801808 input.executionResolver
802809 ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver)
803810 : Layer.effect(
804− RuntimeExecutionResolverService,
805− Effect.gen(function* () {
806− const rows = yield* SqlControlPlaneRowsService;
807− const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
808−
809− return createWorkspaceExecutionEnvironmentResolver({
810− rows,
811− sourceAuthService,
812− resolveSecretMaterial: input.resolveSecretMaterial,
813− });
814− }),
815− );
811+ RuntimeExecutionResolverService,
812+ Effect.gen(function* () {
813+ const rows = yield* SqlControlPlaneRowsService;
814+ const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
815+
816+ return createWorkspaceExecutionEnvironmentResolver({
817+ rows,
818+ sourceAuthService,
819+ resolveSecretMaterial: input.resolveSecretMaterial,
820+ });
821+ }),
822+ );
packages/control-plane/src/schema/enums.ts+4−0
@@ -20,6 +20,10 @@ export { · 19 unmodified lines
2020 type SourceStatus,
2121 type SourceTransport,
2222 } from "./models/source";
23+export {
24+ CredentialAuthKindSchema,
25+ type CredentialAuthKind,
26+} from "./models/credential";
2327 export {
2428 PolicyApprovalModeSchema,
2529 PolicyEffectSchema,
packages/control-plane/src/schema/ids.ts+4−0
@@ -10,6 +10,9 @@ export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); · 9 unmodified lines
1010 export const SourceAuthSessionIdSchema = Schema.String.pipe(
1111 Schema.brand("SourceAuthSessionId"),
1212 );
13+export const CredentialIdSchema = Schema.String.pipe(
14+ Schema.brand("CredentialId"),
15+);
1316 export const SecretMaterialIdSchema = Schema.String.pipe(
1417 Schema.brand("SecretMaterialId"),
1518 );
@@ -26,6 +29,7 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 10 unmodified lines
2629 export type WorkspaceId = typeof WorkspaceIdSchema.Type;
2730 export type SourceId = typeof SourceIdSchema.Type;
2831 export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
32+export type CredentialId = typeof CredentialIdSchema.Type;
2933 export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
3034 export type PolicyId = typeof PolicyIdSchema.Type;
3135 export type InstallationId = typeof InstallationIdSchema.Type;
packages/control-plane/src/schema/index.ts+1−0
@@ -5,6 +5,7 @@ export * from "./ids"; · 4 unmodified lines
55 export * from "./models/auth";
66 export * from "./models/workspace";
77 export * from "./models/source";
8+export * from "./models/credential";
89 export * from "./models/tool-artifact";
910 export * from "./models/secret-material";
1011 export * from "./models/source-auth-session";
packages/control-plane/src/schema/models/credential.ts+52−0
@@ -0,0 +1,52 @@
1+import { createSelectSchema } from "drizzle-orm/effect-schema";
2+import { Schema } from "effect";
3+
4+import {
5+ credentialsTable,
6+ sourceCredentialBindingsTable,
7+} from "../../persistence/schema";
8+import { TimestampMsSchema } from "../common";
9+import {
10+ CredentialIdSchema,
11+ SourceIdSchema,
12+ WorkspaceIdSchema,
13+} from "../ids";
14+
15+export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2");
16+
17+const credentialSchemaOverrides = {
18+ id: CredentialIdSchema,
19+ workspaceId: WorkspaceIdSchema,
20+ authKind: CredentialAuthKindSchema,
21+ authHeaderName: Schema.String,
22+ authPrefix: Schema.String,
23+ tokenProviderId: Schema.String,
24+ tokenHandle: Schema.String,
25+ refreshTokenProviderId: Schema.NullOr(Schema.String),
26+ refreshTokenHandle: Schema.NullOr(Schema.String),
27+ createdAt: TimestampMsSchema,
28+ updatedAt: TimestampMsSchema,
29+} as const;
30+
31+const sourceCredentialBindingSchemaOverrides = {
32+ id: Schema.String,
33+ workspaceId: WorkspaceIdSchema,
34+ sourceId: SourceIdSchema,
35+ credentialId: CredentialIdSchema,
36+ createdAt: TimestampMsSchema,
37+ updatedAt: TimestampMsSchema,
38+} as const;
39+
40+export const CredentialSchema = createSelectSchema(
41+ credentialsTable,
42+ credentialSchemaOverrides,
43+);
44+
45+export const SourceCredentialBindingSchema = createSelectSchema(
46+ sourceCredentialBindingsTable,
47+ sourceCredentialBindingSchemaOverrides,
48+);
49+
50+export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
51+export type Credential = typeof CredentialSchema.Type;
52+export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type;
packages/control-plane/src/schema/models/source.ts+1−33
@@ -3,10 +3,7 @@ import { · 2 unmodified lines
33 } from "drizzle-orm/effect-schema";
44 import { Schema } from "effect";
55
6−import {
7− sourceCredentialBindingsTable,
8− sourcesTable,
9−} from "../../persistence/schema";
6+import { sourcesTable } from "../../persistence/schema";
107 import { TimestampMsSchema } from "../common";
118 import { SourceIdSchema, WorkspaceIdSchema } from "../ids";
129
@@ -66,9 +63,6 @@ const sourceRowSchemaOverrides = { · 53 unmodified lines
6663 kind: SourceKindSchema,
6764 status: SourceStatusSchema,
6865 transport: Schema.NullOr(SourceTransportSchema),
69− authKind: Schema.Literal("none", "bearer", "oauth2"),
70− authHeaderName: Schema.NullOr(Schema.String),
71− authPrefix: Schema.NullOr(Schema.String),
7266 createdAt: TimestampMsSchema,
7367 updatedAt: TimestampMsSchema,
7468 } as const;
@@ -91,9 +85,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 16 unmodified lines
9185 headersJson: Schema.NullOr(Schema.String),
9286 specUrl: Schema.NullOr(Schema.String),
9387 defaultHeadersJson: Schema.NullOr(Schema.String),
94− authKind: Schema.Literal("none", "bearer", "oauth2"),
95− authHeaderName: Schema.NullOr(Schema.String),
96− authPrefix: Schema.NullOr(Schema.String),
9788 sourceHash: Schema.NullOr(Schema.String),
9889 sourceDocumentText: Schema.NullOr(Schema.String),
9990 lastError: Schema.NullOr(Schema.String),
@@ -116,9 +107,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 16 unmodified lines
116107 headersJson: row.headersJson,
117108 specUrl: row.specUrl,
118109 defaultHeadersJson: row.defaultHeadersJson,
119− authKind: row.authKind,
120− authHeaderName: row.authHeaderName,
121− authPrefix: row.authPrefix,
122110 sourceHash: row.sourceHash,
123111 sourceDocumentText: row.sourceDocumentText,
124112 lastError: row.lastError,
@@ -139,9 +127,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 14 unmodified lines
139127 headersJson: source.headersJson,
140128 specUrl: source.specUrl,
141129 defaultHeadersJson: source.defaultHeadersJson,
142− authKind: source.authKind,
143− authHeaderName: source.authHeaderName,
144− authPrefix: source.authPrefix,
145130 sourceHash: source.sourceHash,
146131 sourceDocumentText: source.sourceDocumentText,
147132 lastError: source.lastError,
@@ -151,22 +136,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 3 unmodified lines
151136 },
152137 );
153138
154−const sourceCredentialBindingSchemaOverrides = {
155− workspaceId: WorkspaceIdSchema,
156− sourceId: SourceIdSchema,
157− tokenProviderId: Schema.NullOr(Schema.String),
158− tokenHandle: Schema.NullOr(Schema.String),
159− refreshTokenProviderId: Schema.NullOr(Schema.String),
160− refreshTokenHandle: Schema.NullOr(Schema.String),
161− createdAt: TimestampMsSchema,
162− updatedAt: TimestampMsSchema,
163−} as const;
164−
165−export const SourceCredentialBindingSchema = createSelectSchema(
166− sourceCredentialBindingsTable,
167− sourceCredentialBindingSchemaOverrides,
168−);
169−
170139 export const SourceSchema = Schema.Struct({
171140 id: SourceIdSchema,
172141 workspaceId: WorkspaceIdSchema,
@@ -194,6 +163,5 @@ export type SourceTransport = typeof SourceTransportSchema.Type; · 21 unmodified lines
194163 export type SecretRef = typeof SecretRefSchema.Type;
195164 export type SourceAuth = typeof SourceAuthSchema.Type;
196165 export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;
197−export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type;
198166 export type StringMap = typeof StringMapSchema.Type;
199167 export type Source = typeof SourceSchema.Type;
personal-notes.md+5−0
@@ -0,0 +1,5 @@
1+# Personal Notes
2+
3+- Credential system direction: keep `SecretRef { providerId, handle }` as the stable runtime abstraction, but move durable auth ownership to first-class `credentials` records instead of source-centric bindings.
4+- Local-first is fine with `postgres`, `keychain`, dangerous `env`, and `params`, but future cloud/team support needs scoped credentials, policy around use/manage/reveal, and source-to-credential binding by `credentialId`.
5+- Avoid leaking durable auth state into generic interaction history or source-specific public APIs; keep the current provider model, but treat current source-level binding as transitional.