Skip to content

Commit e7e3283

Browse files
committed
feat(sdk): add built-in core-tools plugin with agent secret-create flow
Introduces `coreToolsPlugin` in @executor-js/sdk exposing three static tools agents can call directly: - `scopes.list` — enumerate visible scopes by name; agents pick a scope by asking the user, not by guessing or defaulting. - `secrets.list` — visible secrets as `{id, name, provider}`; values never cross the agent boundary. - `secrets.create` — pre-allocates a secret id and returns a URL to the existing /secrets web form, pre-filled with name + id. The user enters the value in the browser; the agent confirms by re-listing. The plugin auto-registers when `coreTools: { webBaseUrl }` is set on ExecutorConfig, so hosts opt in with one line. apps/local wires it to the daemon's own port — same host as the API, no separate UI server. The /secrets web side reads the prefill params via TanStack Router `validateSearch`, opens the add modal pre-filled, and locks the id via a new `initialIdOverride` prop on SecretForm. URL prefilling works fully through the dev:cli vite proxy.
1 parent 45cf288 commit e7e3283

7 files changed

Lines changed: 279 additions & 5 deletions

File tree

apps/local/src/server/executor.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,17 @@ const createLocalExecutorLayer = () => {
320320
plugins,
321321
onElicitation: "accept-all",
322322
oauthEndpointUrlPolicy: { allowHttp: true },
323+
// Built-in agent-facing tools (scopes.list, secrets.list,
324+
// secrets.create). webBaseUrl is where the executor's web UI
325+
// listens — same port as the daemon API since the daemon serves
326+
// both. Mirrors serve.ts's port resolution so a custom $PORT
327+
// flows through. EXECUTOR_WEB_BASE_URL overrides entirely for
328+
// deployments where the UI is on a different host.
329+
coreTools: {
330+
webBaseUrl:
331+
process.env.EXECUTOR_WEB_BASE_URL ??
332+
`http://localhost:${process.env.PORT ?? "4788"}`,
333+
},
323334
});
324335

325336
// Sync sources from executor.jsonc (idempotent — plugins upsert).
Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
import { Schema } from "effect";
12
import { createFileRoute } from "@tanstack/react-router";
23
import { SecretsPage } from "@executor-js/react/pages/secrets";
34

