Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/default-credential-provider.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions apps/local/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { basename, join } from "node:path";
import { createHash } from "node:crypto";

import {
ProviderKey,
Subject,
Tenant,
createExecutor,
Expand Down Expand Up @@ -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`
Expand Down
69 changes: 69 additions & 0 deletions packages/core/sdk/src/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,72 @@ describe("execute over a connection", () => {
}),
);
});

describe("defaultCredentialProvider preference", () => {
const namedMemoryProvider = (key: string): CredentialProvider => {
const store = new Map<string, string>();
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"));
}),
);
});
15 changes: 15 additions & 0 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
* `plugin.credentialProviders`. Config providers register first, so the
* default (first writable) store is selected from them when present. */
readonly providers?: readonly CredentialProvider[];
/** Preferred key for the default writable credential store. When set and the
* named provider is registered and writable, it is chosen as the default
* store (for OAuth tokens and pasted secrets) ahead of registration order.
* Lets a host on ephemeral infrastructure force a durable on-disk store
* (e.g. "file") instead of an in-memory system keychain whose secrets do
* not survive a fresh machine. Falls back to registration order when unset
* or when the named provider is absent or read-only. */
readonly defaultCredentialProvider?: ProviderKey;
/**
* How to respond when a tool requests user input mid-invocation. Pass
* `"accept-all"` for tests / non-interactive hosts, or a handler.
Expand Down Expand Up @@ -1442,7 +1450,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
yield* registerCredentialProvider(provider, "config");
}

const preferredProviderKey =
config.defaultCredentialProvider != null ? String(config.defaultCredentialProvider) : null;

const defaultWritableProvider = (): CredentialProvider | null => {
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;
Expand Down
6 changes: 6 additions & 0 deletions packages/core/sdk/src/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [
* no OAuth callback (exercises the fail-loud redirect path). */
readonly redirectUri?: string | null;
readonly oauthCallbackStateOrgSlug?: string;
/** Prefer a named provider as the default writable secret store, ahead of
* registration order (mirrors `ExecutorConfig.defaultCredentialProvider`). */
readonly defaultCredentialProvider?: ExecutorConfig<TPlugins>["defaultCredentialProvider"];
};

export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
Expand Down Expand Up @@ -160,6 +163,9 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea
// Tests default to auto-accepting elicitation prompts.
onElicitation: "accept-all",
...(redirectUri != null ? { redirectUri } : {}),
...(options?.defaultCredentialProvider != null
? { defaultCredentialProvider: options.defaultCredentialProvider }
: {}),
oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
};
};
Expand Down