From 16f35c878ae82a8655326ce7fd76119f4a77ef3a Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 18 Jul 2026 08:50:18 +0000 Subject: [PATCH] Add configurable default credential provider for durable OAuth secrets --- .changeset/default-credential-provider.md | 5 ++ apps/local/src/executor.ts | 11 ++++ packages/core/sdk/src/connections.test.ts | 69 +++++++++++++++++++++++ packages/core/sdk/src/executor.ts | 15 +++++ packages/core/sdk/src/test-config.ts | 6 ++ 5 files changed, 106 insertions(+) create mode 100644 .changeset/default-credential-provider.md diff --git a/.changeset/default-credential-provider.md b/.changeset/default-credential-provider.md new file mode 100644 index 000000000..453e49ac8 --- /dev/null +++ b/.changeset/default-credential-provider.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Add an optional `defaultCredentialProvider` to `ExecutorConfig`. When set to a registered writable provider's key, that provider becomes the default writable store for OAuth tokens and pasted secrets, ahead of registration order; it falls back to registration order when unset or when the named provider is absent or read-only. The local app reads `EXECUTOR_DEFAULT_SECRET_PROVIDER` to populate it. This lets a host on ephemeral infrastructure force a durable on-disk store (for example `file`, backed by `EXECUTOR_DATA_DIR`) instead of an in-memory system keychain (kernel keyutils on Linux), whose OAuth tokens do not survive a fresh machine. diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 5fa7c9a0f..b5993d97f 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -5,6 +5,7 @@ import { basename, join } from "node:path"; import { createHash } from "node:crypto"; import { + ProviderKey, Subject, Tenant, createExecutor, @@ -186,12 +187,22 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { const webBaseUrl = process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`; + // A host on ephemeral infrastructure (e.g. a disposable cloud sandbox) + // can force the default writable secret store to a durable on-disk + // provider, so OAuth tokens survive a fresh machine. The system keychain + // (kernel keyutils on Linux) is in-memory and does not. Unset keeps the + // registration-order default (system keychain when reachable). + const defaultSecretProvider = process.env.EXECUTOR_DEFAULT_SECRET_PROVIDER?.trim(); + const executor = yield* createExecutor({ tenant: Tenant.make(tenantId), subject: Subject.make(LOCAL_SUBJECT), db: sqlite.db, plugins, onElicitation: "accept-all", + ...(defaultSecretProvider + ? { defaultCredentialProvider: ProviderKey.make(defaultSecretProvider) } + : {}), oauthEndpointUrlPolicy: { allowHttp: true }, // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` // route on the same origin as the web UI. Derived from `webBaseUrl` diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 7ed046f65..f0869116c 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -718,3 +718,72 @@ describe("execute over a connection", () => { }), ); }); + +describe("defaultCredentialProvider preference", () => { + const namedMemoryProvider = (key: string): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make(key), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((k) => ({ id: ProviderItemId.make(k), name: k })), + ), + }; + }; + + // Two writable providers; "first" registers ahead of "durable" in order. + const twoProviderPlugin = definePlugin(() => ({ + id: "two-providers" as const, + credentialProviders: [namedMemoryProvider("first"), namedMemoryProvider("durable")], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("noop"), description: "noop" }] }), + invokeTool: () => Effect.succeed({}), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + + it.effect("selects the preferred writable store ahead of registration order", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [twoProviderPlugin] as const, + defaultCredentialProvider: ProviderKey.make("durable"), + }).pipe(Effect.tap((e) => e["two-providers"].seed())); + + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + + expect(connection.provider).toBe(ProviderKey.make("durable")); + }), + ); + + it.effect("falls back to registration order when the preferred key is not registered", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [twoProviderPlugin] as const, + defaultCredentialProvider: ProviderKey.make("nonexistent"), + }).pipe(Effect.tap((e) => e["two-providers"].seed())); + + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + + expect(connection.provider).toBe(ProviderKey.make("first")); + }), + ); +}); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 42dee8cd1..1648fd24a 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -406,6 +406,14 @@ export interface ExecutorConfig { + if (preferredProviderKey !== null) { + const preferred = credentialProviders.get(preferredProviderKey); + if (preferred?.writable) return preferred; + } for (const key of credentialProviderOrder) { const provider = credentialProviders.get(key); if (provider?.writable) return provider; diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index bce9392b4..5999d3c6d 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -122,6 +122,9 @@ export type TestConfigOptions["defaultCredentialProvider"]; }; export const makeTestConfig = ( @@ -160,6 +163,9 @@ export const makeTestConfig =