5+
// Query params supported by the agent-facing `secrets.create` static tool:
6+
// it builds a URL like `/secrets?name=…&scope=…&secretId=…` and hands
7+
// it to the user. The page opens the add modal pre-filled when any
8+
// prefill field is present so the user only has to type the value.
9+
const SearchParams = Schema.toStandardSchemaV1(
10+
Schema.Struct({
11+
name: Schema.optional(Schema.String),
12+
secretId: Schema.optional(Schema.String),
13+
provider: Schema.optional(Schema.String),
14+
scope: Schema.optional(Schema.String),
15+
}),
16+
);
17+
418
export const Route = createFileRoute("/secrets")({
5-
component: SecretsPage,
19+
validateSearch: SearchParams,
20+
component: () => {
21+
const { name, secretId, provider } = Route.useSearch();
22+
const hasPrefill = name != null || secretId != null;
23+
return <SecretsPage prefill={hasPrefill ? { name, secretId, provider } : undefined} />;
24+
},
625
});
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// ---------------------------------------------------------------------------
2+
// core-tools plugin
3+
//
4+
// Built-in plugin that contributes agent-facing static tools for managing
5+
// executor-level primitives (scopes, secrets). Auto-registered by
6+
// `createExecutor`, so callers don't need to wire it in.
7+
//
8+
// Today's surface:
9+
// - scopes.list — enumerate visible scopes by name
10+
// - secrets.list — list visible secrets (collapsed across scopes)
11+
// - secrets.create — agent supplies scope + name; tool returns a URL
12+
// that opens the existing /secrets web page with the
13+
// add-modal pre-filled. User enters the value in
14+
// that form (writes via the existing secrets HTTP
15+
// endpoint). Agent confirms by calling secrets.list.
16+
//
17+
// No elicitation suspension, no cross-request coordination. Works on
18+
// Cloudflare Workers because the tool's return value is just a URL.
19+
//
20+
// The agent never sees plaintext secret values. The agent never picks a
21+
// default scope on the user's behalf — every write tool requires an
22+
// explicit scope name, and `scopes.list` exists so the agent can
23+
// enumerate options before asking.
24+
// ---------------------------------------------------------------------------
25+
26+
import { Effect, Schema } from "effect";
27+
28+
import { definePlugin, tool } from "./plugin";
29+
30+
// ---------------------------------------------------------------------------
31+
// Tool input/output schemas
32+
// ---------------------------------------------------------------------------
33+
34+
const ScopesListOutput = Schema.Struct({
35+
scopes: Schema.Array(
36+
Schema.Struct({
37+
name: Schema.String,
38+
}),
39+
),
40+
});
41+
42+
const SecretsListOutput = Schema.Struct({
43+
secrets: Schema.Array(
44+
Schema.Struct({
45+
id: Schema.String,
46+
name: Schema.String,
47+
provider: Schema.String,
48+
}),
49+
),
50+
});
51+
52+
const SecretsCreateInput = Schema.Struct({
53+
/** Display name shown in the secrets UI and used to reference this
54+
* secret in subsequent tool calls. */
55+
name: Schema.String,
56+
/** Name of the scope (from `scopes.list`) that should own this
57+
* secret. Required — there is no default. */
58+
scope: Schema.String,
59+
/** Optional provider override. If omitted, the executor picks the
60+
* first writable provider in registration order. */
61+
provider: Schema.optional(Schema.String),
62+
});
63+
64+
const SecretsCreateOutput = Schema.Struct({
65+
/** Pre-allocated id the secret will receive when the user submits the
66+
* form. The agent can pass this to other tools that need a secret
67+
* reference; it materializes in `secrets.list` once the user saves. */
68+
id: Schema.String,
69+
/** URL to hand to the user. Opens the /secrets page with the add
70+
* modal pre-filled with name, scope, and the pre-allocated id. */
71+
url: Schema.String,
72+
});
73+
74+
const ScopesListOutputStd = Schema.toStandardSchemaV1(
75+
Schema.toStandardJSONSchemaV1(ScopesListOutput),
76+
);
77+
const SecretsListOutputStd = Schema.toStandardSchemaV1(
78+
Schema.toStandardJSONSchemaV1(SecretsListOutput),
79+
);
80+
const SecretsCreateInputStd = Schema.toStandardSchemaV1(
81+
Schema.toStandardJSONSchemaV1(SecretsCreateInput),
82+
);
83+
const SecretsCreateOutputStd = Schema.toStandardSchemaV1(
84+
Schema.toStandardJSONSchemaV1(SecretsCreateOutput),
85+
);
86+
87+
// ---------------------------------------------------------------------------
88+
// Options
89+
// ---------------------------------------------------------------------------
90+
91+
export interface CoreToolsPluginOptions {
92+
/** Base URL of the executor's web UI. Used to build the URL handed to
93+
* the user for secret-value entry, e.g. `${webBaseUrl}/secrets?...`.
94+
* If omitted, secrets.create is registered but will fail at invoke
95+
* time — the host must supply a URL it can route back to. */
96+
readonly webBaseUrl?: string;
97+
}
98+
99+
// ---------------------------------------------------------------------------
100+
// Plugin
101+
// ---------------------------------------------------------------------------
102+
103+
export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {}) => ({
104+
id: "core-tools" as const,
105+
packageName: "@executor-js/sdk/core-tools",
106+
storage: () => ({}),
107+
extension: () => ({}),
108+
109+
staticSources: () => [
110+
{
111+
id: "core-tools",
112+
kind: "executor",
113+
name: "Executor",
114+
tools: [
115+
tool({
116+
name: "scopes.list",
117+
description:
118+
"List the scopes visible to this executor. Use this before any tool that takes a `scope` argument so you can ask the user which scope to use.",
119+
outputSchema: ScopesListOutputStd,
120+
execute: (_args, { ctx }) =>
121+
Effect.succeed({
122+
scopes: ctx.scopes.map((s) => ({ name: s.name })),
123+
}),
124+
}),
125+
126+
tool({
127+
name: "secrets.list",
128+
description:
129+
"List secrets visible to this executor. Returns id, display name, and provider — never values. Use the returned id when other tools ask for a secret reference.",
130+
outputSchema: SecretsListOutputStd,
131+
execute: (_args, { ctx }) =>
132+
Effect.gen(function* () {
133+
const refs = yield* ctx.secrets.list();
134+
return {
135+
secrets: refs.map((r) => ({
136+
id: r.id,
137+
name: r.name,
138+
provider: r.provider,
139+
})),
140+
};
141+
}),
142+
}),
143+
144+
tool({
145+
name: "secrets.create",
146+
description:
147+
"Create a new secret. Returns a URL the user should open to enter the value securely; the agent never sees plaintext. The secret materializes once the user submits the form — confirm by calling `secrets.list` and looking for the returned id.",
148+
inputSchema: SecretsCreateInputStd,
149+
outputSchema: SecretsCreateOutputStd,
150+
execute: (input, { ctx }) =>
151+
Effect.gen(function* () {
152+
const webBaseUrl = options.webBaseUrl;
153+
if (!webBaseUrl) {
154+
return yield* Effect.die(
155+
new Error(
156+
"core-tools secrets.create requires webBaseUrl. Pass it to coreToolsPlugin({ webBaseUrl }) at executor construction.",
157+
),
158+
);
159+
}
160+
161+
const targetScope = ctx.scopes.find((s) => s.name === input.scope);
162+
if (!targetScope) {
163+
return yield* Effect.die(
164+
new Error(
165+
`secrets.create: unknown scope "${input.scope}". Call scopes.list to see valid names.`,
166+
),
167+
);
168+
}
169+
170+
const secretId = crypto.randomUUID();
171+
172+
const url = new URL(`${webBaseUrl.replace(/\/$/, "")}/secrets`);
173+
// Page reads these and opens the add modal pre-filled.
174+
// Final value is collected from the user and written via
175+
// the existing /scopes/:id/secrets POST. The presence of
176+
// `name` is the open-modal signal (no separate flag).
177+
url.searchParams.set("scope", String(targetScope.id));
178+
url.searchParams.set("name", input.name);
179+
url.searchParams.set("secretId", secretId);
180+
if (input.provider) url.searchParams.set("provider", input.provider);
181+
182+
return { id: secretId, url: url.toString() };
183+
}),
184+
}),
185+
],
186+
},
187+
],
188+
}));
189+
190+
export default coreToolsPlugin;

