Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ personal-notes/
*.har.executor
executor.har
.executor/
apps/local/.executor-dev/

# desktop app build artifacts
apps/desktop/resources/
Expand Down
54 changes: 42 additions & 12 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,20 +748,50 @@ const withStdoutReroutedToStderr = async <A>(body: () => Promise<A>): Promise<A>

const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>
Effect.gen(function* () {
const executor = yield* Effect.promise(() => withStdoutReroutedToStderr(() => getExecutor()));
yield* Effect.promise(() =>
runMcpStdioServer({
executor,
codeExecutor: makeQuickJsExecutor(),
elicitationMode:
input.elicitationMode === "browser"
? {
mode: "browser" as const,
approvalUrl: (executionId) => `/resume/${encodeURIComponent(executionId)}`,
}
: { mode: input.elicitationMode },
const web = yield* Effect.promise(() =>
withStdoutReroutedToStderr(async () => {
const host = "127.0.0.1";
const port = await Effect.runPromise(
chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),
);
const baseUrl = `http://localhost:${port}`;
const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);

try {
const executor = await getExecutor();
const server = await startServer({
port,
hostname: host,
embeddedWebUI,
});
const serverBaseUrl = `http://localhost:${server.port}`;
return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };
} catch (cause) {
restoreWebBaseUrl();
throw cause;
}
}),
);

try {
yield* Effect.promise(() =>
runMcpStdioServer({
executor: web.executor,
codeExecutor: makeQuickJsExecutor(),
elicitationMode:
input.elicitationMode === "browser"
? {
mode: "browser" as const,
approvalUrl: (executionId) =>
`${web.baseUrl}/resume/${encodeURIComponent(executionId)}`,
}
: { mode: input.elicitationMode },
}),
);
} finally {
web.restoreWebBaseUrl();
yield* Effect.promise(() => web.server.stop());
}
});

const scope = Options.string("scope").pipe(
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/mcp-miniflare.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it)
// `HttpApiGroup` name ("approve") becomes part of the sandbox path,
// so the invocation reads `tools.approveapi.approve.approveThing`.
const code = [
`await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`,
`await tools.executor.openapi.addSource({ name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`,
`return await tools.approveapi.approve.approveThing({});`,
].join("\n");
const result = yield* Effect.promise(() =>
Expand Down
30 changes: 23 additions & 7 deletions apps/cloud/src/routes/secrets.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { Schema } from "effect";
import { createFileRoute } from "@tanstack/react-router";
import { SecretsPage } from "@executor-js/react/pages/secrets";

const SearchParams = Schema.toStandardSchemaV1(
Schema.Struct({
name: Schema.optional(Schema.String),
secretId: Schema.optional(Schema.String),
provider: Schema.optional(Schema.String),
scope: Schema.optional(Schema.String),
}),
);

export const Route = createFileRoute("/secrets")({
component: () => (
<SecretsPage
addSecretDescription="Store a credential or API key for this organization."
showProviderInfo={false}
storageOptions={[{ value: "workos-vault", label: "WorkOS Vault" }]}
/>
),
validateSearch: SearchParams,
component: () => {
const { name, secretId, provider, scope } = Route.useSearch();
const hasPrefill = name != null || secretId != null;
return (
<SecretsPage
addSecretDescription="Store a credential or API key for this organization."
showProviderInfo={false}
storageOptions={[{ value: "workos-vault", label: "WorkOS Vault" }]}
prefill={hasPrefill ? { name, secretId, provider, scope } : undefined}
/>
);
},
});
3 changes: 3 additions & 0 deletions apps/cloud/src/services/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,8 @@ export const createScopedExecutor = (
plugins,
httpClientLayer,
onElicitation: "accept-all",
coreTools: {
webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
},
});
});
4 changes: 2 additions & 2 deletions apps/desktop/scripts/smoke-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ const main = async () => {
// inside QuickJS.
const code = `
await tools.executor.openapi.addSource({
scope: ${JSON.stringify(scopeDir)},
spec: ${JSON.stringify(`${openapi.origin}/openapi.json`)},
spec: { kind: "url", url: ${JSON.stringify(`${openapi.origin}/openapi.json`)} },
name: "Petstore Smoke API",
baseUrl: ${JSON.stringify(openapi.origin)},
namespace: "petstore",
});
Expand Down
5 changes: 4 additions & 1 deletion apps/local/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export default defineExecutorConfig({
keychainPlugin(),
fileSecretsPlugin(),
onepasswordHttpPlugin(),
desktopSettingsPlugin(),
desktopSettingsPlugin({
webBaseUrl:
process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`,
}),
] as const,
});
2 changes: 1 addition & 1 deletion apps/local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"scripts": {
"dev": "bun run dev:proxy && bun run dev:vite",
"dev:proxy": "portless proxy start --multiplex --port 1355 || true",
"dev:vite": "portless --name executor-local bunx --bun vite dev",
"dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev",
"build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build",
"start": "bun run src/serve.ts",
"db:generate": "drizzle-kit generate",
Expand Down
31 changes: 28 additions & 3 deletions apps/local/src/server/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ const removeSqliteFileSet = (path: string) => {
}
};

