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
161 changes: 161 additions & 0 deletions apps/local/src/server/config-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// ---------------------------------------------------------------------------
// Integration test for boot-time config sync.
//
// Drives `syncFromConfig` against a real in-memory executor and asserts
// the replayed source lands in the DB with the correct runtime shape —
// specifically that remote MCP auth makes it through the file→runtime
// transform intact. Covers the regression class where an auth field is
// silently dropped somewhere along the replay path.
//
// `mcp.addSource` for remote sources persists the row even when auth
// resolution or tool discovery fails (see #364), so we don't need to
// seed secrets or stand up a real MCP server — an unreachable endpoint
// is enough to assert on the stored auth shape.
// ---------------------------------------------------------------------------

import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { SECRET_REF_PREFIX, type ExecutorFileConfig } from "@executor/config";
import { createExecutor, makeTestConfig } from "@executor/sdk";
import { mcpPlugin } from "@executor/plugin-mcp";
import { openApiPlugin } from "@executor/plugin-openapi";
import { graphqlPlugin } from "@executor/plugin-graphql";

import { syncFromConfig } from "./config-sync";

const UNREACHABLE = "http://127.0.0.1:1/mcp";
const TEST_SCOPE = "test-scope";

let workDir: string;

beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "exec-config-sync-"));
});

afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});

const writeConfig = (config: ExecutorFileConfig): string => {
const path = join(workDir, "executor.jsonc");
writeFileSync(path, JSON.stringify(config, null, 2));
return path;
};

const makeExecutor = () =>
createExecutor(
makeTestConfig({
plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const,
}),
);

describe("syncFromConfig", () => {
it.effect("replays remote MCP header auth with the secret-ref prefix stripped", () =>
Effect.gen(function* () {
const configPath = writeConfig({
sources: [
{
kind: "mcp",
transport: "remote",
name: "PostHog",
endpoint: UNREACHABLE,
namespace: "posthog",
auth: {
kind: "header",
headerName: "Authorization",
secret: `${SECRET_REF_PREFIX}posthog-api-key`,
prefix: "Bearer ",
},
},
],
});
const executor = yield* makeExecutor();

yield* syncFromConfig(executor, configPath);

const stored = yield* executor.mcp.getSource("posthog", TEST_SCOPE);
expect(stored).not.toBeNull();
expect(stored!.config).toMatchObject({
transport: "remote",
endpoint: UNREACHABLE,
auth: {
kind: "header",
headerName: "Authorization",
secretId: "posthog-api-key",
prefix: "Bearer ",
},
});
}),
);

it.effect("replays remote MCP oauth2 auth preserving connectionId", () =>
Effect.gen(function* () {
const configPath = writeConfig({
sources: [
{
kind: "mcp",
transport: "remote",
name: "Linear",
endpoint: UNREACHABLE,
namespace: "linear",
auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" },
},
],
});
const executor = yield* makeExecutor();

yield* syncFromConfig(executor, configPath);

const stored = yield* executor.mcp.getSource("linear", TEST_SCOPE);
expect(stored).not.toBeNull();
expect(stored!.config).toMatchObject({
transport: "remote",
auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" },
});
}),
);

it.effect("preserves kind:none auth on replay", () =>
Effect.gen(function* () {
const configPath = writeConfig({
sources: [
{
kind: "mcp",
transport: "remote",
name: "DeepWiki",
endpoint: UNREACHABLE,
namespace: "devin",
auth: { kind: "none" },
},
],
});
const executor = yield* makeExecutor();

yield* syncFromConfig(executor, configPath);

const stored = yield* executor.mcp.getSource("devin", TEST_SCOPE);
expect(stored!.config).toMatchObject({
transport: "remote",
auth: { kind: "none" },
});
}),
);

it.effect("skips a missing config file without error", () =>
Effect.gen(function* () {
const executor = yield* makeExecutor();
const missing = join(workDir, "does-not-exist.jsonc");

yield* syncFromConfig(executor, missing);

// No MCP source should be created from the missing file. Plugins
// may seed their own static control sources — filter to MCP only.
const sources = yield* executor.sources.list();
expect(sources.filter((s) => s.kind === "mcp")).toHaveLength(0);
}),
);
});
56 changes: 14 additions & 42 deletions apps/local/src/server/config-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,19 @@ import { join } from "node:path";
import * as fs from "node:fs";
import * as jsonc from "jsonc-parser";