packages/core/sdk/src/executor.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "@executor-js/storage-core";
2525

2626
import { pluginBlobStore, type BlobStore } from "./blob";
27+
import { coreToolsPlugin } from "./core-tools";
2728
import {
2829
ConnectionProviderState,
2930
ConnectionRef,
@@ -368,6 +369,20 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> {
368369
readonly timeout?: Duration.Input;
369370
readonly hostedOutboundPolicy?: boolean;
370371
};
372+
/**
373+
* Enable the built-in `core-tools` plugin which contributes
374+
* agent-facing static tools (`scopes.list`, `secrets.list`,
375+
* `secrets.create`). The `webBaseUrl` is where the executor's web
376+
* UI lives; `secrets.create` builds a URL elicitation that points
377+
* the user at `${webBaseUrl}/secrets?...` so the plaintext value
378+
* never crosses the agent.
379+
*
380+
* Omit to skip registration (tests, MCP-only hosts that don't
381+
* surface a web UI, etc.).
382+
*/
383+
readonly coreTools?: {
384+
readonly webBaseUrl: string;
385+
};
371386
}
372387

373388
// ---------------------------------------------------------------------------
@@ -766,7 +781,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []>
766781
const empty: readonly AnyPlugin[] = [];
767782
return empty as TPlugins;
768783
};
769-
const { scopes, adapter: rootAdapter, blobs, plugins = defaultPlugins() } = config;
784+
const { scopes, adapter: rootAdapter, blobs, plugins: userPlugins = defaultPlugins() } = config;
770785

771786
if (scopes.length === 0) {
772787
return yield* new StorageError({
@@ -775,6 +790,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []>
775790
});
776791
}
777792

793+
// Built-in core-tools plugin: contributes scopes.list / secrets.list /
794+
// secrets.create static tools so agents can manage executor primitives
795+
// without the host wiring it explicitly. Opt-in via `coreTools` config.
796+
const plugins: readonly AnyPlugin[] = config.coreTools
797+
? ([
798+
coreToolsPlugin({ webBaseUrl: config.coreTools.webBaseUrl }),
799+
...userPlugins,
800+
] as readonly AnyPlugin[])
801+
: (userPlugins as readonly AnyPlugin[]);
802+
778803
// Scope-wrap the root adapter so every read on a tenant-scoped
779804
// table filters by `scope_id IN (scopes)` and every write's
780805
// `scope_id` payload is validated to be in the stack. Reads walk