const removeSqliteSidecars = (path: string) => {
for (const suffix of ["-wal", "-shm"]) {
fs.rmSync(`${path}${suffix}`, { force: true });
}
};

const moveSqliteFileSet = (source: string, target: string) => {
fs.renameSync(source, target);
for (const suffix of ["-wal", "-shm"]) {
Expand Down Expand Up @@ -335,6 +341,7 @@ const replaceSqliteFileSetWithRollback = (input: {
readonly targetPath: string;
}): string => {
const backupPath = moveSqliteFileSetToBackup(input.sourcePath);
removeSqliteSidecars(backupPath);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local DB replacement must restore the original file set if the swap fails halfway
try {
moveSqliteFileSet(input.targetPath, input.sourcePath);
Expand Down Expand Up @@ -401,6 +408,8 @@ const prepareLegacySqliteForFumaImport = (input: {
`Skipping legacy Drizzle replay and importing the existing schema as-is.`,
);
}
sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
sqlite.exec("PRAGMA journal_mode = DELETE");
return { legacySecrets: [] };
} finally {
sqlite.close();
Expand Down Expand Up @@ -446,8 +455,10 @@ const importMissingMarkedTables = async (input: {
tables: pickedTables,
scopeId: input.scopeId,
});
target.sqlite.exec("PRAGMA wal_checkpoint(FULL)");
target.sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
target.sqlite.exec("PRAGMA journal_mode = DELETE");
await target.close();
removeSqliteSidecars(input.storage.sqlitePath);

if (result.imported) {
const importedTables = [
Expand Down Expand Up @@ -511,9 +522,11 @@ export const importLegacySqliteIfNeeded = async (options: {
await withQueryContext(target.db, {
allowedScopeIds: new Set([scopeId]),
}).createMany("secret", createLegacySecretRows(scopeId, prepared.legacySecrets));
target.sqlite.exec("PRAGMA wal_checkpoint(FULL)");
target.sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
target.sqlite.exec("PRAGMA journal_mode = DELETE");
} finally {
await target.close();
removeSqliteSidecars(storage.sqlitePath);
}
}
writeSqliteImportMarker(storage.importMarkerPath, {
Expand Down Expand Up @@ -576,8 +589,10 @@ export const importLegacySqliteIfNeeded = async (options: {
tables,
scopeId,
});
target.sqlite.exec("PRAGMA wal_checkpoint(FULL)");
target.sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
target.sqlite.exec("PRAGMA journal_mode = DELETE");
await target.close();
removeSqliteSidecars(targetPath);

if (result.imported) {
const backupPath = replaceSqliteFileSetWithRollback({
Expand Down Expand Up @@ -653,6 +668,16 @@ const createLocalExecutorLayer = () => {
plugins,
onElicitation: "accept-all",
oauthEndpointUrlPolicy: { allowHttp: true },
// Built-in agent-facing tools (scopes.list, secrets.list,
// secrets.create). webBaseUrl is where the executor's web UI
// listens — same port as the daemon API since the daemon serves
// both. Mirrors serve.ts's port resolution so a custom $PORT
// flows through. EXECUTOR_WEB_BASE_URL overrides entirely for
// deployments where the UI is on a different host.
coreTools: {
webBaseUrl:
process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`,
},
});

return { executor, plugins };
Expand Down
2 changes: 2 additions & 0 deletions apps/local/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const EXECUTOR_GITHUB_URL = (
.replace(/^git\+/, "")
.replace(/\.git$/, "");

const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));

/**
Expand Down Expand Up @@ -113,6 +114,7 @@ export default defineConfig({
define: {
"import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
"import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),
"import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
},
resolve: {
Expand Down
12 changes: 12 additions & 0 deletions notes/source-setup-sdk-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Source setup orchestration

The web source-add flow currently owns a lot of business logic that agents have
to rediscover through low-level tools: preset lookup, URL-to-endpoint mapping,
endpoint probing, OAuth strategy choice, connection id generation, browser
handoff, credential binding, and final source registration.

Longer term, consider moving this into an SDK-level source setup service so the
frontend and agent tools share the same state machine. The frontend would render
steps from the service, while agent tools would return the same state with
model-facing `instructions` fields. Keep low-level plugin tools as escape
hatches, but make common preset flows first-class.
6 changes: 6 additions & 0 deletions notes/todo
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Improve agent-facing source configuration scope flow

- Source add tools now hide raw source placement and resolve the install scope internally, but credential placement still exposes raw scope ids.
- Add a product-level credential target flow for agents, probably "personal" vs "organization", that maps to the right credential scope internally.
- Keep the main-branch separation intact: source tools declare shared source config and credential slots; credential tools create secrets/connections and bind them at the selected credential scope.
- Agent descriptions should explain when a credential target choice is needed and when local single-scope executors can default automatically.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"scripts": {
"dev": "turbo run dev --filter='!@executor-js/desktop' --filter='!@executor-js/cloud'",
"dev:desktop": "turbo run dev",
"dev:cli": "EXECUTOR_DEV=1 bun run apps/cli/src/main.ts",
"dev:cli": "EXECUTOR_DEV=1 EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-apps/local/.executor-dev} bun run apps/cli/src/main.ts",
"test": "turbo run test",
"test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",
"build:packages": "bun run --filter='fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
Expand All @@ -44,6 +44,7 @@
"lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",
"docs:snippets": "bun run scripts/generate-doc-snippets.ts",
"docs:smoke:install": "bun run scripts/smoke-docs-install.ts",
"smoke:agent-config": "bun run scripts/agent-config-smoke.ts",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"pull:references": "bun run scripts/pull-references.ts",
Expand Down
21 changes: 20 additions & 1 deletion packages/app/src/routes/secrets.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { Schema } from "effect";
import { createFileRoute } from "@tanstack/react-router";
import { SecretsPage } from "@executor-js/react/pages/secrets";

// Query params supported by the agent-facing `secrets.create` static tool:
// it builds a URL like `/secrets?name=…&scope=…&secretId=…` and hands
// it to the user. The page opens the add modal pre-filled when any
// prefill field is present so the user only has to type the value.
const SearchParams = Schema.toStandardSchemaV1(
Schema.Struct({
name: Schema.optional(Schema.String),
secretId: Schema.optional(Schema.String),
provider: Schema.optional(Schema.String),
scope: Schema.optional(Schema.String),
}),
);

export const Route = createFileRoute("/secrets")({
component: SecretsPage,
validateSearch: SearchParams,
component: () => {
const { name, secretId, provider, scope } = Route.useSearch();
const hasPrefill = name != null || secretId != null;
return <SecretsPage prefill={hasPrefill ? { name, secretId, provider, scope } : undefined} />;
},
});
2 changes: 2 additions & 0 deletions packages/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import appPlugin from "./vite";
// here unless a separate server is running.
const LOCAL_CONFIG = fileURLToPath(new URL("../../apps/local/executor.config.ts", import.meta.url));
const LOCAL_JSONC = fileURLToPath(new URL("../../apps/local/executor.jsonc", import.meta.url));
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));

export default defineConfig({
plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })],
define: {
"import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-dev"),
"import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"),
"import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),
},
});
25 changes: 19 additions & 6 deletions packages/core/execution/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,31 @@ export const formatPausedExecution = (
const lines: string[] = [`Execution paused: ${req.message}`];
const isUrlElicitation = Predicate.isTagged(req, "UrlElicitation");
const isFormElicitation = Predicate.isTagged(req, "FormElicitation");
const requestedSchema = isFormElicitation ? req.requestedSchema : undefined;
const hasRequestedSchema =
requestedSchema !== undefined && Object.keys(requestedSchema).length > 0;
const instructions = isUrlElicitation
? `The user needs to open this URL in a browser and complete the flow. After the user finishes, call the resume tool with executionId "${paused.id}" and action "accept".`
: hasRequestedSchema
? `Ask the user for values matching requestedSchema. Then call the resume tool with executionId "${paused.id}", action "accept", and content matching requestedSchema. If the user declines, call resume with action "decline" or "cancel".`
: `This is a model-side confirmation gate; there is no browser form to open. Ask the user whether to approve the paused tool call. If the user approves, call the resume tool with executionId "${paused.id}" and action "accept". If the user declines, call resume with action "decline" or "cancel".`;

if (isUrlElicitation) {
lines.push(`\nOpen this URL in a browser:\n${req.url}`);
lines.push("\nAfter the browser flow, resume with the executionId below:");
lines.push('\nAfter the browser flow, call the resume tool with action "accept".');
} else if (hasRequestedSchema) {
lines.push(
"\nAsk the user for a response matching the requested schema, then call the resume tool.",
);
lines.push(`\nRequested schema:\n${JSON.stringify(requestedSchema, null, 2)}`);
} else {
lines.push("\nResume with the executionId below and a response matching the requested schema:");
const schema = req.requestedSchema;
if (schema && Object.keys(schema).length > 0) {
lines.push(`\nRequested schema:\n${JSON.stringify(schema, null, 2)}`);
}
lines.push(
'\nThis is a model-side confirmation gate; no browser form is waiting. Ask the user whether to approve, then call the resume tool with action "accept", "decline", or "cancel".',
);
}

lines.push(`\nexecutionId: ${paused.id}`);
lines.push(`\ninstructions: ${instructions}`);

return {
text: lines.join("\n"),
Expand All @@ -129,6 +141,7 @@ export const formatPausedExecution = (
interaction: {
kind: isUrlElicitation ? "url" : "form",
message: req.message,
instructions,
toolId: String(paused.elicitationContext.toolId),
args: paused.elicitationContext.args,
...(isUrlElicitation ? { url: req.url } : {}),
Expand Down
Loading
Loading