import type {
SourceConfig,
ExecutorFileConfig,
ConfigHeaderValue,
import type { SourceConfig, ExecutorFileConfig } from "@executor/config";
import {
headersFromConfigValues,
mcpAuthFromConfig,
} from "@executor/config";
import { SECRET_REF_PREFIX } from "@executor/config";

import type { LocalExecutor } from "./executor";

// ---------------------------------------------------------------------------
// Header translation: config format → plugin format
// ---------------------------------------------------------------------------

const translateHeader = (
value: ConfigHeaderValue,
): string | { secretId: string; prefix?: string } => {
if (typeof value === "string") {
if (value.startsWith(SECRET_REF_PREFIX)) {
return { secretId: value.slice(SECRET_REF_PREFIX.length) };
}
return value;
}
// Object form: { value, prefix? }
if (typeof value.value === "string" && value.value.startsWith(SECRET_REF_PREFIX)) {
return {
secretId: value.value.slice(SECRET_REF_PREFIX.length),
prefix: value.prefix,
};
}
return value.value;
};

const translateHeaders = (
headers: Record<string, ConfigHeaderValue> | undefined,
): Record<string, string | { secretId: string; prefix?: string }> | undefined => {
if (!headers) return undefined;
const out: Record<string, string | { secretId: string; prefix?: string }> = {};
for (const [k, v] of Object.entries(headers)) {
out[k] = translateHeader(v);
}
return out;
};
// Narrow, non-exported view of the executor — tests can provide a
// minimal `createExecutor({ plugins: [mcpPlugin(), …] })` without
// loading the full LocalExecutor plugin set (keychain/1password/etc.).
// Derived via `Pick` so there's no signature restatement.
type ConfigSyncTarget = Pick<LocalExecutor, "scopes" | "openapi" | "graphql" | "mcp">;

// ---------------------------------------------------------------------------
// Config path resolution
Expand Down Expand Up @@ -81,7 +52,7 @@ const loadConfigSync = (path: string): ExecutorFileConfig | null => {
// ---------------------------------------------------------------------------

const addSourceFromConfig = (
executor: LocalExecutor,
executor: ConfigSyncTarget,
source: SourceConfig,
): Effect.Effect<void, unknown> => {
// `executor.jsonc` is a single-scope artifact today — the file isn't
Expand All @@ -96,15 +67,15 @@ const addSourceFromConfig = (
scope,
baseUrl: source.baseUrl,
namespace: source.namespace,
headers: translateHeaders(source.headers),
headers: headersFromConfigValues(source.headers),
}).pipe(Effect.asVoid);

case "graphql":
return executor.graphql.addSource({
endpoint: source.endpoint,
scope,
namespace: source.namespace,
headers: translateHeaders(source.headers) as Record<string, string> | undefined,
headers: headersFromConfigValues(source.headers) as Record<string, string> | undefined,
}).pipe(Effect.asVoid);

case "mcp":
Expand All @@ -128,6 +99,7 @@ const addSourceFromConfig = (
remoteTransport: source.remoteTransport,
queryParams: source.queryParams,
headers: source.headers,
auth: mcpAuthFromConfig(source.auth),
namespace: source.namespace,
}).pipe(Effect.asVoid);
}
Expand All @@ -138,7 +110,7 @@ const addSourceFromConfig = (
* Each source is added independently — if one fails, the rest still load.
*/
export const syncFromConfig = (
executor: LocalExecutor,
executor: ConfigSyncTarget,
configPath: string,
): Effect.Effect<void> =>
Effect.gen(function* () {
Expand Down
11 changes: 9 additions & 2 deletions packages/core/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
McpRemoteSourceConfig,
McpStdioSourceConfig,
McpAuthConfig,
McpConnectionAuth,
SecretMetadata,
ConfigHeaderValue,
SECRET_REF_PREFIX,
Expand All @@ -22,8 +23,14 @@ export {
} from "./write";

export type { ConfigFileSink, ConfigFileSinkOptions } from "./sink";
export { makeFileConfigSink } from "./sink";

export {
makeFileConfigSink,
headerToConfigValue,
headersToConfigValues,
} from "./sink";
headerFromConfigValue,
headersFromConfigValues,
mcpAuthToConfig,
mcpAuthFromConfig,
type PluginHeaderValue,
} from "./transform";
33 changes: 30 additions & 3 deletions packages/core/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export type GraphqlSourceConfig = typeof GraphqlSourceConfig.Type;

const StringMap = Schema.Record({ key: Schema.String, value: Schema.String });

// ---------------------------------------------------------------------------
// MCP connection auth — two shapes.
//
// `McpAuthConfig` is the file shape: header `secret` is a
// `secret-public-ref:<id>` string so the file is human-readable and
// stable across machines.
//
// `McpConnectionAuth` is the runtime shape handed to the plugin: header
// `secretId` is the bare id. Transforms between the two shapes live in
// `./transform` — plugins never touch the file prefix directly.
//
// `oauth2` is identical in both shapes today: a stable pointer to an SDK
// Connection (`ctx.connections`) holding the token material.
// ---------------------------------------------------------------------------

export const McpAuthConfig = Schema.Union(
Schema.Struct({ kind: Schema.Literal("none") }),
Schema.Struct({
Expand All @@ -59,14 +74,26 @@ export const McpAuthConfig = Schema.Union(
}),
Schema.Struct({
kind: Schema.Literal("oauth2"),
/** Stable id of the SDK Connection holding access + refresh token
* material. Scope shadowing means the same id resolves per-user
* via the executor's innermost-wins lookup. */
connectionId: Schema.String,
}),
);
export type McpAuthConfig = typeof McpAuthConfig.Type;

export const McpConnectionAuth = Schema.Union(
Schema.Struct({ kind: Schema.Literal("none") }),
Schema.Struct({
kind: Schema.Literal("header"),
headerName: Schema.String,
secretId: Schema.String,
prefix: Schema.optional(Schema.String),
}),
Schema.Struct({
kind: Schema.Literal("oauth2"),
connectionId: Schema.String,
}),
);
export type McpConnectionAuth = typeof McpConnectionAuth.Type;

export const McpRemoteSourceConfig = Schema.Struct({
kind: Schema.Literal("mcp"),
transport: Schema.Literal("remote"),
Expand Down
27 changes: 6 additions & 21 deletions packages/core/config/src/sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,14 @@ import { Effect } from "effect";
import type { Layer } from "effect";
import type { FileSystem } from "@effect/platform";

import { SECRET_REF_PREFIX, type ConfigHeaderValue, type SourceConfig } from "./schema";
import type { SourceConfig } from "./schema";
import { addSourceToConfig, removeSourceFromConfig } from "./write";

// Translate a plugin-side header value (`{ secretId, prefix? }` for secret
// refs) into the config file's `secret-public-ref:<id>` string form.
type PluginHeaderValue = string | { secretId: string; prefix?: string };

export const headerToConfigValue = (
value: PluginHeaderValue,
): ConfigHeaderValue => {
if (typeof value === "string") return value;
const ref = `${SECRET_REF_PREFIX}${value.secretId}`;
return value.prefix ? { value: ref, prefix: value.prefix } : ref;
};

export const headersToConfigValues = (
headers: Record<string, PluginHeaderValue> | undefined,
): Record<string, ConfigHeaderValue> | undefined => {
if (!headers) return undefined;
const out: Record<string, ConfigHeaderValue> = {};
for (const [k, v] of Object.entries(headers)) out[k] = headerToConfigValue(v);
return out;
};
export {
headerToConfigValue,
headersToConfigValues,
type PluginHeaderValue,
} from "./transform";

export interface ConfigFileSink {
readonly upsertSource: (source: SourceConfig) => Effect.Effect<void>;
Expand Down
Loading
Loading