packages/core/sdk/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ export {
301301
collectSchemas,
302302
} from "./executor";
303303

304+
// Built-in core-tools plugin (scopes.list, secrets.list, secrets.create
305+
// with URL elicitation). Auto-registered by createExecutor when
306+
// `coreTools` is set on the config; also exportable for callers who
307+
// want to register it manually.
308+
export { coreToolsPlugin, type CoreToolsPluginOptions } from "./core-tools";
309+
304310
// CLI / runtime config
305311
export {
306312
defineExecutorConfig,

packages/react/src/pages/secrets.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,20 @@ const isSecretInUseError = Schema.is(SecretInUseError);
6262
// state always starts fresh — no manual reset.
6363
// ---------------------------------------------------------------------------
6464

65+
interface SecretPrefill {
66+
readonly name?: string;
67+
readonly secretId?: string;
68+
readonly provider?: string;
69+
}
70+
6571
function AddSecretDialog(props: {
6672
open: boolean;
6773
onOpenChange: (v: boolean) => void;
6874
description: string;
6975
storageOptions: readonly SecretStorageOption[];
7076
existingSecretIds: readonly string[];
7177
scopeId: ScopeId;
78+
prefill?: SecretPrefill;
7279
}) {
7380
return (
7481
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
@@ -79,6 +86,7 @@ function AddSecretDialog(props: {
7986
storageOptions={props.storageOptions}
8087
existingSecretIds={props.existingSecretIds}
8188
scopeId={props.scopeId}
89+
prefill={props.prefill}
8290
onClose={() => props.onOpenChange(false)}
8391
/>
8492
)}
@@ -91,13 +99,17 @@ function AddSecretDialogContent(props: {
9199
storageOptions: readonly SecretStorageOption[];
92100
existingSecretIds: readonly string[];
93101
scopeId: ScopeId;
102+
prefill?: SecretPrefill;
94103
onClose: () => void;
95104
}) {
96-
const initialProvider = props.storageOptions[0]?.value ?? "auto";
105+
const initialProvider =
106+
props.prefill?.provider ?? props.storageOptions[0]?.value ?? "auto";
97107

98108
return (
99109
<SecretForm.Provider
100110
existingSecretIds={props.existingSecretIds}
111+
suggestedName={props.prefill?.name}
112+
initialIdOverride={props.prefill?.secretId}
101113
initialProvider={initialProvider}
102114
scopeId={props.scopeId}
103115
onCreated={props.onClose}
@@ -232,14 +244,19 @@ export function SecretsPage(props: {
232244
addSecretDescription?: string;
233245
showProviderInfo?: boolean;
234246
storageOptions?: readonly SecretStorageOption[];
247+
/** Pre-fill values for the add-secret modal and auto-open it. Set by
248+
* the route when the URL carries `?openAdd=1&name=…&secretId=…`,
249+
* which is how the agent-facing `secrets.create` tool hands a user
250+
* off to this page. */
251+
prefill?: SecretPrefill;
235252
}) {
236253
const storageOptions = props.storageOptions ?? defaultStorageOptions;
237254
const showProviderInfo = props.showProviderInfo ?? true;
238255
const addSecretDescription =
239256
props.addSecretDescription ??
240257
"Store a credential or API key. Values are kept in your system keychain when available, with a local encrypted file fallback.";
241258
const secretProviderPlugins = useSecretProviderPlugins();
242-
const [addOpen, setAddOpen] = useState(false);
259+
const [addOpen, setAddOpen] = useState(props.prefill != null);
243260
const scopeId = useScope();
244261
const scopeStack = useScopeStack();
245262
const secrets = useAtomValue(secretsOptimisticAtom(scopeId));
@@ -395,6 +412,7 @@ export function SecretsPage(props: {
395412
storageOptions={storageOptions}
396413
existingSecretIds={existingSecretIds}
397414
scopeId={scopeId}
415+
prefill={props.prefill}
398416
/>
399417
</div>
400418
</div>

0 commit comments

Comments
 (0)