Skip to content

Commit 16f35c8

Browse files
committed
Add configurable default credential provider for durable OAuth secrets
1 parent 2226e72 commit 16f35c8

5 files changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/sdk": patch
3+
---
4+
5+
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.

apps/local/src/executor.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { basename, join } from "node:path";
55
import { createHash } from "node:crypto";
66

77
import {
8+
ProviderKey,
89
Subject,
910
Tenant,
1011
createExecutor,
@@ -186,12 +187,22 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
186187
const webBaseUrl =
187188
process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`;
188189

190+
// A host on ephemeral infrastructure (e.g. a disposable cloud sandbox)
191+
// can force the default writable secret store to a durable on-disk
192+
// provider, so OAuth tokens survive a fresh machine. The system keychain
193+
// (kernel keyutils on Linux) is in-memory and does not. Unset keeps the
194+
// registration-order default (system keychain when reachable).
195+
const defaultSecretProvider = process.env.EXECUTOR_DEFAULT_SECRET_PROVIDER?.trim();
196+
189197
const executor = yield* createExecutor({
190198
tenant: Tenant.make(tenantId),
191199
subject: Subject.make(LOCAL_SUBJECT),
192200
db: sqlite.db,
193201
plugins,
194202
onElicitation: "accept-all",
203+
...(defaultSecretProvider
204+
? { defaultCredentialProvider: ProviderKey.make(defaultSecretProvider) }
205+
: {}),
195206
oauthEndpointUrlPolicy: { allowHttp: true },
196207
// EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback`
197208
// route on the same origin as the web UI. Derived from `webBaseUrl`

packages/core/sdk/src/connections.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,3 +718,72 @@ describe("execute over a connection", () => {
718718
}),
719719
);
720720
});
721+
722+
describe("defaultCredentialProvider preference", () => {
723+
const namedMemoryProvider = (key: string): CredentialProvider => {
724+
const store = new Map<string, string>();
725+
return {
726+
key: ProviderKey.make(key),
727+
writable: true,
728+
get: (id) => Effect.sync(() => store.get(String(id)) ?? null),
729+
set: (id, value) => Effect.sync(() => void store.set(String(id), value)),
730+
has: (id) => Effect.sync(() => store.has(String(id))),
731+
list: () =>
732+
Effect.sync(() =>
733+
Array.from(store.keys()).map((k) => ({ id: ProviderItemId.make(k), name: k })),
734+
),
735+
};
736+
};
737+
738+
// Two writable providers; "first" registers ahead of "durable" in order.
739+
const twoProviderPlugin = definePlugin(() => ({
740+
id: "two-providers" as const,
741+
credentialProviders: [namedMemoryProvider("first"), namedMemoryProvider("durable")],
742+
storage: () => ({}),
743+
resolveTools: () =>
744+
Effect.succeed({ tools: [{ name: ToolName.make("noop"), description: "noop" }] }),
745+
invokeTool: () => Effect.succeed({}),
746+
extension: (ctx) => ({
747+
seed: () =>
748+
ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }),
749+
}),
750+
}))();
751+
752+
it.effect("selects the preferred writable store ahead of registration order", () =>
753+
Effect.gen(function* () {
754+
const executor = yield* makeTestExecutor({
755+
plugins: [twoProviderPlugin] as const,
756+
defaultCredentialProvider: ProviderKey.make("durable"),
757+
}).pipe(Effect.tap((e) => e["two-providers"].seed()));
758+
759+
const connection = yield* executor.connections.create({
760+
owner: "org",
761+
name: ConnectionName.make("main"),
762+
integration: INTEG,
763+
template: TEMPLATE,
764+
value: "secret-token",
765+
});
766+
767+
expect(connection.provider).toBe(ProviderKey.make("durable"));
768+
}),
769+
);
770+
771+
it.effect("falls back to registration order when the preferred key is not registered", () =>
772+
Effect.gen(function* () {
773+
const executor = yield* makeTestExecutor({
774+
plugins: [twoProviderPlugin] as const,
775+
defaultCredentialProvider: ProviderKey.make("nonexistent"),
776+
}).pipe(Effect.tap((e) => e["two-providers"].seed()));
777+
778+
const connection = yield* executor.connections.create({
779+
owner: "org",
780+
name: ConnectionName.make("main"),
781+
integration: INTEG,
782+
template: TEMPLATE,
783+
value: "secret-token",
784+
});
785+
786+
expect(connection.provider).toBe(ProviderKey.make("first"));
787+
}),
788+
);
789+
});

packages/core/sdk/src/executor.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
406406
* `plugin.credentialProviders`. Config providers register first, so the
407407
* default (first writable) store is selected from them when present. */
408408
readonly providers?: readonly CredentialProvider[];
409+
/** Preferred key for the default writable credential store. When set and the
410+
* named provider is registered and writable, it is chosen as the default
411+
* store (for OAuth tokens and pasted secrets) ahead of registration order.
412+
* Lets a host on ephemeral infrastructure force a durable on-disk store
413+
* (e.g. "file") instead of an in-memory system keychain whose secrets do
414+
* not survive a fresh machine. Falls back to registration order when unset
415+
* or when the named provider is absent or read-only. */
416+
readonly defaultCredentialProvider?: ProviderKey;
409417
/**
410418
* How to respond when a tool requests user input mid-invocation. Pass
411419
* `"accept-all"` for tests / non-interactive hosts, or a handler.
@@ -1442,7 +1450,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
14421450
yield* registerCredentialProvider(provider, "config");
14431451
}
14441452

1453+
const preferredProviderKey =
1454+
config.defaultCredentialProvider != null ? String(config.defaultCredentialProvider) : null;
1455+
14451456
const defaultWritableProvider = (): CredentialProvider | null => {
1457+
if (preferredProviderKey !== null) {
1458+
const preferred = credentialProviders.get(preferredProviderKey);
1459+
if (preferred?.writable) return preferred;
1460+
}
14461461
for (const key of credentialProviderOrder) {
14471462
const provider = credentialProviders.get(key);
14481463
if (provider?.writable) return provider;

packages/core/sdk/src/test-config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [
122122
* no OAuth callback (exercises the fail-loud redirect path). */
123123
readonly redirectUri?: string | null;
124124
readonly oauthCallbackStateOrgSlug?: string;
125+
/** Prefer a named provider as the default writable secret store, ahead of
126+
* registration order (mirrors `ExecutorConfig.defaultCredentialProvider`). */
127+
readonly defaultCredentialProvider?: ExecutorConfig<TPlugins>["defaultCredentialProvider"];
125128
};
126129

127130
export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
@@ -160,6 +163,9 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea
160163
// Tests default to auto-accepting elicitation prompts.
161164
onElicitation: "accept-all",
162165
...(redirectUri != null ? { redirectUri } : {}),
166+
...(options?.defaultCredentialProvider != null
167+
? { defaultCredentialProvider: options.defaultCredentialProvider }
168+
: {}),
163169
oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
164170
};
165171
};

0 commit comments

Comments
 (0)