History

Apps console: source management UI and e2e coverage (#1365)

* Add console support for custom app tools * Only claim unambiguous git URLs in connect-dialog detection * Add custom tools console recording * Omit the required key when schema projection empties it * Assert projection output stays JSON-encodable without JSON.parse * Derive app tool schemas via Standard Schema * Cover zod schemas in custom tools e2e * Pass the required target option to Standard Schema converters * Make app sources top-level integrations * Limit local directory app sources to local host * Add local directory picker endpoint * Update local app dependency lockfile * Cover local directory picker in local e2e * Fix CLI node ambient types * Cover custom tools on cloud target * Complete local custom tools picker e2e * Require auth in custom tools git e2e * Exercise custom tools run tab * Cover custom tools collect failure recovery * Handle cloud auth status in custom tools e2e * Show local app source shape in directory picker * Tighten directory picker e2e tag assertion * Remove implicit GitHub connection token from git sources * Package workerd for local binaries * Trust bundled root certificates in workerd outbound TLS workerd's built-in internet service cannot read the Windows system trust store, so npm installs inside the publish worker failed TLS verification. Declare the internet service explicitly with the runtime's bundled Mozilla roots, surface workerd stderr in startup failures, and fail the bundle when a dependency install warning is present instead of publishing a broken bundle. * Add packed-binary custom tools e2e scenarios The new CLI VM scenario proves the compiled executor binary can publish and invoke local-directory custom tools inside macOS, Linux, and Windows guests. The desktop VM scenario proves the packaged sidecar daemon does the same on desktop bundles, and these manual-run VM projects remain outside the default CI chain by design.

Rhys Sullivan committed 4 months ago

Commit 28fdd311 parent f59577164 files · +4946 −290Browse files

Unified · Split

64 files
  1. apps/cli/package.json +1 −0
  2. apps/cli/scripts/smoke-packed-apps.ts +192 −0
  3. apps/cli/src/build.ts +92 −1
  4. apps/cli/src/native-bindings.ts +19 −5
  5. apps/cli/tsconfig.json +1 −1
  6. apps/cloud/executor.config.ts +2 −0
  7. apps/desktop/src/sidecar/native-bindings.ts +18 −5
  8. apps/host-selfhost/executor.config.ts +6 −2
  9. apps/host-selfhost/src/boot.test.ts +78 −44
  10. apps/host-selfhost/src/testing/test-app.ts +20 −1
  11. apps/local/executor.config.ts +11 −0
  12. apps/local/package.json +1 −0
  13. bun.lock +16 −0
  14. e2e/cli/custom-tools-packed.test.ts +264 −0
  15. e2e/desktop-vm/custom-tools-sidecar.test.ts +228 −0
  16. e2e/fixtures/custom-tools-git/custom-tools-shas.txt +3 −0
  17. e2e/fixtures/custom-tools-git/custom-tools-v1.pack +0 −0
  18. e2e/fixtures/custom-tools-git/custom-tools-v2.pack +0 −0
  19. e2e/fixtures/custom-tools-git/custom-tools-v3.pack +0 −0
  20. e2e/local/custom-tools-directory-picker.test.ts +115 −0
  21. e2e/recordings/custom-tools-console.mp4 +0 −0
  22. e2e/scenarios/custom-tools.test.ts +520 −0
  23. e2e/setup/desktop-linux.globalsetup.ts +1 −0
  24. e2e/setup/desktop-macos.globalsetup.ts +1 −0
  25. packages/core/sdk/src/connections.test.ts +62 −1
  26. packages/core/sdk/src/executor.ts +5 −1
  27. packages/kernel/runtime-workerd-subprocess/src/index.test.ts +41 −1
  28. packages/kernel/runtime-workerd-subprocess/src/index.ts +125 −30
  29. packages/plugins/apps/package.json +25 −1
  30. packages/plugins/apps/src/api.ts +4 −4
  31. packages/plugins/apps/src/executor/app-tool-executor.ts +40 −10
  32. packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts +21 −6
  33. packages/plugins/apps/src/executor/workerd-app-tool-executor.ts +23 −8
  34. packages/plugins/apps/src/git-client/url-security.ts +8 −2
  35. packages/plugins/apps/src/pipeline/discover.ts +12 −3
  36. packages/plugins/apps/src/pipeline/publish.test.ts +29 −1
  37. packages/plugins/apps/src/pipeline/worker-bundler.test.ts +5 −0
  38. packages/plugins/apps/src/pipeline/worker-bundler.ts +25 −14
  39. packages/plugins/apps/src/plugin/apps-plugin.test.ts +369 −35
  40. packages/plugins/apps/src/plugin/apps-plugin.ts +215 −70
  41. packages/plugins/apps/src/plugin/handlers.ts +64 −1
  42. packages/plugins/apps/src/plugin/routes.ts +38 −1
  43. packages/plugins/apps/src/plugin/store.test.ts +21 −0
  44. packages/plugins/apps/src/plugin/store.ts +53 −1
  45. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +459 −0
  46. packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx +292 −0
  47. packages/plugins/apps/src/react/custom-tools-client.ts +375 −0
  48. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +267 −0
  49. packages/plugins/apps/src/react/index.ts +2 −0
  50. packages/plugins/apps/src/react/plugin-client.tsx +16 −0
  51. packages/plugins/apps/src/react/source-panel-model.ts +156 −0
  52. packages/plugins/apps/src/react/source-plugin.tsx +36 −0
  53. packages/plugins/apps/src/source/app-source.test.ts +100 −2
  54. packages/plugins/apps/src/source/local-directory-source.ts +162 −4
  55. packages/plugins/apps/src/testing/conformance.ts +97 −0
  56. packages/plugins/apps/tsconfig.json +2 −1
  57. packages/react/src/components/json-schema-form.tsx +31 −8
  58. packages/react/src/components/tool-run-panel.test.ts +18 −1
  59. packages/react/src/components/tool-run-panel.tsx +27 −22
  60. packages/react/src/lib/integration-detail-tabs.ts +16 −0
  61. packages/react/src/pages/integration-add.tsx +11 −1
  62. packages/react/src/pages/integration-detail.tsx +41 −5
  63. packages/react/src/pages/integrations.tsx +52 −0
  64. packages/react/src/routes/integrations.$namespace.tsx +16 −1
apps/cli/package.json+1−0
@@ -37,6 +37,7 @@ · 36 unmodified lines
3737 },
3838 "devDependencies": {
3939 "@effect/vitest": "catalog:",
40+ "@types/node": "catalog:",
4041 "bun-types": "catalog:",
4142 "typescript": "catalog:",
4243 "vitest": "catalog:"
apps/cli/scripts/smoke-packed-apps.ts+192−0
@@ -0,0 +1,192 @@
1+/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- boundary: standalone build smoke harness tears down a spawned packed binary */
2+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
4+import { join, resolve } from "node:path";
5+import { spawn, type Subprocess } from "bun";
6+
7+const binary = process.argv[2];
8+if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts <packed-binary>");
9+
10+const AUTH_TOKEN = "packed-apps-smoke-token";
11+const READY_TIMEOUT_MS = 45_000;
12+
13+const fail = (message: string): never => {
14+ throw new Error(`[smoke-packed-apps] FAIL: ${message}`);
15+};
16+
17+const requestJson = async <T>(origin: string, path: string, init: RequestInit = {}): Promise<T> => {
18+ const response = await fetch(new URL(path, origin), {
19+ ...init,
20+ headers: {
21+ authorization: `Bearer ${AUTH_TOKEN}`,
22+ ...(init.body === undefined ? {} : { "content-type": "application/json" }),
23+ ...init.headers,
24+ },
25+ });
26+ const text = await response.text();
27+ if (!response.ok) fail(`${init.method ?? "GET"} ${path} returned ${response.status}: ${text}`);
28+ return (text.length > 0 ? JSON.parse(text) : null) as T;
29+};
30+
31+const waitForReadyPort = async (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> =>
32+ new Promise((resolveReady, rejectReady) => {
33+ const timer = setTimeout(
34+ () => rejectReady(new Error("packed binary did not report ready in time")),
35+ READY_TIMEOUT_MS,
36+ );
37+ let stdout = "";
38+ let stderr = "";
39+ const finish = (result: number | Error): void => {
40+ clearTimeout(timer);
41+ if (result instanceof Error) rejectReady(result);
42+ else resolveReady(result);
43+ };
44+ void proc.exited.then((code) => {
45+ finish(new Error(`packed binary exited before ready with code ${code}: ${stderr}`));
46+ });
47+ void (async () => {
48+ const reader = proc.stdout.getReader();
49+ const decoder = new TextDecoder();
50+ try {
51+ while (true) {
52+ const chunk = await reader.read();
53+ if (chunk.done) return;
54+ stdout += decoder.decode(chunk.value);
55+ const match = stdout.match(/EXECUTOR_READY:(\d+)/);
56+ if (match) {
57+ finish(Number(match[1]));
58+ return;
59+ }
60+ }
61+ } catch (cause) {
62+ finish(cause instanceof Error ? cause : new Error(String(cause)));
63+ }
64+ })();
65+ void (async () => {
66+ const reader = proc.stderr.getReader();
67+ const decoder = new TextDecoder();
68+ while (true) {
69+ const chunk = await reader.read();
70+ if (chunk.done) return;
71+ stderr += decoder.decode(chunk.value);
72+ }
73+ })().catch(() => undefined);
74+ });
75+
76+const main = async (): Promise<void> => {
77+ const fixture = await mkdtemp(join(tmpdir(), "executor-packed-apps-fixture-"));
78+ const dataDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-data-"));
79+ const scopeDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-scope-"));
80+ let proc: Subprocess<"ignore", "pipe", "pipe"> | null = null;
81+ try {
82+ await mkdir(join(fixture, "tools"), { recursive: true });
83+ await writeFile(
84+ join(fixture, "tools", "greet.ts"),
85+ `
86+ import { z } from "zod";
87+ import { defineTool } from "executor:app";
88+
89+ const Input = z.object({ name: z.string() });
90+
91+ export default defineTool({
92+ description: "Greet",
93+ input: Input,
94+ async handler(input) {
95+ return { greeting: "hello " + input.name };
96+ },
97+ });
98+ `,
99+ );
100+ await writeFile(
101+ join(fixture, "package.json"),
102+ `${JSON.stringify({ name: "packed-apps-smoke", dependencies: { zod: "4.3.6" } })}\n`,
103+ );
104+ await writeFile(join(fixture, "bun.lock"), "");
105+
106+ proc = spawn({
107+ cmd: [
108+ resolve(binary),
109+ "daemon",
110+ "run",
111+ "--foreground",
112+ "--port",
113+ "0",
114+ "--hostname",
115+ "127.0.0.1",
116+ "--auth-token",
117+ AUTH_TOKEN,
118+ ],
119+ env: {
120+ ...process.env,
121+ EXECUTOR_CLIENT: "desktop",
122+ EXECUTOR_DATA_DIR: dataDir,
123+ EXECUTOR_SCOPE_DIR: scopeDir,
124+ },
125+ stdin: "ignore",
126+ stdout: "pipe",
127+ stderr: "pipe",
128+ });
129+
130+ const port = await waitForReadyPort(proc);
131+ const origin = `http://127.0.0.1:${port}`;
132+ console.log(`[smoke-packed-apps] ready on ${origin}`);
133+
134+ await requestJson(origin, "/api/apps/sources", {
135+ method: "POST",
136+ body: JSON.stringify({
137+ kind: "local-directory",
138+ slug: "packed-apps-smoke",
139+ app: "packed-apps-smoke",
140+ path: fixture,
141+ }),
142+ });
143+
144+ const sync = await requestJson<{
145+ readonly status: string;
146+ readonly tools: readonly string[];
147+ readonly errors?: readonly unknown[];
148+ }>(origin, "/api/apps/sources/packed-apps-smoke/sync", { method: "POST" });
149+ if (sync.status !== "published") fail(`sync failed: ${JSON.stringify(sync.errors)}`);
150+ if (JSON.stringify(sync.tools) !== JSON.stringify(["greet"])) {
151+ fail(`expected greet tool, got ${JSON.stringify(sync.tools)}`);
152+ }
153+
154+ const invoked = await requestJson<{
155+ readonly status: string;
156+ readonly structured: unknown;
157+ readonly isError: boolean;
158+ readonly text: string;
159+ }>(origin, "/api/executions", {
160+ method: "POST",
161+ body: JSON.stringify({
162+ code: `export default await tools["packed-apps-smoke.org.published.greet"]({ name: "Ada" })`,
163+ autoApprove: true,
164+ }),
165+ });
166+ if (invoked.status !== "completed" || invoked.isError) {
167+ fail(`execution failed: ${JSON.stringify(invoked)}`);
168+ }
169+ if (
170+ JSON.stringify(invoked.structured) !==
171+ JSON.stringify({
172+ status: "completed",
173+ result: { ok: true, data: { greeting: "hello Ada" } },
174+ logs: [],
175+ })
176+ ) {
177+ fail(`unexpected execution result: ${JSON.stringify(invoked)}`);
178+ }
179+ console.log("[smoke-packed-apps] OK - local-directory app source bundled and invoked");
180+ } finally {
181+ if (proc) {
182+ proc.kill("SIGTERM");
183+ await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 3000))]);
184+ proc.kill("SIGKILL");
185+ }
186+ await rm(fixture, { recursive: true, force: true }).catch(() => undefined);
187+ await rm(dataDir, { recursive: true, force: true }).catch(() => undefined);
188+ await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined);
189+ }
190+};
191+
192+await main();
apps/cli/src/build.ts+92−1
@@ -2,7 +2,7 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line
22 import { existsSync } from "node:fs";
33 import { createRequire } from "node:module";
44 import { resolve, join, dirname } from "node:path";
5−import { fileURLToPath } from "node:url";
5+import { fileURLToPath, pathToFileURL } from "node:url";
66 import { parseArgs } from "node:util";
77 import { $ } from "bun";
88
@@ -11,6 +11,8 @@ const cliRoot = resolve(repoRoot, "apps/cli"); · 2 unmodified lines
1111 const webRoot = resolve(repoRoot, "apps/local");
1212 const distDir = resolve(cliRoot, "dist");
1313 const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";
14+const WORKERD_VERSION = "1.20260708.1";
15+const WORKER_BUNDLER_DIRNAME = "worker-bundler";
1416
1517 const resolveQuickJsWasmPath = (): string => {
1618 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
@@ -33,6 +35,34 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 16 unmodified lines
3335 return wasmPath;
3436 };
3537
38+const resolveWorkerBundlerDistPath = (): string => {
39+ const req = createRequire(join(repoRoot, "packages/plugins/apps/package.json"));
40+ const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json");
41+ const distPath = join(dirname(pkgJson), "dist");
42+ if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) {
43+ throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`);
44+ }
45+ return distPath;
46+};
47+
48+const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => {
49+ const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js");
50+ if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`);
51+ const { build } = await import(pathToFileURL(esbuildPath).href);
52+ const result = await build({
53+ entryPoints: [join(distPath, "index.js")],
54+ bundle: true,
55+ format: "esm",
56+ platform: "browser",
57+ external: ["./esbuild.wasm"],
58+ logLevel: "silent",
59+ write: false,
60+ });
61+ const source = result.outputFiles[0]?.text;
62+ if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
63+ return source;
64+};
65+
3666 // ---------------------------------------------------------------------------
3767 // Metadata
3868 // ---------------------------------------------------------------------------
@@ -119,6 +149,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines
119149 };
120150
121151 const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");
152+const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd");
122153
123154 const isCurrentPlatform = (t: Target) =>
124155 t.os === process.platform && t.arch === process.arch && !t.abi;
@@ -211,6 +242,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines
211242 }
212243 };
213244
245+const resolveWorkerdBinary = (t: Target): string | null => {
246+ const platformMap: Record<string, string> = {
247+ "darwin-arm64": "@cloudflare/workerd-darwin-arm64",
248+ "darwin-x64": "@cloudflare/workerd-darwin-64",
249+ "linux-arm64": "@cloudflare/workerd-linux-arm64",
250+ "linux-x64": "@cloudflare/workerd-linux-64",
251+ "win32-x64": "@cloudflare/workerd-windows-64",
252+ };
253+ const key = [t.os, t.arch, t.abi].filter(Boolean).join("-");
254+ const pkg = platformMap[key];
255+ if (!pkg) return null;
256+ const binary = workerdBinaryName(t);
257+ try {
258+ const req = createRequire(
259+ join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"),
260+ );
261+ const pkgJson = req.resolve(`${pkg}/package.json`);
262+ for (const candidate of [
263+ join(dirname(pkgJson), "bin", binary),
264+ join(dirname(pkgJson), binary),
265+ ]) {
266+ if (existsSync(candidate)) return candidate;
267+ }
268+ } catch {
269+ const packageRoot = join(
270+ repoRoot,
271+ `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`,
272+ );
273+ for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) {
274+ if (existsSync(candidate)) return candidate;
275+ }
276+ }
277+ return null;
278+};
279+
214280 // ---------------------------------------------------------------------------
215281 // Build mode
216282 // ---------------------------------------------------------------------------
@@ -333,6 +399,8 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 116 unmodified lines
333399
334400 const quickJsWasmPath = resolveQuickJsWasmPath();
335401 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
402+ const workerBundlerDistPath = resolveWorkerBundlerDistPath();
403+ const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
336404
337405 try {
338406 for (const target of targets) {
@@ -375,12 +443,35 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 36 unmodified lines
375443 await cp(libsqlNative, join(binDir, "libsql.node"));
376444 }
377445
446+ const workerdBinary = resolveWorkerdBinary(target);
447+ if (workerdBinary && existsSync(workerdBinary)) {
448+ await cp(workerdBinary, join(binDir, workerdBinaryName(target)));
449+ if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755);
450+ }
451+
452+ await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), {
453+ recursive: true,
454+ });
455+ await writeFile(
456+ join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"),
457+ packedWorkerBundlerSource,
458+ );
459+
378460 // Smoke test on current platform
379461 if (isCurrentPlatform(target)) {
380462 const bin = join(binDir, binaryName(target));
381463 console.log(` Smoke test: ${bin} --version`);
382464 const version = await $`${bin} --version`.text();
383465 console.log(` OK: ${version.trim()}`);
466+ console.log(` Smoke test: packed apps source sync and invoke`);
467+ const smoke = Bun.spawn(
468+ ["bun", "run", join(cliRoot, "scripts/smoke-packed-apps.ts"), bin],
469+ {
470+ cwd: repoRoot,
471+ stdio: ["ignore", "inherit", "inherit"],
472+ },
473+ );
474+ if ((await smoke.exited) !== 0) throw new Error("packed apps smoke failed");
384475 }
385476
386477 // Variant package.json. All variants publish to the SAME npm package
apps/cli/src/native-bindings.ts+19−5
@@ -1,11 +1,10 @@
11 // ---------------------------------------------------------------------------
22 // Native-binding bootstrap for the `bun build --compile` binary.
33 //
4−// `bun --compile` bundles JS into bunfs but does NOT include `.node` native
5−// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside
6−// the binary fails. build.ts copies each platform's `.node` next to the
7−// executable (`libsql.node`, `keyring.node`); here we publish their on-disk
8−// paths via env vars the loaders read.
4+// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5+// wasm sidecars, or runtime-read package files. build.ts copies those artifacts
6+// next to the executable; here we publish their on-disk paths via env vars the
7+// loaders read.
98 //
109 // This MUST be the FIRST import in main.ts. ES modules evaluate every import
1110 // before the importer's own body, and libSQL resolves its native addon EAGERLY
@@ -43,3 +42,18 @@ if ( · 31 unmodified lines
4342 ) {
4443 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
4544 }
45+
46+const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
47+if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
48+ process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
49+}
50+
51+const workerBundlerOnDisk = join(execDir, "worker-bundler");
52+if (
53+ typeof Bun !== "undefined" &&
54+ !process.env.EXECUTOR_WORKER_BUNDLER_DIR &&
55+ existsSync(join(workerBundlerOnDisk, "dist", "index.js")) &&
56+ existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm"))
57+) {
58+ process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk;
59+}
apps/cli/tsconfig.json+1−1
@@ -8,7 +8,7 @@ · 7 unmodified lines
88 "skipLibCheck": true,
99 "noEmit": true,
1010 "declaration": true,
11− "types": ["bun-types"]
11+ "types": ["bun-types", "node"]
1212 },
1313 "include": ["src", "bin"]
1414 }
apps/cloud/executor.config.ts+2−0
@@ -75,6 +75,7 @@ export default defineExecutorConfig({ · 74 unmodified lines
7575 workosCredentials,
7676 workosVaultClient,
7777 activeToolkitSlug,
78+ allowLocalNetwork,
7879 workerLoader,
7980 }: CloudPluginDeps = {}) =>
8081 [
@@ -94,6 +95,7 @@ export default defineExecutorConfig({ · 13 unmodified lines
9495 }
9596 : {}),
9697 sourceKinds: ["git"],
98+ allowPrivateGitHosts: allowLocalNetwork === true,
9799 }),
98100 toolkitsPlugin({ activeToolkitSlug }),
99101 workosVaultPlugin({
apps/desktop/src/sidecar/native-bindings.ts+18−5
@@ -1,11 +1,9 @@
11 // ---------------------------------------------------------------------------
22 // Native-binding bootstrap for manually compiled sidecar binaries.
33 //
4−// `bun --compile` bundles JS into bunfs but does NOT include `.node` native
5−// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside
6−// the binary fails. If this dev sidecar is compiled directly, copy each
7−// platform's `.node` next to the executable (`libsql.node`, `keyring.node`);
8−// here we publish their
4+// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5+// wasm sidecars, or runtime-read package files. If this dev sidecar is compiled
6+// directly, copy those artifacts next to the executable; here we publish their
97 // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts.
108 //
119 // This MUST be the FIRST import in server.ts. ES modules evaluate every import
@@ -44,3 +42,18 @@ if ( · 32 unmodified lines
4442 ) {
4543 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
4644 }
45+
46+const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
47+if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
48+ process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
49+}
50+
51+const workerBundlerOnDisk = join(execDir, "worker-bundler");
52+if (
53+ typeof Bun !== "undefined" &&
54+ !process.env.EXECUTOR_WORKER_BUNDLER_DIR &&
55+ existsSync(join(workerBundlerOnDisk, "dist", "index.js")) &&
56+ existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm"))
57+) {
58+ process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk;
59+}
apps/host-selfhost/executor.config.ts+6−2
@@ -32,10 +32,14 @@ import { resolveSecretKey } from "./src/config"; · 31 unmodified lines
3232
3333 interface SelfHostPluginDeps {
3434 readonly activeToolkitSlug?: string;
35+ readonly sourceKinds?: readonly ("git" | "local-directory")[];
36+ /** Accepted for test-harness parity; the Microsoft Graph URL override moved
37+ * into the OpenAPI provider presets, so the factory no longer reads it. */
38+ readonly allowLocalNetwork?: boolean;
3539 }
3640
3741 export default defineExecutorConfig({
38− plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>
42+ plugins: ({ activeToolkitSlug, sourceKinds }: SelfHostPluginDeps = {}) =>
3943 [
4044 openApiHttpPlugin({
4145 presets: [...googleCatalog, ...microsoftCatalog],
@@ -46,7 +50,7 @@ export default defineExecutorConfig({ · 4 unmodified lines
4650 appsHttpPlugin({
4751 executor: makeWorkerdAppToolExecutor(),
4852 bundler: makeWorkerBundlerBackend(),
49− sourceKinds: ["git", "local-directory"],
53+ sourceKinds: sourceKinds ?? ["git"],
5054 allowPrivateGitHosts: true,
5155 }),
5256 toolkitsPlugin({ activeToolkitSlug }),
apps/host-selfhost/src/boot.test.ts+78−44
@@ -98,7 +98,36 @@ test("POST /executions runs code in the QuickJS sandbox", async () => { · 97 unmodified lines
9898 expect(body.isError).toBe(false);
9999 });
100100
101+test("selfhost rejects local-directory app sources over HTTP", async () => {
102+ const created = await handler(
103+ new Request("http://localhost/api/apps/sources", {
104+ method: "POST",
105+ headers: { "content-type": "application/json" },
106+ body: JSON.stringify({
107+ kind: "local-directory",
108+ slug: "local-greeter-disabled",
109+ app: "local-greeter-disabled",
110+ path: "/tmp/local-greeter-disabled",
111+ }),
112+ }),
113+ );
114+ expect(created.status).toBe(500);
115+
116+ const dirs = await handler(new Request("http://localhost/api/apps/fs/dirs"));
117+ expect(dirs.status).toBe(404);
118+});
119+
101120 test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => {
121+ const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app");
122+ const localDirectoryApp = await makeSelfHostTestApp({
123+ identity: singleAdminIdentityLayer({
124+ userId: "admin-local-directory",
125+ organizationId: "default-org-local-directory",
126+ organizationName: "Default Local Directory",
127+ }),
128+ // This test exercises the plugin loop, not the selfhost product posture.
129+ pluginDeps: { sourceKinds: ["git", "local-directory"] },
130+ });
102131 const root = mkdtempSync(join(tmpdir(), "eh-app-src-"));
103132 mkdirSync(join(root, "tools"), { recursive: true });
104133 writeFileSync(
@@ -121,51 +150,56 @@ test("apps source sync publishes and invokes a local-directory tool over HTTP", · 16 unmodified lines
121150 );
122151 writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" }));
123152
124− const created = await handler(
125− new Request("http://localhost/api/apps/sources", {
126− method: "POST",
127− headers: { "content-type": "application/json" },
128− body: JSON.stringify({
129− kind: "local-directory",
130− slug: "local-greeter",
131− app: "local-greeter",
132− path: root,
153+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test fixture must dispose the booted app after assertions
154+ try {
155+ const created = await localDirectoryApp.handler(
156+ new Request("http://localhost/api/apps/sources", {
157+ method: "POST",
158+ headers: { "content-type": "application/json" },
159+ body: JSON.stringify({
160+ kind: "local-directory",
161+ slug: "local-greeter",
162+ app: "local-greeter",
163+ path: root,
164+ }),
133165 }),
134− }),
135− );
136− expect(created.status).toBe(200);
166+ );
167+ expect(created.status).toBe(200);
137168
138− const synced = await handler(
139− new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }),
140− );
141− expect(synced.status).toBe(200);
142− const syncBody = (await synced.json()) as {
143− readonly status: string;
144− readonly tools: readonly string[];
145− readonly errors?: readonly unknown[];
146− };
147− expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published");
148− expect(syncBody.tools).toEqual(["greeter"]);
149−
150− const listed = await handler(new Request("http://localhost/api/tools"));
151− expect(listed.status).toBe(200);
152− const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>;
153− expect(
154− tools.some((tool) => tool.address === "tools.apps.org.published.greeter"),
155− JSON.stringify(tools.map((tool) => tool.address)),
156− ).toBe(true);
157−
158− const invoked = await handler(
159− new Request("http://localhost/api/executions", {
160− method: "POST",
161− headers: { "content-type": "application/json" },
162− body: JSON.stringify({
163− code: 'export default await tools.apps.org.published.greeter({ name: "Ada" })',
169+ const synced = await localDirectoryApp.handler(
170+ new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }),
171+ );
172+ expect(synced.status).toBe(200);
173+ const syncBody = (await synced.json()) as {
174+ readonly status: string;
175+ readonly tools: readonly string[];
176+ readonly errors?: readonly unknown[];
177+ };
178+ expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published");
179+ expect(syncBody.tools).toEqual(["greeter"]);
180+
181+ const listed = await localDirectoryApp.handler(new Request("http://localhost/api/tools"));
182+ expect(listed.status).toBe(200);
183+ const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>;
184+ expect(
185+ tools.some((tool) => tool.address === "tools.local-greeter.org.published.greeter"),
186+ JSON.stringify(tools.map((tool) => tool.address)),
187+ ).toBe(true);
188+
189+ const invoked = await localDirectoryApp.handler(
190+ new Request("http://localhost/api/executions", {
191+ method: "POST",
192+ headers: { "content-type": "application/json" },
193+ body: JSON.stringify({
194+ code: 'export default await tools["local-greeter.org.published.greeter"]({ name: "Ada" })',
195+ }),
164196 }),
165− }),
166− );
167− expect(invoked.status).toBe(200);
168− const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean };
169− expect(output.isError).toBe(false);
170− expect(output.text).toContain("hello Ada");
197+ );
198+ expect(invoked.status).toBe(200);
199+ const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean };
200+ expect(output.isError).toBe(false);
201+ expect(output.text).toContain("hello Ada");
202+ } finally {
203+ await localDirectoryApp.dispose();
204+ }
171205 });
apps/host-selfhost/src/testing/test-app.ts+20−1
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 composePluginApi,
66 ExecutorApp,
77 IdentityProvider,
8+ PluginsProvider,
89 type Principal,
910 textFailureStrategy,
1011 Unauthorized,
@@ -22,6 +23,7 @@ import { · 11 unmodified lines
2223 SelfHostHostConfig,
2324 SelfHostPluginsProvider,
2425 } from "../execution";
26+import executorConfig from "../../executor.config";
2527 import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
2628 import {
2729 makeSelfHostMcpSessionStore,
@@ -165,6 +167,8 @@ export interface MakeSelfHostTestAppOptions { · 137 unmodified lines
165167 readonly identity: Layer.Layer<IdentityProvider>;
166168 /** Override the SQLite path (defaults to the config data dir). */
167169 readonly dbPath?: string;
170+ /** Test-only override for executor.config.ts plugin dependencies. */
171+ readonly pluginDeps?: Parameters<typeof executorConfig.plugins>[0];
168172 }
169173
170174 export interface SelfHostTestHandler {
@@ -185,6 +189,21 @@ export const makeSelfHostTestApp = async ( · 14 unmodified lines
185189 });
186190
187191 const sessionStore = makeSelfHostMcpSessionStore(dbHandle);
192+ const pluginsProvider = options.pluginDeps
193+ ? Layer.succeed(PluginsProvider)({
194+ plugins: (context) =>
195+ executorConfig.plugins({
196+ ...options.pluginDeps,
197+ activeToolkitSlug:
198+ context?.mcpResource?.kind === "toolkit"
199+ ? context.mcpResource.slug
200+ : options.pluginDeps?.activeToolkitSlug,
201+ allowLocalNetwork:
202+ options.pluginDeps?.allowLocalNetwork ??
203+ process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
204+ }),
205+ })
206+ : SelfHostPluginsProvider;
188207
189208 const { toWebHandler } = ExecutorApp.make({
190209 plugins: selfHostPlugins,
@@ -197,7 +216,7 @@ export const makeSelfHostTestApp = async ( · 6 unmodified lines
197216 sessions: selfHostMcpSessions(sessionStore),
198217 reporter: selfHostMcpReporter,
199218 },
200− plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
219+ plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
201220 errorCapture: ErrorCaptureLive,
202221 },
203222 extensions: {
apps/local/executor.config.ts+11−0
@@ -1,4 +1,9 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
2+import {
3+ makeWorkerBundlerBackend,
4+ makeWorkerdAppToolExecutor,
5+} from "@executor-js/plugin-apps/selfhost";
6+import { appsHttpPlugin } from "@executor-js/plugin-apps/api";
27 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
38 import {
49 googleCatalog,
@@ -38,6 +43,12 @@ export default defineExecutorConfig({ · 33 unmodified lines
3843 }),
3944 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
4045 graphqlHttpPlugin(),
46+ appsHttpPlugin({
47+ executor: makeWorkerdAppToolExecutor(),
48+ bundler: makeWorkerBundlerBackend(),
49+ sourceKinds: ["git", "local-directory"],
50+ allowPrivateGitHosts: true,
51+ }),
4152 toolkitsPlugin({ activeToolkitSlug }),
4253 keychainPlugin(),
4354 fileSecretsPlugin(),
apps/local/package.json+1−0
@@ -29,6 +29,7 @@ · 28 unmodified lines
2929 "@executor-js/fumadb": "workspace:*",
3030 "@executor-js/host-mcp": "workspace:*",
3131 "@executor-js/integrations-registry": "workspace:*",
32+ "@executor-js/plugin-apps": "workspace:*",
3233 "@executor-js/plugin-desktop-settings": "workspace:*",
3334 "@executor-js/plugin-example": "workspace:*",
3435 "@executor-js/plugin-file-secrets": "workspace:*",
bun.lock+16−0
@@ -50,6 +50,7 @@ · 49 unmodified lines
5050 },
5151 "devDependencies": {
5252 "@effect/vitest": "catalog:",
53+ "@types/node": "catalog:",
5354 "bun-types": "catalog:",
5455 "typescript": "catalog:",
5556 "vitest": "catalog:",
@@ -270,6 +271,7 @@ · 214 unmodified lines
270271 "@executor-js/fumadb": "workspace:*",
271272 "@executor-js/host-mcp": "workspace:*",
272273 "@executor-js/integrations-registry": "workspace:*",
274+ "@executor-js/plugin-apps": "workspace:*",
273275 "@executor-js/plugin-desktop-settings": "workspace:*",
274276 "@executor-js/plugin-example": "workspace:*",
275277 "@executor-js/plugin-file-secrets": "workspace:*",
@@ -801,16 +803,30 @@ · 525 unmodified lines
801803 "zod": "4.3.6",
802804 },
803805 "devDependencies": {
806+ "@effect/atom-react": "catalog:",
804807 "@effect/vitest": "catalog:",
808+ "@executor-js/react": "workspace:*",
805809 "@types/node": "catalog:",
810+ "@types/react": "catalog:",
806811 "bun-types": "catalog:",
807812 "effect": "catalog:",
813+ "react": "catalog:",
808814 "tsup": "catalog:",
809815 "vitest": "catalog:",
810816 },
811817 "peerDependencies": {
818+ "@effect/atom-react": "catalog:",
819+ "@executor-js/react": "workspace:*",
820+ "@tanstack/react-router": "catalog:",
812821 "effect": "catalog:",
822+ "react": "catalog:",
813823 },
824+ "optionalPeers": [
825+ "@effect/atom-react",
826+ "@executor-js/react",
827+ "@tanstack/react-router",
828+ "react",
829+ ],
814830 },
815831 "packages/plugins/desktop-settings": {
816832 "name": "@executor-js/plugin-desktop-settings",
e2e/cli/custom-tools-packed.test.ts+264−0
@@ -0,0 +1,264 @@
1+/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */
2+// Proves the compiled executor binary's bundled workerd + worker-bundler path
3+// can package local-directory custom tools on every CLI guest OS. The sync
4+// assertion covers npm install inside workerd, including the Windows TLS trust
5+// path fixed by f0365449, and the execution assertion proves the published tool
6+// is invoked, not just synced.
7+import { execFile } from "node:child_process";
8+import { promisify } from "node:util";
9+
10+import { expect } from "@effect/vitest";
11+import { Effect } from "effect";
12+
13+import { scenario } from "../src/scenario";
14+import { Target } from "../src/services";
15+import type { Identity, Target as TargetShape } from "../src/target";
16+
17+const execFileAsync = promisify(execFile);
18+
19+const SSH_OPTS = [
20+ "-o",
21+ "StrictHostKeyChecking=no",
22+ "-o",
23+ "UserKnownHostsFile=/dev/null",
24+ "-o",
25+ "ConnectTimeout=15",
26+ "-o",
27+ "ServerAliveInterval=5",
28+ "-o",
29+ "LogLevel=ERROR",
30+] as const;
31+
32+type GuestOs = "macos" | "linux" | "windows";
33+
34+interface SyncResponse {
35+ readonly status: "published" | "up-to-date" | "failed";
36+ readonly tools: readonly string[];
37+}
38+
39+interface ExecuteResponse {
40+ readonly structured: unknown;
41+ readonly text: string;
42+}
43+
44+const guestOs = (): GuestOs => {
45+ const os = process.env.E2E_VM_OS;
46+ if (os === "macos" || os === "linux" || os === "windows") return os;
47+ throw new Error(`Unsupported E2E_VM_OS: ${os ?? "<unset>"}`);
48+};
49+
50+const sshInvocation = (command: string): { command: string; args: ReadonlyArray<string> } => {
51+ const host = process.env.E2E_CLI_VM_HOST;
52+ if (!host) throw new Error("E2E_CLI_VM_HOST is not set");
53+ const os = guestOs();
54+ const wrapped =
55+ os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command;
56+ const keyPath = process.env.E2E_CLI_SSH_KEY;
57+ const user = os === "windows" ? "Administrator" : "admin";
58+ return keyPath
59+ ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] }
60+ : {
61+ command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass",
62+ args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped],
63+ };
64+};
65+
66+const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => {
67+ const invocation = sshInvocation(command);
68+ try {
69+ const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], {
70+ maxBuffer: 64 * 1024 * 1024,
71+ });
72+ return { stdout, stderr, code: 0 };
73+ } catch (error) {
74+ const err = error as { stdout?: string; stderr?: string; code?: number };
75+ return {
76+ stdout: err.stdout ?? "",
77+ stderr: err.stderr ?? "",
78+ code: typeof err.code === "number" ? err.code : 1,
79+ };
80+ }
81+};
82+
83+const fixturePath = (slug: string): string =>
84+ guestOs() === "windows"
85+ ? `C:/Users/Administrator/apps-packed-fixture-${slug}`
86+ : `/tmp/apps-packed-fixture-${slug}`;
87+
88+const windowsFixtureCommand = (path: string): string => `
89+$ErrorActionPreference = 'Stop'
90+Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue
91+New-Item -ItemType Directory -Force -Path '${path}/tools' | Out-Null
92+Set-Content -NoNewline -Encoding UTF8 -Path '${path}/tools/greet.ts' -Value @'
93+${"import"} { z } from "zod";
94+${"import"} { defineTool } from "executor:app";
95+
96+const Input = z.object({ name: z.string() });
97+
98+export default defineTool({
99+ description: "Greet",
100+ input: Input,
101+ async handler(input) {
102+ return { greeting: "hello " + input.name };
103+ },
104+});
105+'@
106+Set-Content -NoNewline -Encoding UTF8 -Path '${path}/package.json' -Value '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}'
107+Set-Content -NoNewline -Encoding UTF8 -Path '${path}/bun.lock' -Value ''
108+`;
109+
110+const unixFixtureCommand = (path: string): string => `
111+set -eu
112+rm -rf '${path}'
113+mkdir -p '${path}/tools'
114+cat >'${path}/tools/greet.ts' <<'EOF'
115+${"import"} { z } from "zod";
116+${"import"} { defineTool } from "executor:app";
117+
118+const Input = z.object({ name: z.string() });
119+
120+export default defineTool({
121+ description: "Greet",
122+ input: Input,
123+ async handler(input) {
124+ return { greeting: "hello " + input.name };
125+ },
126+});
127+EOF
128+printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json'
129+: >'${path}/bun.lock'
130+`;
131+
132+const writeFixture = async (path: string): Promise<void> => {
133+ const result = await ssh(
134+ guestOs() === "windows" ? windowsFixtureCommand(path) : unixFixtureCommand(path),
135+ );
136+ expect(
137+ result.code,
138+ `fixture is written into the guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
139+ ).toBe(0);
140+};
141+
142+const removeFixture = (path: string): Effect.Effect<void> =>
143+ Effect.promise(() =>
144+ ssh(
145+ guestOs() === "windows"
146+ ? `Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue`
147+ : `rm -rf '${path}'`,
148+ ).then(() => undefined),
149+ ).pipe(Effect.ignore);
150+
151+const request = async <T>(
152+ target: TargetShape,
153+ identity: Identity,
154+ path: string,
155+ init: RequestInit = {},
156+ expectedStatus = 200,
157+ timeoutMs = 30_000,
158+): Promise<T> => {
159+ const controller = new AbortController();
160+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
161+ try {
162+ const headers = new Headers(init.headers);
163+ for (const [name, value] of Object.entries(identity.headers ?? {})) headers.set(name, value);
164+ if (init.body !== undefined && !headers.has("content-type")) {
165+ headers.set("content-type", "application/json");
166+ }
167+ const response = await fetch(new URL(path, target.baseUrl), {
168+ ...init,
169+ headers,
170+ signal: controller.signal,
171+ });
172+ const text = await response.text();
173+ expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus);
174+ return (text.length > 0 ? JSON.parse(text) : null) as T;
175+ } finally {
176+ clearTimeout(timeout);
177+ }
178+};
179+
180+const postJson = <T>(
181+ target: TargetShape,
182+ identity: Identity,
183+ path: string,
184+ body?: unknown,
185+ timeoutMs?: number,
186+): Promise<T> =>
187+ request<T>(
188+ target,
189+ identity,
190+ path,
191+ {
192+ method: "POST",
193+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
194+ },
195+ 200,
196+ timeoutMs,
197+ );
198+
199+const deleteSource = (target: TargetShape, identity: Identity, slug: string): Effect.Effect<void> =>
200+ Effect.promise(() =>
201+ request(target, identity, `/api/apps/sources/${slug}`, { method: "DELETE" }).then(
202+ () => undefined,
203+ ),
204+ ).pipe(Effect.ignore);
205+
206+const syncAndInvoke = (input: {
207+ readonly target: TargetShape;
208+ readonly identity: Identity;
209+ readonly slug: string;
210+ readonly path: string;
211+}): Effect.Effect<void> =>
212+ Effect.promise(async () => {
213+ await postJson(input.target, input.identity, "/api/apps/sources", {
214+ kind: "local-directory",
215+ slug: input.slug,
216+ app: input.slug,
217+ path: input.path,
218+ });
219+
220+ const sync = await postJson<SyncResponse>(
221+ input.target,
222+ input.identity,
223+ `/api/apps/sources/${input.slug}/sync`,
224+ undefined,
225+ 180_000,
226+ );
227+ expect(sync.status, "the packed binary bundled and published the tool").toBe("published");
228+ expect(sync.tools, "the packed binary bundled and published the tool").toContain("greet");
229+
230+ const execution = await postJson<ExecuteResponse>(
231+ input.target,
232+ input.identity,
233+ "/api/executions",
234+ {
235+ code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`,
236+ autoApprove: true,
237+ },
238+ );
239+ expect(
240+ JSON.stringify(execution.structured ?? execution.text),
241+ "the published tool executes and returns the greeting",
242+ ).toContain('"greeting":"hello Ada"');
243+ });
244+
245+scenario(
246+ "CLI packed binary · local-directory custom tool publishes and executes",
247+ { timeout: 300_000 },
248+ Effect.gen(function* () {
249+ const target = yield* Target;
250+ const identity = yield* target.newIdentity();
251+ const slug = `packed-smoke-${crypto.randomUUID().slice(0, 8)}`;
252+ const path = fixturePath(slug);
253+
254+ yield* Effect.promise(() => writeFixture(path)).pipe(
255+ Effect.andThen(syncAndInvoke({ target, identity, slug, path })),
256+ Effect.ensuring(
257+ Effect.all([deleteSource(target, identity, slug), removeFixture(path)], {
258+ concurrency: "unbounded",
259+ discard: true,
260+ }),
261+ ),
262+ );
263+ }),
264+);
e2e/desktop-vm/custom-tools-sidecar.test.ts+228−0
@@ -0,0 +1,228 @@
1+/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */
2+// Proves the packaged desktop bundle's sidecar daemon can package and invoke a
3+// local-directory custom tool from inside the GUI guest. The API calls run over
4+// guest loopback so the daemon reads the same guest filesystem as the fixture.
5+import { execFile } from "node:child_process";
6+import { promisify } from "node:util";
7+
8+import { expect, it } from "@effect/vitest";
9+import { Effect } from "effect";
10+
11+import { scenario } from "../src/scenario";
12+
13+const execFileAsync = promisify(execFile);
14+
15+const SSH_OPTS = [
16+ "-o",
17+ "StrictHostKeyChecking=no",
18+ "-o",
19+ "UserKnownHostsFile=/dev/null",
20+ "-o",
21+ "ConnectTimeout=15",
22+ "-o",
23+ "ServerAliveInterval=5",
24+ "-o",
25+ "LogLevel=ERROR",
26+] as const;
27+
28+type DesktopOs = "macos" | "linux" | "windows";
29+
30+interface CurlResult<T> {
31+ readonly status: number;
32+ readonly body: T;
33+ readonly text: string;
34+}
35+
36+interface SyncResponse {
37+ readonly status: "published" | "up-to-date" | "failed";
38+ readonly tools: readonly string[];
39+}
40+
41+interface ExecuteResponse {
42+ readonly structured: unknown;
43+ readonly text: string;
44+}
45+
46+const NAME = "Desktop sidecar · local-directory custom tool publishes and executes";
47+const guestIp = process.env.E2E_DESKTOP_VM_IP;
48+const os: DesktopOs =
49+ process.env.E2E_TARGET === "desktop-windows"
50+ ? "windows"
51+ : process.env.E2E_TARGET === "desktop-linux"
52+ ? "linux"
53+ : "macos";
54+
55+const token = (): string => (os === "linux" ? "desktop-linux-e2e" : "desktop-macos-e2e");
56+
57+const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => {
58+ if (!guestIp) throw new Error("E2E_DESKTOP_VM_IP is not set");
59+ const wrapped =
60+ os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command;
61+ try {
62+ const { stdout, stderr } = await execFileAsync(
63+ process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass",
64+ ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${guestIp}`, wrapped],
65+ { maxBuffer: 64 * 1024 * 1024 },
66+ );
67+ return { stdout, stderr, code: 0 };
68+ } catch (error) {
69+ const err = error as { stdout?: string; stderr?: string; code?: number };
70+ return {
71+ stdout: err.stdout ?? "",
72+ stderr: err.stderr ?? "",
73+ code: typeof err.code === "number" ? err.code : 1,
74+ };
75+ }
76+};
77+
78+const fixturePath = (slug: string): string =>
79+ os === "linux"
80+ ? `/home/admin/apps-sidecar-fixture-${slug}`
81+ : `/Users/admin/apps-sidecar-fixture-${slug}`;
82+
83+const writeFixtureCommand = (path: string): string => `
84+set -eu
85+rm -rf '${path}'
86+mkdir -p '${path}/tools'
87+cat >'${path}/tools/greet.ts' <<'EOF'
88+${"import"} { z } from "zod";
89+${"import"} { defineTool } from "executor:app";
90+
91+const Input = z.object({ name: z.string() });
92+
93+export default defineTool({
94+ description: "Greet",
95+ input: Input,
96+ async handler(input) {
97+ return { greeting: "hello " + input.name };
98+ },
99+});
100+EOF
101+printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json'
102+: >'${path}/bun.lock'
103+`;
104+
105+const writeFixture = async (path: string): Promise<void> => {
106+ const result = await ssh(writeFixtureCommand(path));
107+ expect(
108+ result.code,
109+ `fixture is written into the desktop guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
110+ ).toBe(0);
111+};
112+
113+const removeFixture = (path: string): Effect.Effect<void> =>
114+ Effect.promise(() => ssh(`rm -rf '${path}'`).then(() => undefined)).pipe(Effect.ignore);
115+
116+const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
117+
118+const guestRequest = async <T>(
119+ path: string,
120+ init: { readonly method: "POST" | "DELETE"; readonly body?: unknown },
121+ timeoutSeconds = 30,
122+): Promise<CurlResult<T>> => {
123+ const body = init.body === undefined ? undefined : JSON.stringify(init.body);
124+ const bodyFlag = body === undefined ? "" : ` --data-binary ${shellQuote(body)}`;
125+ const command = [
126+ "curl",
127+ "-sS",
128+ "--max-time",
129+ String(timeoutSeconds),
130+ "-w",
131+ shellQuote("\\n__HTTP_STATUS__:%{http_code}"),
132+ "-X",
133+ init.method,
134+ "-H",
135+ shellQuote(`Authorization: Bearer ${token()}`),
136+ "-H",
137+ shellQuote("content-type: application/json"),
138+ bodyFlag,
139+ shellQuote(`http://127.0.0.1:4789${path}`),
140+ ].join(" ");
141+ const result = await ssh(command);
142+ expect(
143+ result.code,
144+ `${init.method} ${path} exits cleanly\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
145+ ).toBe(0);
146+ const marker = "\n__HTTP_STATUS__:";
147+ const markerIndex = result.stdout.lastIndexOf(marker);
148+ expect(markerIndex, `${init.method} ${path} printed an HTTP status`).toBeGreaterThanOrEqual(0);
149+ const text = result.stdout.slice(0, markerIndex);
150+ const status = Number(result.stdout.slice(markerIndex + marker.length).trim());
151+ return { status, body: (text.length > 0 ? JSON.parse(text) : null) as T, text };
152+};
153+
154+const expectGuestRequest = async <T>(
155+ path: string,
156+ init: { readonly method: "POST" | "DELETE"; readonly body?: unknown },
157+ timeoutSeconds?: number,
158+): Promise<T> => {
159+ const result = await guestRequest<T>(path, init, timeoutSeconds);
160+ expect(result.status, `${init.method} ${path}: ${result.text}`).toBe(200);
161+ return result.body;
162+};
163+
164+const deleteSource = (slug: string): Effect.Effect<void> =>
165+ Effect.promise(() =>
166+ expectGuestRequest(`/api/apps/sources/${slug}`, { method: "DELETE" }).then(() => undefined),
167+ ).pipe(Effect.ignore);
168+
169+const syncAndInvoke = (input: {
170+ readonly slug: string;
171+ readonly path: string;
172+}): Effect.Effect<void> =>
173+ Effect.promise(async () => {
174+ await expectGuestRequest("/api/apps/sources", {
175+ method: "POST",
176+ body: {
177+ kind: "local-directory",
178+ slug: input.slug,
179+ app: input.slug,
180+ path: input.path,
181+ },
182+ });
183+
184+ const sync = await expectGuestRequest<SyncResponse>(
185+ `/api/apps/sources/${input.slug}/sync`,
186+ { method: "POST" },
187+ 180,
188+ );
189+ expect(sync.status, "the desktop sidecar bundled and published the tool").toBe("published");
190+ expect(sync.tools, "the desktop sidecar bundled and published the tool").toContain("greet");
191+
192+ const execution = await expectGuestRequest<ExecuteResponse>("/api/executions", {
193+ method: "POST",
194+ body: {
195+ code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`,
196+ autoApprove: true,
197+ },
198+ });
199+ expect(
200+ JSON.stringify(execution.structured ?? execution.text),
201+ "the published tool executes and returns the greeting",
202+ ).toContain('"greeting":"hello Ada"');
203+ });
204+
205+if (!guestIp) {
206+ it.skip(`${NAME} (needs a desktop guest with E2E_DESKTOP_VM_IP)`, () => {});
207+} else if (os === "windows") {
208+ it.skip(`${NAME} (desktop-windows does not boot a sidecar daemon with a known token; use cli-windows)`, () => {});
209+} else {
210+ scenario(
211+ "Desktop sidecar · local-directory custom tool publishes and executes",
212+ { timeout: 300_000 },
213+ Effect.gen(function* () {
214+ const slug = `sidecar-smoke-${crypto.randomUUID().slice(0, 8)}`;
215+ const path = fixturePath(slug);
216+
217+ yield* Effect.promise(() => writeFixture(path)).pipe(
218+ Effect.andThen(syncAndInvoke({ slug, path })),
219+ Effect.ensuring(
220+ Effect.all([deleteSource(slug), removeFixture(path)], {
221+ concurrency: "unbounded",
222+ discard: true,
223+ }),
224+ ),
225+ );
226+ }),
227+ );
228+}
e2e/fixtures/custom-tools-git/custom-tools-shas.txt+3−0
@@ -0,0 +1,3 @@
1+48e7995f99423b64c8adf2aa07186571d9e1f66e
2+b8de904b34df6cad1dc29a49986f3703c0fe87f4
3+251a747551ac772f029d3f37e2e708e8fed20ee9
e2e/fixtures/custom-tools-git/custom-tools-v1.pack+0−0

Binary file not shown.

e2e/fixtures/custom-tools-git/custom-tools-v2.pack+0−0

Binary file not shown.

e2e/fixtures/custom-tools-git/custom-tools-v3.pack+0−0

Binary file not shown.

e2e/local/custom-tools-directory-picker.test.ts+115−0
@@ -0,0 +1,115 @@
1+// Local-only: custom tool local-directory sources can browse the user's own
2+// filesystem through the local app. The shared self-host target intentionally
3+// does not expose this source kind.
4+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5+import { homedir } from "node:os";
6+import { join } from "node:path";
7+
8+import { expect } from "@effect/vitest";
9+import { Effect } from "effect";
10+
11+import { scenario } from "../src/scenario";
12+import { Browser, Cli, RunDir, Target } from "../src/services";
13+import { withLocalServer } from "./local-server";
14+
15+scenario(
16+ "Local custom tools · the directory picker selects a local source path",
17+ { timeout: 180_000 },
18+ Effect.gen(function* () {
19+ const cli = yield* Cli;
20+ const browser = yield* Browser;
21+ const target = yield* Target;
22+ const runDir = yield* RunDir;
23+ const identity = yield* target.newIdentity();
24+ const fixtureRoot = mkdtempSync(join(homedir(), ".executor-picker-e2e-"));
25+ const fixtureDir = join(fixtureRoot, "picked-tools");
26+ mkdirSync(join(fixtureDir, "tools"), { recursive: true });
27+ writeFileSync(
28+ join(fixtureDir, "package.json"),
29+ JSON.stringify({
30+ name: "picked-tools",
31+ version: "0.0.0",
32+ private: true,
33+ dependencies: { zod: "^4.1.13" },
34+ }),
35+ );
36+ writeFileSync(
37+ join(fixtureDir, "tools", "echo.ts"),
38+ `
39+import { z } from "zod";
40+import { defineTool } from "executor:app";
41+
42+export default defineTool({
43+ description: "Echo a picker message.",
44+ input: z.object({ message: z.string() }),
45+ handler(input) {
46+ return { echoed: \`local-picker:\${input.message}\` };
47+ },
48+});
49+`,
50+ );
51+
52+ yield* withLocalServer(cli, runDir, ({ url }) =>
53+ browser.session(identity, async ({ page, step }) => {
54+ await step("Open custom tools from the local console", async () => {
55+ const addSourceUrl = new URL(url);
56+ addSourceUrl.pathname = "/integrations/add/apps";
57+ await page.goto(addSourceUrl.toString(), {
58+ waitUntil: "domcontentloaded",
59+ });
60+ await page.getByRole("heading", { name: "Add custom tools" }).waitFor({
61+ timeout: 60_000,
62+ });
63+ });
64+
65+ await step("Browse to a local directory and select it", async () => {
66+ await page.getByRole("button", { name: "Directory path" }).click();
67+ const pathInput = page.getByPlaceholder("/Users/me/tools");
68+ await pathInput.fill(fixtureRoot);
69+ await page.getByRole("button", { name: "Browse" }).click();
70+ const dialog = page.getByRole("dialog", { name: "Choose directory" });
71+ await dialog
72+ .getByRole("button", { name: /picked-tools/ })
73+ .getByText("tools", { exact: true })
74+ .waitFor({ timeout: 30_000 });
75+ await dialog.getByRole("button", { name: "picked-tools" }).click();
76+ await dialog.getByText("1 tool found:").waitFor({ timeout: 30_000 });
77+ await dialog.getByText("echo", { exact: true }).waitFor({ timeout: 30_000 });
78+ await dialog.getByRole("button", { name: "Select" }).click();
79+ expect(await pathInput.inputValue()).toBe(fixtureDir);
80+ });
81+
82+ await step("Sync the picked directory as a custom tools source", async () => {
83+ await page.getByRole("button", { name: "Sync source" }).click();
84+ await page.waitForURL(/\/integrations\/picked-tools(?:\?|$)/, { timeout: 90_000 });
85+ await page.getByLabel("Source").getByText("1 tool").waitFor({ timeout: 90_000 });
86+ });
87+
88+ await step("Run the published local tool from the console", async () => {
89+ await page.getByRole("tab", { name: "Tools" }).click();
90+ await page.getByRole("button", { name: /picked-tools\s+1/ }).click();
91+ await page.getByRole("button", { name: "echo", exact: true }).click();
92+ await page.getByRole("tab", { name: "Run" }).click();
93+ await page.getByLabel("message").fill("from directory picker");
94+ await page.getByRole("button", { name: "Run", exact: true }).click();
95+ await page
96+ .locator("pre")
97+ .filter({ hasText: "local-picker:from directory picker" })
98+ .last()
99+ .waitFor({ timeout: 90_000 });
100+ });
101+
102+ await step("Remove the local directory source", async () => {
103+ await page.goto(new URL("/integrations/picked-tools?tab=source", url).toString(), {
104+ waitUntil: "networkidle",
105+ });
106+ await page.getByRole("button", { name: "Remove" }).click();
107+ await page.getByRole("button", { name: "Remove source" }).click();
108+ await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 });
109+ });
110+ }),
111+ ).pipe(
112+ Effect.ensuring(Effect.sync(() => rmSync(fixtureRoot, { recursive: true, force: true }))),
113+ );
114+ }),
115+);
e2e/recordings/custom-tools-console.mp4+0−0

Binary file not shown.

e2e/scenarios/custom-tools.test.ts+520−0
@@ -0,0 +1,520 @@
1+/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e fixture server and raw HTTP assertions */
2+import { readFile } from "node:fs/promises";
3+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4+import { join } from "node:path";
5+
6+import { expect } from "@effect/vitest";
7+import { Effect } from "effect";
8+
9+import { scenario } from "../src/scenario";
10+import { Api, Browser, Target } from "../src/services";
11+import type { Identity, Target as TargetShape } from "../src/target";
12+import type { BrowserSurface } from "../src/surfaces/browser";
13+
14+const textEncoder = new TextEncoder();
15+const FIXTURE_GIT_TOKEN = "fixture-token";
16+const EXPECTED_GIT_AUTHORIZATION = `Basic ${btoa(`git:${FIXTURE_GIT_TOKEN}`)}`;
17+
18+const concat = (parts: readonly Uint8Array[]): Uint8Array => {
19+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
20+ let offset = 0;
21+ for (const part of parts) {
22+ out.set(part, offset);
23+ offset += part.length;
24+ }
25+ return out;
26+};
27+
28+const pktLine = (payload: string | Uint8Array): Uint8Array => {
29+ const body = typeof payload === "string" ? textEncoder.encode(payload) : payload;
30+ const header = textEncoder.encode((body.length + 4).toString(16).padStart(4, "0"));
31+ return concat([header, body]);
32+};
33+
34+const FLUSH = textEncoder.encode("0000");
35+
36+const sideBand = (bytes: Uint8Array): Uint8Array => {
37+ const chunks: Uint8Array[] = [pktLine("NAK\n")];
38+ for (let offset = 0; offset < bytes.length; offset += 60_000) {
39+ const chunk = bytes.subarray(offset, offset + 60_000);
40+ const payload = new Uint8Array(chunk.length + 1);
41+ payload[0] = 1;
42+ payload.set(chunk, 1);
43+ chunks.push(pktLine(payload));
44+ }
45+ chunks.push(FLUSH);
46+ return concat(chunks);
47+};
48+
49+const advertisement = (sha: string): Uint8Array =>
50+ concat([
51+ pktLine("# service=git-upload-pack\n"),
52+ FLUSH,
53+ pktLine(
54+ `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`,
55+ ),
56+ pktLine(`${sha} refs/heads/main\n`),
57+ FLUSH,
58+ ]);
59+
60+const readFixture = async () => {
61+ const dir = join(import.meta.dirname, "..", "fixtures", "custom-tools-git");
62+ const [shas, pack1, pack2, pack3] = await Promise.all([
63+ readFile(join(dir, "custom-tools-shas.txt"), "utf8"),
64+ readFile(join(dir, "custom-tools-v1.pack")),
65+ readFile(join(dir, "custom-tools-v2.pack")),
66+ readFile(join(dir, "custom-tools-v3.pack")),
67+ ]);
68+ const [sha1, sha2, sha3] = shas.trim().split("\n");
69+ return { sha1: sha1!, sha2: sha2!, sha3: sha3!, pack1, pack2, pack3 };
70+};
71+
72+const fixtureGitServer = Effect.acquireRelease(
73+ Effect.promise(async () => {
74+ const fixture = await readFixture();
75+ let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) };
76+ let packRequests = 0;
77+ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
78+ if (
79+ (request.url === "/repo.git/info/refs?service=git-upload-pack" ||
80+ request.url === "/repo.git/git-upload-pack") &&
81+ request.headers.authorization !== EXPECTED_GIT_AUTHORIZATION
82+ ) {
83+ response.writeHead(401, {
84+ "content-type": "text/plain",
85+ "www-authenticate": 'Basic realm="custom-tools-fixture"',
86+ });
87+ response.end("authentication required");
88+ return;
89+ }
90+ if (request.url === "/repo.git/info/refs?service=git-upload-pack") {
91+ response.writeHead(200, {
92+ "content-type": "application/x-git-upload-pack-advertisement",
93+ });
94+ response.end(advertisement(current.sha));
95+ return;
96+ }
97+ if (request.url === "/repo.git/git-upload-pack" && request.method === "POST") {
98+ packRequests += 1;
99+ response.writeHead(200, { "content-type": "application/x-git-upload-pack-result" });
100+ response.end(sideBand(current.pack));
101+ return;
102+ }
103+ response.writeHead(404, { "content-type": "text/plain" });
104+ response.end("not found");
105+ });
106+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
107+ const address = server.address();
108+ if (!address || typeof address === "string") throw new Error("fixture server did not bind");
109+ return {
110+ url: `http://127.0.0.1:${address.port}/repo.git`,
111+ advance: () => {
112+ current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) };
113+ },
114+ failCollect: () => {
115+ current = { sha: fixture.sha3, pack: new Uint8Array(fixture.pack3) };
116+ },
117+ restoreGood: () => {
118+ current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) };
119+ },
120+ packRequests: () => packRequests,
121+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
122+ };
123+ }),
124+ (server) => Effect.promise(() => server.close()).pipe(Effect.ignore),
125+);
126+
127+interface ToolRow {
128+ readonly address: string;
129+ readonly name: string;
130+ readonly integration: string;
131+}
132+
133+interface ExecuteResponse {
134+ readonly status: "completed" | "paused";
135+ readonly text: string;
136+ readonly structured: unknown;
137+ readonly isError?: boolean;
138+}
139+
140+const authHeaders = async (
141+ target: TargetShape,
142+ identity: Identity,
143+): Promise<Record<string, string>> => {
144+ if (identity.headers) return identity.headers;
145+ const credentials = identity.credentials;
146+ if (!credentials) throw new Error(`identity ${identity.label} has no credentials`);
147+ const response = await fetch(new URL("/api/auth/sign-in/email", target.baseUrl), {
148+ method: "POST",
149+ headers: { "content-type": "application/json", origin: new URL(target.baseUrl).origin },
150+ body: JSON.stringify(credentials),
151+ redirect: "manual",
152+ });
153+ const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
154+ if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`);
155+ return { cookie };
156+};
157+
158+const request = async <T>(
159+ target: TargetShape,
160+ identity: Identity,
161+ path: string,
162+ init: RequestInit = {},
163+ expectedStatus = 200,
164+): Promise<{ readonly body: T; readonly text: string }> => {
165+ const headers = new Headers(init.headers);
166+ headers.set("origin", new URL(target.baseUrl).origin);
167+ for (const [name, value] of Object.entries(await authHeaders(target, identity))) {
168+ headers.set(name, value);
169+ }
170+ if (init.body !== undefined && !headers.has("content-type")) {
171+ headers.set("content-type", "application/json");
172+ }
173+ const response = await fetch(new URL(path, target.baseUrl), { ...init, headers });
174+ const text = await response.text();
175+ expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus);
176+ return { body: (text.length > 0 ? JSON.parse(text) : null) as T, text };
177+};
178+
179+const postJson = <T>(
180+ target: TargetShape,
181+ identity: Identity,
182+ path: string,
183+ body?: unknown,
184+ expectedStatus = 200,
185+): Promise<{ readonly body: T; readonly text: string }> =>
186+ request<T>(
187+ target,
188+ identity,
189+ path,
190+ {
191+ method: "POST",
192+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
193+ },
194+ expectedStatus,
195+ );
196+
197+const deletePath = (
198+ target: TargetShape,
199+ identity: Identity,
200+ path: string,
201+): Promise<{ readonly body: { readonly removed?: boolean }; readonly text: string }> =>
202+ request(target, identity, path, { method: "DELETE" });
203+
204+const execute = (target: TargetShape, identity: Identity, code: string): Promise<ExecuteResponse> =>
205+ postJson<ExecuteResponse>(target, identity, "/api/executions", {
206+ code,
207+ autoApprove: true,
208+ }).then((response) => response.body);
209+
210+const addSourceThroughConsole = (input: {
211+ readonly target: TargetShape;
212+ readonly browser: BrowserSurface;
213+ readonly identity: Identity;
214+ readonly sourceUrl: string;
215+}) =>
216+ input.browser.session(input.identity, async ({ page, step }) => {
217+ await step("Open the integrations page", async () => {
218+ await page.goto(new URL("/integrations", input.target.baseUrl).toString(), {
219+ waitUntil: "networkidle",
220+ });
221+ });
222+
223+ await step("Detect the Git repository as custom tools", async () => {
224+ await page.getByRole("button", { name: "Connect", exact: true }).click();
225+ const dialog = page.getByRole("dialog");
226+ await dialog.getByRole("textbox").fill(input.sourceUrl);
227+ await dialog.getByRole("button", { name: "Detect" }).click();
228+ await page.getByRole("heading", { name: "Add custom tools" }).waitFor();
229+ });
230+
231+ await step("Sync the custom tools source", async () => {
232+ await page.locator('input[type="password"]').fill(FIXTURE_GIT_TOKEN);
233+ await page.getByRole("button", { name: "Sync source" }).click();
234+ await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 });
235+ await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 });
236+ await page.getByRole("link", { name: "repo" }).waitFor({ timeout: 90_000 });
237+ await page.getByRole("tab", { name: "Tools" }).click();
238+ await page.getByRole("button", { name: /repo\s+2/ }).click();
239+ await page.getByRole("button", { name: "echo-tool", exact: true }).click();
240+ await page.getByRole("tab", { name: "Run" }).click();
241+ expect(await page.getByLabel("Connection").count()).toBe(0);
242+ await page.getByLabel("message").fill("from run tab");
243+ await page.getByLabel("apps").selectOption("0");
244+ await page.getByRole("button", { name: "Run", exact: true }).click();
245+ await page
246+ .locator("pre")
247+ .filter({ hasText: "from run tab" })
248+ .filter({ hasText: "v1" })
249+ .last()
250+ .waitFor({ timeout: 90_000 });
251+ });
252+ });
253+
254+const assertBadTokenFailureThroughConsole = (input: {
255+ readonly target: TargetShape;
256+ readonly browser: BrowserSurface;
257+ readonly identity: Identity;
258+ readonly sourceUrl: string;
259+}) =>
260+ input.browser.session(input.identity, async ({ page, step }) => {
261+ await step("Try to sync the custom tools source with a bad token", async () => {
262+ await page.goto(new URL("/integrations", input.target.baseUrl).toString(), {
263+ waitUntil: "networkidle",
264+ });
265+ await page.getByRole("button", { name: "Connect", exact: true }).click();
266+ const dialog = page.getByRole("dialog");
267+ await dialog.getByRole("textbox").fill(input.sourceUrl);
268+ await dialog.getByRole("button", { name: "Detect" }).click();
269+ await page.getByRole("heading", { name: "Add custom tools" }).waitFor();
270+ await page.locator('input[type="password"]').fill("wrong-token");
271+ const syncResponse = page.waitForResponse(
272+ (response) =>
273+ response.url().includes("/api/apps/sources/repo/sync") && response.status() === 200,
274+ { timeout: 90_000 },
275+ );
276+ await page.getByRole("button", { name: "Sync source" }).click();
277+ await syncResponse;
278+ });
279+
280+ await step("See the failed sync on the source panel", async () => {
281+ await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), {
282+ waitUntil: "networkidle",
283+ });
284+ await page.getByText("Last sync failed").waitFor({ timeout: 90_000 });
285+ await page.getByText("source: info/refs 401").waitFor({ timeout: 90_000 });
286+ });
287+
288+ await step("Remove the failed source before retrying with the good token", async () => {
289+ await page.getByRole("button", { name: "Remove" }).click();
290+ await page.getByRole("button", { name: "Remove source" }).click();
291+ await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 });
292+ });
293+ });
294+
295+const syncSourceInConsole = (input: {
296+ readonly target: TargetShape;
297+ readonly browser: BrowserSurface;
298+ readonly identity: Identity;
299+ readonly expectedNotice: string;
300+ readonly expectedToolCount: string;
301+}) =>
302+ input.browser.session(input.identity, async ({ page, step }) => {
303+ await step(`Sync custom tools and see ${input.expectedNotice}`, async () => {
304+ await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), {
305+ waitUntil: "networkidle",
306+ });
307+ await page.getByRole("button", { name: "Sync" }).click();
308+ await page.getByText(input.expectedNotice).waitFor({ timeout: 90_000 });
309+ await page
310+ .locator("p")
311+ .filter({ hasText: new RegExp(`^${input.expectedToolCount}$`) })
312+ .waitFor({ timeout: 90_000 });
313+ });
314+ });
315+
316+const syncCollectFailureInConsole = (input: {
317+ readonly target: TargetShape;
318+ readonly browser: BrowserSurface;
319+ readonly identity: Identity;
320+}) =>
321+ input.browser.session(input.identity, async ({ page, step }) => {
322+ await step("Sync custom tools and see the collect diagnostic", async () => {
323+ await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), {
324+ waitUntil: "networkidle",
325+ });
326+ await page.getByRole("button", { name: "Sync" }).click();
327+ await page.getByText("Sync failed.").waitFor({ timeout: 90_000 });
328+ await page
329+ .locator("p")
330+ .filter({ hasText: "collect: record export key" })
331+ .first()
332+ .waitFor({ timeout: 90_000 });
333+ await page
334+ .locator("p")
335+ .filter({ hasText: /^0 tools$/ })
336+ .waitFor({ timeout: 90_000 });
337+ });
338+ });
339+
340+const removeSourceThroughConsole = (input: {
341+ readonly target: TargetShape;
342+ readonly browser: BrowserSurface;
343+ readonly identity: Identity;
344+}) =>
345+ input.browser.session(input.identity, async ({ page, step }) => {
346+ await step("Remove the custom tools source", async () => {
347+ await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), {
348+ waitUntil: "networkidle",
349+ });
350+ await page.getByRole("button", { name: "Remove" }).click();
351+ await page.getByRole("button", { name: "Remove source" }).click();
352+ await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 });
353+ });
354+ });
355+
356+scenario(
357+ "Custom tools · Git source syncs, invokes, refreshes, and removes",
358+ { timeout: 300_000 },
359+ Effect.scoped(
360+ Effect.gen(function* () {
361+ const target = yield* Target;
362+ const browser = yield* Browser;
363+ yield* Api;
364+ const identity = yield* target.newIdentity();
365+ const git = yield* fixtureGitServer;
366+
367+ yield* Effect.ensuring(
368+ Effect.gen(function* () {
369+ const unauthorized = yield* Effect.promise(() =>
370+ fetch(new URL("/api/apps/sources/custom-tools/sync", target.baseUrl), {
371+ method: "POST",
372+ }),
373+ );
374+ expect(unauthorized.status, "sync requires authentication").toBe(
375+ target.name === "cloud" ? 403 : 401,
376+ );
377+ yield* Effect.promise(() => unauthorized.text());
378+
379+ const missingGitAuth = yield* Effect.promise(() =>
380+ fetch(`${git.url}/info/refs?service=git-upload-pack`),
381+ );
382+ expect(missingGitAuth.status, "fixture git server requires auth").toBe(401);
383+ yield* Effect.promise(() => missingGitAuth.text());
384+
385+ yield* assertBadTokenFailureThroughConsole({
386+ target,
387+ browser,
388+ identity,
389+ sourceUrl: git.url,
390+ });
391+ expect(git.packRequests(), "bad token fails before fetching a pack").toBe(0);
392+
393+ yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url });
394+
395+ const sources = yield* Effect.promise(() =>
396+ request<{
397+ readonly sources: readonly {
398+ readonly slug: string;
399+ readonly app: string;
400+ readonly config: { readonly kind: string; readonly url?: string };
401+ }[];
402+ }>(target, identity, "/api/apps/sources"),
403+ );
404+ expect(sources.text).not.toContain("fixture-token");
405+ const source = sources.body.sources.find((item) => item.app === "repo");
406+ expect(source).toMatchObject({
407+ app: "repo",
408+ config: { kind: "git", url: git.url },
409+ });
410+
411+ const tools = yield* Effect.promise(() =>
412+ request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"),
413+ );
414+ expect(tools.body.map((tool) => tool.name).sort()).toEqual(["echo-tool", "static-tool"]);
415+ const echoTool = tools.body.find((tool) => tool.name === "echo-tool");
416+ expect(echoTool).toBeDefined();
417+
418+ const schema = yield* Effect.promise(() =>
419+ request<{
420+ readonly inputSchema: {
421+ readonly properties?: Record<
422+ string,
423+ {
424+ readonly type?: string;
425+ readonly enum?: readonly string[];
426+ readonly default?: unknown;
427+ }
428+ >;
429+ readonly required?: readonly string[];
430+ };
431+ }>(
432+ target,
433+ identity,
434+ `/api/tools/schema?address=${encodeURIComponent(echoTool!.address)}`,
435+ ),
436+ );
437+ expect(schema.body.inputSchema.properties?.apps).toMatchObject({
438+ enum: ["tools.repo.org.published"],
439+ default: "tools.repo.org.published",
440+ });
441+ expect(schema.body.inputSchema.properties?.message).toMatchObject({ type: "string" });
442+ expect(schema.body.inputSchema.required ?? []).toContain("message");
443+ expect(schema.body.inputSchema.required ?? []).not.toContain("apps");
444+
445+ const invoked = yield* Effect.promise(() =>
446+ execute(
447+ target,
448+ identity,
449+ `return await tools["repo.org.published.echo-tool"]({ message: "hello", apps: "tools.repo.org.published" });`,
450+ ),
451+ );
452+ expect(invoked.status, invoked.text).toBe("completed");
453+ expect(invoked.isError, invoked.text).toBe(false);
454+ expect(JSON.stringify(invoked.structured)).toContain("hello");
455+ expect(JSON.stringify(invoked.structured)).toContain("v1");
456+
457+ expect(git.packRequests(), "initial publish fetched one pack").toBe(1);
458+ git.advance();
459+ yield* syncSourceInConsole({
460+ target,
461+ browser,
462+ identity,
463+ expectedNotice: "Added: extra-tool",
464+ expectedToolCount: "3 tools",
465+ });
466+
467+ yield* syncSourceInConsole({
468+ target,
469+ browser,
470+ identity,
471+ expectedNotice: "Already up to date.",
472+ expectedToolCount: "3 tools",
473+ });
474+
475+ git.failCollect();
476+ yield* syncCollectFailureInConsole({ target, browser, identity });
477+
478+ const afterFailedCollect = yield* Effect.promise(() =>
479+ request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"),
480+ );
481+ expect(afterFailedCollect.body.map((tool) => tool.name).sort()).toEqual([
482+ "echo-tool",
483+ "extra-tool",
484+ "static-tool",
485+ ]);
486+
487+ git.restoreGood();
488+ yield* syncSourceInConsole({
489+ target,
490+ browser,
491+ identity,
492+ expectedNotice: "Already up to date.",
493+ expectedToolCount: "3 tools",
494+ });
495+
496+ yield* removeSourceThroughConsole({ target, browser, identity });
497+
498+ const afterRemove = yield* Effect.promise(() =>
499+ request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"),
500+ );
501+ expect(afterRemove.body).toEqual([]);
502+ }),
503+ Effect.promise(async () => {
504+ const sources = await request<{
505+ readonly sources: readonly { readonly slug: string; readonly app: string }[];
506+ }>(target, identity, "/api/apps/sources").catch(() => ({ body: { sources: [] } }));
507+ for (const source of sources.body.sources) {
508+ if (source.app === "repo") {
509+ await deletePath(
510+ target,
511+ identity,
512+ `/api/apps/sources/${encodeURIComponent(source.slug)}`,
513+ ).catch(() => undefined);
514+ }
515+ }
516+ }).pipe(Effect.ignore),
517+ );
518+ }),
519+ ),
520+);
e2e/setup/desktop-linux.globalsetup.ts+1−0
@@ -87,6 +87,7 @@ const provisionLinux = async (): Promise<ProvisionedGuest> => { · 86 unmodified lines
8787 await vm.ssh(
8888 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +
8989 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
90+ `--auth-token desktop-linux-e2e ` +
9091 `>/tmp/executor-daemon.log 2>&1 &`,
9192 );
9293 if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
e2e/setup/desktop-macos.globalsetup.ts+1−0
@@ -78,6 +78,7 @@ const provisionMac = async (): Promise<ProvisionedGuest> => { · 77 unmodified lines
7878 await vm.ssh(
7979 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +
8080 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
81+ `--auth-token desktop-macos-e2e ` +
8182 `>/tmp/executor-daemon.log 2>&1 &`,
8283 );
8384 if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
packages/core/sdk/src/connections.test.ts+62−1
@@ -362,13 +362,14 @@ describe("connections.refresh", () => { · 361 unmodified lines
362362
363363 describe("tool catalog sync safety", () => {
364364 it.effect(
365− "background sync preserves a nonzero catalog when a plugin returns authoritative empty",
365+ "background sync preserves a nonzero remote catalog when a plugin returns authoritative empty",
366366 () =>
367367 Effect.scoped(
368368 Effect.gen(function* () {
369369 let empty = false;
370370 const guardedPlugin = definePlugin(() => ({
371371 id: "guarded" as const,
372+ remoteToolCatalog: true,
372373 credentialProviders: [memoryProvider()],
373374 storage: () => ({}),
374375 resolveTools: () =>
@@ -424,6 +425,66 @@ describe("tool catalog sync safety", () => { · 49 unmodified lines
424425 ),
425426 );
426427
428+ it.effect(
429+ "background sync clears a non-remote catalog when a plugin returns authoritative empty",
430+ () =>
431+ Effect.scoped(
432+ Effect.gen(function* () {
433+ let empty = false;
434+ const storedStatePlugin = definePlugin(() => ({
435+ id: "stored-state" as const,
436+ credentialProviders: [memoryProvider()],
437+ storage: () => ({}),
438+ resolveTools: () =>
439+ Effect.sync(() => ({
440+ tools: empty
441+ ? []
442+ : [
443+ { name: ToolName.make("deploy"), description: "deploy" },
444+ { name: ToolName.make("list"), description: "list" },
445+ ],
446+ })),
447+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
448+ extension: (ctx) => ({
449+ seed: () =>
450+ ctx.core.integrations.register({
451+ slug: INTEG,
452+ description: "Vercel",
453+ config: {},
454+ }),
455+ }),
456+ }))();
457+ const config = makeTestConfig({ plugins: [storedStatePlugin] as const });
458+ const executor = yield* createExecutor(config);
459+ yield* executor["stored-state"].seed();
460+ yield* executor.connections.create({
461+ owner: "org",
462+ name: ConnectionName.make("main"),
463+ integration: INTEG,
464+ template: TEMPLATE,
465+ value: "secret-token",
466+ });
467+
468+ empty = true;
469+ yield* Effect.promise(() =>
470+ config.db.updateMany("connection", {
471+ where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),
472+ set: { tools_synced_at: null },
473+ }),
474+ );
475+ const tools = yield* executor.tools.list({ integration: INTEG });
476+ const connection = yield* executor.connections.get({
477+ owner: "org",
478+ integration: INTEG,
479+ name: ConnectionName.make("main"),
480+ });
481+
482+ expect(tools).toEqual([]);
483+ expect(connection?.lastHealth).toBeNull();
484+ }),
485+ ),
486+ );
487+
427488 it.effect("explicit refresh accepts an authoritative empty catalog", () =>
428489 Effect.scoped(
429490 Effect.gen(function* () {
packages/core/sdk/src/executor.ts+5−1
@@ -2167,7 +2167,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2166 unmodified lines
21672167 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
21682168 }
21692169
2170− if (mode === "background" && result.tools.length === 0) {
2170+ if (
2171+ mode === "background" &&
2172+ runtime.plugin.remoteToolCatalog === true &&
2173+ result.tools.length === 0
2174+ ) {
21712175 const keptRows = yield* core.findMany("tool", { where });
21722176 if (keptRows.length > 0) {
21732177 const reason =
packages/kernel/runtime-workerd-subprocess/src/index.test.ts+41−1
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { stat } from "node:fs/promises";
2+import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
34 import { join } from "node:path";
45 import * as Data from "effect/Data";
56 import * as Effect from "effect/Effect";
@@ -9,6 +10,7 @@ import { · 3 unmodified lines
910 createWorkerdModuleRunner,
1011 isWorkerdAvailable,
1112 makeWorkerdSubprocessExecutor,
13+ workerdBinaryCandidatesForTest,
1214 WORKERD_VERSION,
1315 } from "./index";
1416
@@ -107,6 +109,44 @@ it("exports the pinned workerd version", () => { · 92 unmodified lines
107109 expect(WORKERD_VERSION).toBe("1.20260708.1");
108110 });
109111
112+describe("workerd binary resolution", () => {
113+ it("honors EXECUTOR_WORKERD_BIN before probing", async () => {
114+ const tmp = await mkdtemp(join(tmpdir(), "executor-workerd-bin-test-"));
115+ const previous = process.env.EXECUTOR_WORKERD_BIN;
116+ const bin = join(tmp, "workerd");
117+ try {
118+ await writeFile(bin, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
119+ await chmod(bin, 0o755);
120+ process.env.EXECUTOR_WORKERD_BIN = bin;
121+ expect(isWorkerdAvailable()).toBe(true);
122+ } finally {
123+ if (previous === undefined) {
124+ delete process.env.EXECUTOR_WORKERD_BIN;
125+ } else {
126+ process.env.EXECUTOR_WORKERD_BIN = previous;
127+ }
128+ await rm(tmp, { recursive: true, force: true });
129+ }
130+ });
131+
132+ it("orders generic package probes before platform package probes", () => {
133+ const roots = ["/app", "/workspace"];
134+ expect(workerdBinaryCandidatesForTest(roots, "darwin", "arm64")).toEqual([
135+ "/app/node_modules/workerd/bin/workerd",
136+ "/workspace/node_modules/workerd/bin/workerd",
137+ "/app/node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd",
138+ "/app/node_modules/@cloudflare/workerd-darwin-arm64/workerd",
139+ "/workspace/node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd",
140+ "/workspace/node_modules/@cloudflare/workerd-darwin-arm64/workerd",
141+ ]);
142+ });
143+
144+ it("probes workerd.exe in the Windows package root layout", () => {
145+ const candidates = workerdBinaryCandidatesForTest(["C:\\app"], "win32", "x64");
146+ expect(candidates).toContain("C:\\app/node_modules/@cloudflare/workerd-windows-64/workerd.exe");
147+ });
148+});
149+
110150 describe.skipIf(!isWorkerdAvailable())("runtime-workerd-subprocess", () => {
111151 it("boots and executes a trivial module", async () => {
112152 const runner = makeRunner();
packages/kernel/runtime-workerd-subprocess/src/index.ts+125−30
@@ -1,11 +1,14 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-promise-reject, executor/no-instanceof-tagged-error -- subprocess boundary normalizes JavaScript errors into typed runtime errors */
22 import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";
3+import { existsSync, readFileSync } from "node:fs";
34 import { access, mkdir, rm, writeFile } from "node:fs/promises";
45 import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
6+import { createRequire } from "node:module";
57 import { createServer as createNetServer, type Server as NetServer } from "node:net";
68 import type { Readable } from "node:stream";
79 import { tmpdir } from "node:os";
810 import { dirname, join } from "node:path";
11+import { rootCertificates } from "node:tls";
912 import { fileURLToPath } from "node:url";
1013
1114 import {
@@ -77,6 +80,7 @@ type PendingRequest = { · 65 unmodified lines
7780 };
7881
7982 const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
83+const packageRequire = createRequire(import.meta.url);
8084
8185 const normalizeError = (cause: unknown): Error =>
8286 cause instanceof Error ? cause : new Error(String(cause));
@@ -150,16 +154,86 @@ const reserveLocalPort = (): Promise<{ · 67 unmodified lines
150154 });
151155 });
152156
153−const findWorkerdBin = async (configured?: string): Promise<string> => {
154− if (configured) return configured;
157+const workerdBinaryName = (platform: NodeJS.Platform = process.platform): string =>
158+ platform === "win32" ? "workerd.exe" : "workerd";
159+
160+const detectLinuxMusl = (): boolean => {
161+ if (process.platform !== "linux") return false;
162+ if (existsSync("/etc/alpine-release")) return true;
163+ try {
164+ return readFileSync("/usr/bin/ldd", "utf8").toLowerCase().includes("musl");
165+ } catch {
166+ return false;
167+ }
168+};
169+
170+const workerdPlatformPackage = (
171+ platform: NodeJS.Platform = process.platform,
172+ arch: NodeJS.Architecture = process.arch,
173+): string | null => {
174+ if (platform === "darwin" && arch === "arm64") return "@cloudflare/workerd-darwin-arm64";
175+ if (platform === "darwin" && arch === "x64") return "@cloudflare/workerd-darwin-64";
176+ if (platform === "linux" && detectLinuxMusl()) return null;
177+ if (platform === "linux" && arch === "arm64") return "@cloudflare/workerd-linux-arm64";
178+ if (platform === "linux" && arch === "x64") return "@cloudflare/workerd-linux-64";
179+ if (platform === "win32" && arch === "x64") return "@cloudflare/workerd-windows-64";
180+ return null;
181+};
182+
183+const workerdPackageDirCandidates = (packageName: string): readonly string[] => {
184+ const packageJson = `${packageName}/package.json`;
185+ const resolved = new Set<string>();
186+ for (const req of [
187+ packageRequire,
188+ createRequire(join(process.cwd(), "package.json")),
189+ createRequire(join(packageDir, "package.json")),
190+ ]) {
191+ try {
192+ resolved.add(dirname(req.resolve(packageJson)));
193+ } catch {
194+ // Try the next package-manager layout.
195+ }
196+ }
197+ return [...resolved];
198+};
199+
200+export const workerdBinaryCandidatesForTest = (
201+ roots: readonly string[],
202+ platform: NodeJS.Platform,
203+ arch: NodeJS.Architecture,
204+): readonly string[] => {
205+ const binary = workerdBinaryName(platform);
206+ const platformPackage = workerdPlatformPackage(platform, arch);
207+ return [
208+ ...roots.map((root) => join(root, "node_modules", "workerd", "bin", binary)),
209+ ...(platformPackage
210+ ? roots.flatMap((root) => [
211+ join(root, "node_modules", platformPackage, "bin", binary),
212+ join(root, "node_modules", platformPackage, binary),
213+ ])
214+ : []),
215+ ];
216+};
155217
156− const candidates = [
157− join(packageDir, "node_modules", "workerd", "bin", "workerd"),
158− join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"),
159− join(process.cwd(), "node_modules", "workerd", "bin", "workerd"),
218+const workerdBinaryCandidates = (): readonly string[] => {
219+ const roots = [packageDir, join(packageDir, "..", "..", ".."), process.cwd()];
220+ const platformPackage = workerdPlatformPackage();
221+ return [
222+ ...(platformPackage
223+ ? workerdPackageDirCandidates(platformPackage).flatMap((dir) => [
224+ join(dir, "bin", workerdBinaryName()),
225+ join(dir, workerdBinaryName()),
226+ ])
227+ : []),
228+ ...workerdBinaryCandidatesForTest(roots, process.platform, process.arch),
160229 ];
230+};
231+
232+const findWorkerdBin = async (configured?: string): Promise<string> => {
233+ if (configured) return configured;
234+ if (process.env.EXECUTOR_WORKERD_BIN) return process.env.EXECUTOR_WORKERD_BIN;
161235
162− for (const candidate of candidates) {
236+ for (const candidate of workerdBinaryCandidates()) {
163237 try {
164238 await access(candidate);
165239 return candidate;
@@ -168,36 +242,41 @@ const findWorkerdBin = async (configured?: string): Promise<string> => { · 2 unmodified lines
168242 }
169243 }
170244
171− return "workerd";
172−};
173−
174−export const isWorkerdAvailable = (workerdBin?: string): boolean => {
175− const configured = workerdBin ?? "workerd";
176− const result = spawnSync(configured, ["--version"], {
245+ if (workerdPlatformPackage() === null) throw new Error("workerd is unavailable on this platform");
246+ const pathWorkerd = spawnSync("workerd", ["--version"], {
177247 stdio: "ignore",
178248 timeout: 5000,
179249 });
180− if (result.error === undefined && result.status === 0) return true;
250+ if (pathWorkerd.error !== undefined || pathWorkerd.status !== 0) {
251+ throw new Error("workerd is unavailable on this platform");
252+ }
253+ return "workerd";
254+};
181255
182− const local = spawnSync(
183− join(packageDir, "node_modules", "workerd", "bin", "workerd"),
184− ["--version"],
185− {
256+export const isWorkerdAvailable = (workerdBin?: string): boolean => {
257+ const configured = workerdBin ?? process.env.EXECUTOR_WORKERD_BIN;
258+ if (configured) {
259+ const result = spawnSync(configured, ["--version"], {
186260 stdio: "ignore",
187261 timeout: 5000,
188− },
189− );
190− if (local.error === undefined && local.status === 0) return true;
262+ });
263+ return result.error === undefined && result.status === 0;
264+ }
191265
192− const workspace = spawnSync(
193− join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"),
194− ["--version"],
195− {
266+ for (const candidate of workerdBinaryCandidates()) {
267+ const result = spawnSync(candidate, ["--version"], {
196268 stdio: "ignore",
197269 timeout: 5000,
198− },
199− );
200− return workspace.error === undefined && workspace.status === 0;
270+ });
271+ if (result.error === undefined && result.status === 0) return true;
272+ }
273+
274+ if (workerdPlatformPackage() === null) return false;
275+ const result = spawnSync("workerd", ["--version"], {
276+ stdio: "ignore",
277+ timeout: 5000,
278+ });
279+ return result.error === undefined && result.status === 0;
201280 };
202281
203282 const capnpString = (value: string): string => JSON.stringify(value);
@@ -225,9 +304,19 @@ const buildConfig = (input: { · 21 unmodified lines
225304 return `( name = ${capnpString(name)}, ${field} = embed ${capnpString(name)} )`;
226305 })
227306 .join(",\n ");
307+ // workerd's built-in `internet` service relies on the platform TLS trust
308+ // store, which kj/tls cannot read on Windows ("unable to get local issuer
309+ // certificate"). Declaring our own internet service with the runtime's
310+ // bundled Mozilla roots gives every platform the same trust behavior.
311+ const trustedCertificates = rootCertificates.map(capnpString).join(",\n ");
228312 const services = [
229313 '( name = "main", worker = .mainWorker )',
230314 '( name = "blocked", worker = .blockedWorker )',
315+ ...(input.globalOutbound === "internet"
316+ ? [
317+ `( name = "internet", network = ( allow = ["public"], tlsOptions = ( trustedCertificates = [\n ${trustedCertificates}\n ] ) ) )`,
318+ ]
319+ : []),
231320 ...(input.hostPort === null
232321 ? []
233322 : [`( name = "host", external = ( address = "127.0.0.1:${input.hostPort}", http = () ) )`]),
@@ -426,7 +515,10 @@ export const createWorkerdModuleRunner = ( · 192 unmodified lines
426515 });
427516 proc.stderr.setEncoding("utf8");
428517 proc.stdout.resume();
429− proc.stderr.resume();
518+ let stderrTail = "";
519+ proc.stderr.on("data", (chunk: string) => {
520+ stderrTail = (stderrTail + chunk).slice(-4000);
521+ });
430522 proc.on("exit", (code, signal) => {
431523 lastExitAt = Date.now();
432524 if (processState?.proc === proc) {
@@ -444,7 +536,10 @@ export const createWorkerdModuleRunner = ( · 11 unmodified lines
444536 await waitReady(listenPort, token, startupTimeoutMs);
445537 } catch (cause) {
446538 await disposeState(true);
447− throw cause;
539+ const detail = stderrTail.trim();
540+ throw detail.length === 0
541+ ? cause
542+ : new Error(`${normalizeError(cause).message}\nworkerd stderr:\n${detail}`);
448543 }
449544 })();
450545
packages/plugins/apps/package.json+25−1
@@ -20,6 +20,8 @@ · 19 unmodified lines
2020 "./api": "./src/api.ts",
2121 "./authoring": "./src/authoring.ts",
2222 "./cloud": "./src/cloud.ts",
23+ "./client": "./src/react/plugin-client.tsx",
24+ "./react": "./src/react/index.ts",
2325 "./selfhost": "./src/selfhost.ts",
2426 "./testing": "./src/testing/index.ts"
2527 },
@@ -39,14 +41,36 @@ · 13 unmodified lines
3941 "zod": "4.3.6"
4042 },
4143 "devDependencies": {
44+ "@effect/atom-react": "catalog:",
4245 "@effect/vitest": "catalog:",
46+ "@executor-js/react": "workspace:*",
4347 "@types/node": "catalog:",
48+ "@types/react": "catalog:",
4449 "bun-types": "catalog:",
4550 "effect": "catalog:",
51+ "react": "catalog:",
4652 "tsup": "catalog:",
4753 "vitest": "catalog:"
4854 },
4955 "peerDependencies": {
50− "effect": "catalog:"
56+ "@effect/atom-react": "catalog:",
57+ "@executor-js/react": "workspace:*",
58+ "@tanstack/react-router": "catalog:",
59+ "effect": "catalog:",
60+ "react": "catalog:"
61+ },
62+ "peerDependenciesMeta": {
63+ "@effect/atom-react": {
64+ "optional": true
65+ },
66+ "@executor-js/react": {
67+ "optional": true
68+ },
69+ "@tanstack/react-router": {
70+ "optional": true
71+ },
72+ "react": {
73+ "optional": true
74+ }
5175 }
5276 }
packages/plugins/apps/src/api.ts+4−4
@@ -1,8 +1,8 @@
11 import { definePlugin } from "@executor-js/sdk/core";
22
33 import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin";
4−import { AppsExtensionService, AppsHandlers } from "./plugin/handlers";
5−import { AppsGroup } from "./plugin/routes";
4+import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers";
5+import { appsGroupForSourceKinds } from "./plugin/routes";
66
77 export { AppsGroup } from "./plugin/routes";
88 export { AppsHandlers, AppsExtensionService } from "./plugin/handlers";
@@ -12,7 +12,7 @@ export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; · 3 unmodified lines
1212
1313 export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({
1414 ...makeAppsPlugin(options),
15− routes: () => AppsGroup,
16− handlers: () => AppsHandlers,
15+ routes: () => appsGroupForSourceKinds(options?.sourceKinds),
16+ handlers: () => appsHandlersForSourceKinds(options?.sourceKinds),
1717 extensionService: AppsExtensionService,
1818 }));
packages/plugins/apps/src/executor/app-tool-executor.ts+40−10
@@ -1,7 +1,6 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- boundary: in-process dynamic-import backing converts JavaScript exceptions into typed AppExecutorError */
22 import { Buffer } from "node:buffer";
33 import { Data, Effect, Predicate } from "effect";
4−import { z } from "zod";
54
65 import { validToolKey } from "../pipeline/discover";
76 import { stableStringify } from "../pipeline/descriptor";
@@ -114,13 +113,39 @@ const validateStandard = async ( · 106 unmodified lines
114113 return isRecord(result) && "value" in result ? result.value : value;
115114 };
116115
117−const jsonSchemaFor = (schema: unknown): unknown => {
116+const jsonSchemaFor = (
117+ schema: unknown,
118+ side: "input" | "output",
119+ toolName: string,
120+ sourcePath: string,
121+): unknown => {
118122 if (schema === undefined) return undefined;
119123 if (isRecord(schema) && isRecord(schema["~standard"])) {
124+ const jsonSchema = schema["~standard"].jsonSchema;
125+ if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") {
126+ throw new AppExecutorError({
127+ kind: "collect",
128+ message: `tool "${toolName}" ${side} schema library does not expose the Standard Schema jsonSchema extension`,
129+ diagnostics: [
130+ {
131+ path: sourcePath,
132+ message: `${side} schema library does not expose the Standard Schema jsonSchema extension`,
133+ },
134+ ],
135+ });
136+ }
120137 try {
121− return z.toJSONSchema(schema as unknown as z.ZodType);
122− } catch {
123− return {};
138+ const converted = jsonSchema[side]({ target: "draft-2020-12" });
139+ if (!isRecord(converted)) return converted;
140+ const { $schema: _metaSchema, ...withoutMetaSchema } = converted;
141+ return withoutMetaSchema;
142+ } catch (cause) {
143+ throw new AppExecutorError({
144+ kind: "collect",
145+ message: `tool "${toolName}" ${side} schema conversion failed`,
146+ diagnostics: [{ path: sourcePath, message: `${side} schema conversion failed` }],
147+ cause,
148+ });
124149 }
125150 }
126151 return schema;
@@ -243,7 +268,7 @@ const collectFromExport = ( · 116 unmodified lines
243268 sourcePath: string,
244269 ): CollectedModule => {
245270 if (isDefinedTool(exported)) {
246− const inputJsonSchema = jsonSchemaFor(exported.input);
271+ const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath);
247272 const integrations = collectIntegrations(
248273 fileSlug,
249274 sourcePath,
@@ -257,7 +282,7 @@ const collectFromExport = ( · 7 unmodified lines
257282 description: exported.description,
258283 integrations,
259284 inputSchema: projectInputSchema(inputJsonSchema, integrations),
260− outputSchema: jsonSchemaFor(exported.output),
285+ outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath),
261286 annotations: exported.annotations,
262287 },
263288 ],
@@ -296,7 +321,7 @@ const collectFromExport = ( · 32 unmodified lines
296321 diagnostics: [{ path: sourcePath, message: `duplicate tool name "${toolName}"` }],
297322 });
298323 }
299− const inputJsonSchema = jsonSchemaFor(value.input);
324+ const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath);
300325 const integrations = collectIntegrations(
301326 toolName,
302327 sourcePath,
@@ -310,7 +335,7 @@ const collectFromExport = ( · 7 unmodified lines
310335 description: value.description,
311336 integrations,
312337 inputSchema: projectInputSchema(inputJsonSchema, integrations),
313− outputSchema: jsonSchemaFor(value.output),
338+ outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath),
314339 annotations: value.annotations,
315340 });
316341 }
@@ -442,7 +467,12 @@ export const makeInProcessAppToolExecutor = (): AppToolExecutor => ({ · 125 unmodified lines
442467 message: `tool not found in bundle: ${entry.toolName}`,
443468 });
444469 }
445− const inputJsonSchema = jsonSchemaFor(tool.input);
470+ const inputJsonSchema = jsonSchemaFor(
471+ tool.input,
472+ "input",
473+ entry.toolName,
474+ entry.toolName,
475+ );
446476 const integrations = collectIntegrations(
447477 entry.toolName,
448478 entry.toolName,
packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts+21−6
@@ -112,7 +112,22 @@ const integrationDeclaration = (value) => { · 111 unmodified lines
112112 if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null;
113113 return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) };
114114 };
115−const jsonSchemaFor = (schema) => schema === undefined ? undefined : (isRecord(schema) && isRecord(schema["~standard"]) ? {} : schema);
115+const jsonSchemaFor = (schema, side, toolName, sourcePath) => {
116+ if (schema === undefined) return undefined;
117+ if (!isRecord(schema) || !isRecord(schema["~standard"])) return schema;
118+ const jsonSchema = schema["~standard"].jsonSchema;
119+ if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") {
120+ throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema library does not expose the Standard Schema jsonSchema extension', [issue(sourcePath, side + " schema library does not expose the Standard Schema jsonSchema extension")]);
121+ }
122+ try {
123+ const converted = jsonSchema[side]({ target: "draft-2020-12" });
124+ if (!isRecord(converted)) return converted;
125+ const { $schema: _metaSchema, ...withoutMetaSchema } = converted;
126+ return withoutMetaSchema;
127+ } catch (cause) {
128+ throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema conversion failed', [issue(sourcePath, side + " schema conversion failed")], cause);
129+ }
130+};
116131 const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => {
117132 if (declarations === undefined) return {};
118133 if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]);
@@ -138,9 +153,9 @@ const projectInputSchema = (inputJsonSchema, integrations) => { · 19 unmodified lines
138153 };
139154 const collectFromExport = (exported, fileSlug, sourcePath) => {
140155 if (isDefinedTool(exported)) {
141− const inputJsonSchema = jsonSchemaFor(exported.input);
156+ const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath);
142157 const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema);
143− return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output), annotations: exported.annotations }] };
158+ return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), annotations: exported.annotations }] };
144159 }
145160 if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]);
146161 const tools = [];
@@ -151,10 +166,10 @@ const collectFromExport = (exported, fileSlug, sourcePath) => { · 4 unmodified lines
151166 if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]);
152167 const toolName = fileSlug + "__" + key;
153168 if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]);
154− const inputJsonSchema = jsonSchemaFor(value.input);
169+ const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath);
155170 const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema);
156171 seen.add(toolName);
157− tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output), annotations: value.annotations });
172+ tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), annotations: value.annotations });
158173 }
159174 if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]);
160175 return { tools };
@@ -220,7 +235,7 @@ export default class AppExecutor extends WorkerEntrypoint { · 59 unmodified lines
220235 const exported = await resolveDefault();
221236 const tool = selectTool(exported, input.toolName);
222237 if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName);
223− const inputJsonSchema = jsonSchemaFor(tool.input);
238+ const inputJsonSchema = jsonSchemaFor(tool.input, "input", input.toolName, input.toolName);
224239 const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema);
225240 const split = splitInvokeInput(bridge, input.toolName, input.input, integrations);
226241 const decoded = await validateStandard(tool.input, split.input, "input");
packages/plugins/apps/src/executor/workerd-app-tool-executor.ts+23−8
@@ -1,4 +1,4 @@
1−/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- workerd boundary converts worker envelopes into typed AppExecutorError */
1+/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error, executor/no-instanceof-error, executor/no-unknown-error-message -- workerd boundary converts worker envelopes and subprocess startup failures into typed AppExecutorError */
22 import { Effect } from "effect";
33
44 import {
@@ -47,7 +47,7 @@ const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: strin · 42 unmodified lines
4747 ? cause
4848 : new AppExecutorError({
4949 kind,
50− message,
50+ message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`,
5151 cause,
5252 });
5353
@@ -86,7 +86,22 @@ const integrationDeclaration = (value) => { · 32 unmodified lines
8686 if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null;
8787 return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) };
8888 };
89−const jsonSchemaFor = (schema) => schema === undefined ? undefined : (isRecord(schema) && isRecord(schema["~standard"]) ? {} : schema);
89+const jsonSchemaFor = (schema, side, toolName, sourcePath) => {
90+ if (schema === undefined) return undefined;
91+ if (!isRecord(schema) || !isRecord(schema["~standard"])) return schema;
92+ const jsonSchema = schema["~standard"].jsonSchema;
93+ if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") {
94+ throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema library does not expose the Standard Schema jsonSchema extension', [issue(sourcePath, side + " schema library does not expose the Standard Schema jsonSchema extension")]);
95+ }
96+ try {
97+ const converted = jsonSchema[side]({ target: "draft-2020-12" });
98+ if (!isRecord(converted)) return converted;
99+ const { $schema: _metaSchema, ...withoutMetaSchema } = converted;
100+ return withoutMetaSchema;
101+ } catch (cause) {
102+ throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema conversion failed', [issue(sourcePath, side + " schema conversion failed")], cause);
103+ }
104+};
90105 const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => {
91106 if (declarations === undefined) return {};
92107 if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]);
@@ -116,9 +131,9 @@ const projectInputSchema = (inputJsonSchema, integrations) => { · 23 unmodified lines
116131 };
117132 const collectFromExport = (exported, fileSlug, sourcePath) => {
118133 if (isDefinedTool(exported)) {
119− const inputJsonSchema = jsonSchemaFor(exported.input);
134+ const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath);
120135 const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema);
121− return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output), annotations: exported.annotations }] };
136+ return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), annotations: exported.annotations }] };
122137 }
123138 if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]);
124139 const tools = [];
@@ -129,10 +144,10 @@ const collectFromExport = (exported, fileSlug, sourcePath) => { · 4 unmodified lines
129144 if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]);
130145 const toolName = fileSlug + "__" + key;
131146 if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]);
132− const inputJsonSchema = jsonSchemaFor(value.input);
147+ const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath);
133148 const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema);
134149 seen.add(toolName);
135− tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output), annotations: value.annotations });
150+ tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), annotations: value.annotations });
136151 }
137152 if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]);
138153 return { tools };
@@ -208,7 +223,7 @@ export default { · 69 unmodified lines
208223 const exported = await resolveDefault();
209224 const tool = selectTool(exported, input.toolName);
210225 if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName);
211− const inputJsonSchema = jsonSchemaFor(tool.input);
226+ const inputJsonSchema = jsonSchemaFor(tool.input, "input", input.toolName, input.toolName);
212227 const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema);
213228 const split = splitInvokeInput(env, input.toolName, input.input, integrations);
214229 const decoded = await validateStandard(tool.input, split.input, "input");
packages/plugins/apps/src/git-client/url-security.ts+8−2
@@ -136,12 +136,18 @@ export const isPrivateGitHost = (host: string): boolean => { · 135 unmodified lines
136136
137137 export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {
138138 const url = new URL(input);
139− if (url.protocol !== "https:") throw new Error("git source URL must use https");
139+ const privateHost = isPrivateGitHost(url.hostname);
140+ if (
141+ url.protocol !== "https:" &&
142+ !(url.protocol === "http:" && policy.allowPrivateHosts === true && privateHost)
143+ ) {
144+ throw new Error("git source URL must use https");
145+ }
140146 if (url.username || url.password)
141147 throw new Error("git source URL must not include embedded credentials");
142148 if (hasTokenLikeQuery(url))
143149 throw new Error("git source URL must not include token query parameters");
144− if (policy.allowPrivateHosts !== true && isPrivateGitHost(url.hostname)) {
150+ if (policy.allowPrivateHosts !== true && privateHost) {
145151 throw new Error("git source URL host is not allowed");
146152 }
147153 return url;
packages/plugins/apps/src/pipeline/discover.ts+12−3
@@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines
2929 const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;
3030 const DEFERRED_RE = /^(workflows|ui|skills)\//;
3131
32+/** Reserved top-level app source folders whose contents are deferred by discovery. */
33+export const RESERVED_ARTIFACT_DIRS = ["workflows", "ui", "skills"] as const;
34+
35+/** Returns the discovered tool key for a path when it matches the canonical tool layout. */
36+export const toolKeyFromPath = (path: string): string | null => path.match(TOOL_RE)?.[1] ?? null;
37+
38+/** Returns true when a path is under a reserved app source folder. */
39+export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path);
40+
3241 export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key);
3342
3443 export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => {
@@ -39,9 +48,9 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 4 unmodified lines
3948
4049 for (const [path] of files) {
4150 if (path === "executor.json") continue;
42− const tool = path.match(TOOL_RE);
51+ const tool = toolKeyFromPath(path);
4352 if (tool) {
44− const name = tool[1]!;
53+ const name = tool;
4554 if (seen.has(name)) {
4655 diagnostics.push({ path, message: `duplicate tool identity: ${name}` });
4756 } else {
@@ -54,7 +63,7 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 6 unmodified lines
5463 diagnostics.push({ path, message: "file does not match the expected layout for tools/" });
5564 continue;
5665 }
57− if (DEFERRED_RE.test(path)) skipped.push({ path, reason: "not supported yet" });
66+ if (isReservedArtifactPath(path)) skipped.push({ path, reason: "not supported yet" });
5867 }
5968
6069 if (diagnostics.length > 0) {
packages/plugins/apps/src/pipeline/publish.test.ts+29−1
@@ -58,7 +58,18 @@ const makeMemoryStore = (): AppsStore & { · 57 unmodified lines
5858 for (const key of rows.keys()) rows.delete(key);
5959 for (const item of next.tools) rows.set(item.name, item);
6060 }),
61− listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []),
61+ removePublished: (app) =>
62+ Effect.sync(() => {
63+ if (descriptor?.app === app) {
64+ descriptor = null;
65+ descriptorKey = null;
66+ }
67+ for (const key of rows.keys()) rows.delete(key);
68+ }),
69+ listActiveTools: () =>
70+ Effect.sync(
71+ () => descriptor?.tools.map((tool) => ({ ...tool, app: descriptor?.app ?? "" })) ?? [],
72+ ),
6273 getTool: (name) =>
6374 Effect.sync(() => {
6475 const row = descriptor?.tools.find((item) => item.name === name);
@@ -75,6 +86,23 @@ const makeMemoryStore = (): AppsStore & { · 10 unmodified lines
7586 }
7687 : null;
7788 }),
89+ getToolForApp: (app, name) =>
90+ Effect.sync(() => {
91+ const row =
92+ descriptor?.app === app ? descriptor.tools.find((item) => item.name === name) : undefined;
93+ return row
94+ ? {
95+ app: descriptor!.app,
96+ name: row.name,
97+ bundleKey: row.bundleKey,
98+ description: row.description,
99+ inputSchema: row.inputSchema,
100+ outputSchema: row.outputSchema,
101+ integrations: row.integrations,
102+ annotations: row.annotations,
103+ }
104+ : null;
105+ }),
78106 putSource: () => Effect.void,
79107 listSources: () => Effect.succeed([]),
80108 getSource: () => Effect.succeed(null),
packages/plugins/apps/src/pipeline/worker-bundler.test.ts+5−0
@@ -63,6 +63,11 @@ describe("worker-bundler backend", () => { · 62 unmodified lines
6363 sourcePath: "tools/greet.ts",
6464 });
6565 expect(collected.tools.map((tool) => tool.toolName)).toEqual(["greet"]);
66+ expect(collected.tools[0]?.inputSchema).toMatchObject({
67+ type: "object",
68+ properties: { name: { type: "string" } },
69+ required: ["name"],
70+ });
6671 const invoked = yield* executor.invoke(
6772 bundled.code,
6873 { toolName: "greet" },
packages/plugins/apps/src/pipeline/worker-bundler.ts+25−14
@@ -1,4 +1,5 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-error-constructor, executor/no-json-parse, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: subprocess-backed bundler validates package JSON and converts worker failures into typed AppExecutorError */
2+import { existsSync } from "node:fs";
23 import { readFile } from "node:fs/promises";
34 import { dirname, join } from "node:path";
45 import { fileURLToPath } from "node:url";
@@ -7,7 +8,6 @@ import { · 2 unmodified lines
78 createWorkerdModuleRunner,
89 WORKERD_VERSION,
910 } from "@executor-js/runtime-workerd-subprocess";
10−import { build } from "esbuild";
1111 import { Effect } from "effect";
1212
1313 import { AppExecutorError } from "../executor/app-tool-executor";
@@ -18,7 +18,9 @@ import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker · 4 unmodified lines
1818
1919 const textEncoder = new TextEncoder();
2020 const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
21−const workerBundlerDir = join(packageDir, "node_modules", "@cloudflare", "worker-bundler");
21+const workerBundlerDir =
22+ process.env.EXECUTOR_WORKER_BUNDLER_DIR ??
23+ join(packageDir, "node_modules", "@cloudflare", "worker-bundler");
2224
2325 const executorAppSource = `
2426 const makeIntegrationDeclaration = (state) => Object.freeze({
@@ -79,6 +81,10 @@ export default { · 54 unmodified lines
7981 if (code === null) {
8082 return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule });
8183 }
84+ const installFailure = (result.warnings ?? []).find((warning) => /failed to install/i.test(String(warning)));
85+ if (installFailure) {
86+ return json({ ok: false, message: String(installFailure) });
87+ }
8288 for (const [path, module] of Object.entries(result.modules)) {
8389 const source = moduleCode(module) ?? "";
8490 if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) {
@@ -97,19 +103,24 @@ const bundledWorkerBundler = async (): Promise<{ · 12 unmodified lines
97103 readonly source: string;
98104 readonly wasm: Uint8Array;
99105 }> => {
100− const entry = join(workerBundlerDir, "dist", "index.js");
101− const [result, wasm] = await Promise.all([
102− build({
103− entryPoints: [entry],
104− bundle: true,
105− format: "esm",
106− platform: "browser",
107− write: false,
108− external: ["./esbuild.wasm"],
109− logLevel: "silent",
110− }),
111− readFile(join(workerBundlerDir, "dist", "esbuild.wasm")),
106+ const distDir = join(workerBundlerDir, "dist");
107+ const bundledEntry = join(distDir, "index.bundled.js");
108+ const [sourceFromDisk, wasm] = await Promise.all([
109+ existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null),
110+ readFile(join(distDir, "esbuild.wasm")),
112111 ]);
112+ if (sourceFromDisk !== null) return { source: sourceFromDisk, wasm };
113+
114+ const { build } = await import("esbuild");
115+ const result = await build({
116+ entryPoints: [join(distDir, "index.js")],
117+ bundle: true,
118+ format: "esm",
119+ platform: "browser",
120+ write: false,
121+ external: ["./esbuild.wasm"],
122+ logLevel: "silent",
123+ });
113124 const source = result.outputFiles[0]?.text;
114125 if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
115126 return { source, wasm };
packages/plugins/apps/src/plugin/apps-plugin.test.ts+369−35
@@ -1,10 +1,12 @@
11 /* oxlint-disable executor/no-try-catch-or-throw -- boundary: test temporarily overrides global fetch and restores it in finally */
2−import { readFile } from "node:fs/promises";
2+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
34 import { join } from "node:path";
45
56 import { describe, expect, it } from "@effect/vitest";
67 import { Effect, Exit } from "effect";
7−import { ToolResult } from "@executor-js/sdk";
8+import { IntegrationSlug, ToolResult, createExecutor, definePlugin } from "@executor-js/sdk";
9+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
810
911 import { FLUSH, pktLine } from "../git-client/pktline";
1012 import { bundleEntry } from "../pipeline/bundle";
@@ -19,6 +21,45 @@ const bundle = (source: string) => · 8 unmodified lines
1921 entry: "tools/sync.ts",
2022 });
2123
24+const localAppDir = (toolName: string, message: string) =>
25+ Effect.acquireRelease(
26+ Effect.promise(async () => {
27+ const root = await mkdtemp(join(tmpdir(), "executor-apps-test-"));
28+ await mkdir(join(root, "tools"));
29+ await writeFile(
30+ join(root, "tools", `${toolName}.ts`),
31+ `
32+ import { z } from "zod";
33+ import { defineTool } from "executor:app";
34+ export default defineTool({
35+ name: "${toolName}",
36+ description: "${message}",
37+ input: z.object({}),
38+ async handler() {
39+ return { message: "${message}" };
40+ },
41+ });
42+ `,
43+ );
44+ return root;
45+ }),
46+ (root) => Effect.promise(() => rm(root, { recursive: true, force: true })),
47+ );
48+
49+const collidingPlugin = definePlugin(() => ({
50+ id: "collider",
51+ storage: () => ({}),
52+ extension: (ctx) => ({
53+ seed: () =>
54+ ctx.core.integrations.register({
55+ slug: IntegrationSlug.make("github"),
56+ name: "GitHub",
57+ description: "Existing integration",
58+ config: {},
59+ }),
60+ }),
61+}))();
62+
2263 const concat = (parts: readonly Uint8Array[]): Uint8Array => {
2364 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
2465 let offset = 0;
@@ -49,15 +90,14 @@ const sideBand = (bytes: Uint8Array): Uint8Array => { · 24 unmodified lines
4990
5091 const makeSyncStore = (): AppsStore & {
5192 readonly sources: Map<string, AppSourceRecord>;
52− readonly descriptor: () => AppDescriptor | null;
93+ readonly descriptor: (app: string) => AppDescriptor | null;
5394 } => {
5495 const blobs = new Map<string, string>();
5596 const sources = new Map<string, AppSourceRecord>();
56− let descriptor: AppDescriptor | null = null;
57− let descriptorKey: string | null = null;
97+ const descriptors = new Map<string, { descriptor: AppDescriptor; descriptorKey: string }>();
5898 return {
5999 sources,
60− descriptor: () => descriptor,
100+ descriptor: (app) => descriptors.get(app)?.descriptor ?? null,
61101 putBlob: (body) =>
62102 Effect.sync(() => {
63103 const key = `blob:${blobs.size}`;
@@ -65,31 +105,63 @@ const makeSyncStore = (): AppsStore & { · 1 unmodified line
65105 return key;
66106 }),
67107 getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null),
68− getDescriptorRecord: () =>
69− Effect.sync(() =>
70− descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null,
71− ),
108+ getDescriptorRecord: (app) =>
109+ Effect.sync(() => {
110+ const current = descriptors.get(app);
111+ return current
112+ ? {
113+ sourceRef: current.descriptor.sourceRef,
114+ descriptorKey: current.descriptorKey,
115+ }
116+ : null;
117+ }),
72118 putPublished: (next, nextDescriptorKey) =>
73119 Effect.sync(() => {
74− descriptor = next;
75− descriptorKey = nextDescriptorKey;
120+ descriptors.set(next.app, { descriptor: next, descriptorKey: nextDescriptorKey });
121+ }),
122+ removePublished: (app) =>
123+ Effect.sync(() => {
124+ descriptors.delete(app);
76125 }),
77− listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []),
126+ listActiveTools: () =>
127+ Effect.sync(() =>
128+ [...descriptors.values()].flatMap(({ descriptor }) =>
129+ descriptor.tools.map((tool) => ({ ...tool, app: descriptor.app })),
130+ ),
131+ ),
78132 getTool: (name) =>
79133 Effect.sync(() => {
134+ const descriptor = [...descriptors.values()].find((entry) =>
135+ entry.descriptor.tools.some((item) => item.name === name),
136+ )?.descriptor;
80137 const tool = descriptor?.tools.find((item) => item.name === name);
81− return tool
82− ? {
83− app: descriptor!.app,
84− name: tool.name,
85− bundleKey: tool.bundleKey,
86− description: tool.description,
87− inputSchema: tool.inputSchema,
88− outputSchema: tool.outputSchema,
89− integrations: tool.integrations,
90− annotations: tool.annotations,
91− }
92− : null;
138+ if (!descriptor || !tool) return null;
139+ return {
140+ app: descriptor.app,
141+ name: tool.name,
142+ bundleKey: tool.bundleKey,
143+ description: tool.description,
144+ inputSchema: tool.inputSchema,
145+ outputSchema: tool.outputSchema,
146+ integrations: tool.integrations,
147+ annotations: tool.annotations,
148+ };
149+ }),
150+ getToolForApp: (app, name) =>
151+ Effect.sync(() => {
152+ const descriptor = descriptors.get(app)?.descriptor;
153+ const tool = descriptor?.tools.find((item) => item.name === name);
154+ if (!descriptor || !tool) return null;
155+ return {
156+ app: descriptor.app,
157+ name: tool.name,
158+ bundleKey: tool.bundleKey,
159+ description: tool.description,
160+ inputSchema: tool.inputSchema,
161+ outputSchema: tool.outputSchema,
162+ integrations: tool.integrations,
163+ annotations: tool.annotations,
164+ };
93165 }),
94166 putSource: (record) =>
95167 Effect.sync(() => {
@@ -145,6 +217,7 @@ const makeInvokeStore = (input: { · 49 unmodified lines
145217 getBlob: () => Effect.succeed(input.bundle),
146218 getDescriptorRecord: () => Effect.succeed(null),
147219 putPublished: () => Effect.void,
220+ removePublished: () => Effect.void,
148221 listActiveTools: () => Effect.succeed([]),
149222 getTool: () =>
150223 Effect.succeed({
@@ -154,6 +227,14 @@ const makeInvokeStore = (input: { · 3 unmodified lines
154227 description: "Sync",
155228 integrations: input.integrations,
156229 }),
230+ getToolForApp: () =>
231+ Effect.succeed({
232+ app: "crm",
233+ name: "sync",
234+ bundleKey: "bundle",
235+ description: "Sync",
236+ integrations: input.integrations,
237+ }),
157238 putSource: () => Effect.void,
158239 listSources: () => Effect.succeed([]),
159240 getSource: () => Effect.succeed(null),
@@ -190,6 +271,7 @@ describe("apps plugin schema projection", () => { · 30 unmodified lines
190271 getBlob: () => Effect.succeed(null),
191272 getDescriptorRecord: () => Effect.succeed(null),
192273 putPublished: () => Effect.void,
274+ removePublished: () => Effect.void,
193275 listActiveTools: () => Effect.succeed([]),
194276 putSource: () => Effect.void,
195277 listSources: () => Effect.succeed([]),
@@ -206,6 +288,17 @@ describe("apps plugin schema projection", () => { · 10 unmodified lines
206288 inboxes: { slug: "gmail", mode: "many" },
207289 },
208290 }),
291+ getToolForApp: () =>
292+ Effect.succeed({
293+ app: "crm",
294+ name: "sync",
295+ bundleKey: "bundle",
296+ description: "Sync",
297+ integrations: {
298+ crm: { slug: "dealcloud", mode: "one" },
299+ inboxes: { slug: "gmail", mode: "many" },
300+ },
301+ }),
209302 };
210303 const result = yield* projectAppsToolSchema(
211304 {
@@ -219,6 +312,7 @@ describe("apps plugin schema projection", () => { · 7 unmodified lines
219312 ),
220313 },
221314 } as never,
315+ "crm",
222316 "sync",
223317 {
224318 type: "object",
@@ -233,15 +327,25 @@ describe("apps plugin schema projection", () => { · 8 unmodified lines
233327 );
234328 expect(result.inputSchema).toMatchObject({
235329 properties: {
236− crm: { enum: ["tools.dealcloud.org.main"] },
330+ crm: {
331+ enum: ["tools.dealcloud.org.main"],
332+ default: "tools.dealcloud.org.main",
333+ },
237334 inboxes: {
335+ default: ["tools.gmail.org.work", "tools.gmail.user.personal"],
238336 items: {
239337 enum: ["tools.gmail.org.work", "tools.gmail.user.personal"],
240338 },
241339 },
242340 },
243− required: ["crm", "inboxes"],
244341 });
342+ // The response schema requires JSON values: a `required` key holding
343+ // undefined fails encoding (the /api/tools/schema 400), so the key must
344+ // be absent entirely when all required fields were projected away.
345+ expect("required" in (result.inputSchema as Record<string, unknown>)).toBe(false);
346+ expect(Object.values(result.inputSchema as Record<string, unknown>).includes(undefined)).toBe(
347+ false,
348+ );
245349 }),
246350 );
247351 });
@@ -303,6 +407,17 @@ describe("apps source sync", () => { · 55 unmodified lines
303407 }),
304408 );
305409
410+ it.effect("rejects directory listing when local-directory sources are disabled", () =>
411+ Effect.gen(function* () {
412+ const plugin = makeAppsPlugin({ sourceKinds: ["git"] });
413+ const extension = plugin.extension!({
414+ storage: makeSyncStore(),
415+ } as never);
416+ const exit = yield* Effect.exit(extension.listDirs({ path: "/tmp" }));
417+ expect(Exit.isFailure(exit)).toBe(true);
418+ }),
419+ );
420+
306421 it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () =>
307422 Effect.gen(function* () {
308423 const fixture = yield* Effect.promise(() => fixtureFetch());
@@ -314,7 +429,8 @@ describe("apps source sync", () => { · 5 unmodified lines
314429 executor: makeInProcessAppToolExecutor(),
315430 allowPrivateGitHosts: true,
316431 });
317− let connectionExists = false;
432+ const integrations = new Set<string>();
433+ const connections = new Set<string>();
318434 const extension = plugin.extension!({
319435 storage: store,
320436 providers: {
@@ -324,16 +440,31 @@ describe("apps source sync", () => { · 3 unmodified lines
324440 },
325441 core: {
326442 integrations: {
327− register: () => Effect.void,
443+ get: (slug: unknown) =>
444+ Effect.sync(() =>
445+ integrations.has(String(slug))
446+ ? ({ slug, kind: "apps", description: "", canRemove: true } as never)
447+ : null,
448+ ),
449+ register: (input: { readonly slug: unknown }) =>
450+ Effect.sync(() => {
451+ integrations.add(String(input.slug));
452+ }),
453+ remove: (slug: unknown) =>
454+ Effect.sync(() => {
455+ integrations.delete(String(slug));
456+ connections.delete(String(slug));
457+ }),
328458 },
329459 },
330460 connections: {
331461 list: () => Effect.succeed([]),
332462 resolveValue: () => Effect.succeed(null),
333− get: () => Effect.succeed(connectionExists ? ({} as never) : null),
334− create: () =>
463+ get: ({ integration }: { readonly integration: unknown }) =>
464+ Effect.succeed(connections.has(String(integration)) ? ({} as never) : null),
465+ create: ({ integration }: { readonly integration: unknown }) =>
335466 Effect.sync(() => {
336− connectionExists = true;
467+ connections.add(String(integration));
337468 }),
338469 refresh: () => Effect.succeed([]),
339470 },
@@ -359,12 +490,215 @@ describe("apps source sync", () => { · 19 unmodified lines
359490 expect(changed.status).toBe("published");
360491 expect(changed.sourceRef).not.toBe(first.sourceRef);
361492 expect(fixture.packRequests()).toBe(2);
362− expect(store.descriptor()?.sourceRef).toBe(changed.sourceRef);
493+ expect(store.descriptor("fixture")?.sourceRef).toBe(changed.sourceRef);
363494 } finally {
364495 globalThis.fetch = originalFetch;
365496 }
366497 }),
367498 );
499+
500+ it.effect("registers each app as its own integration with disjoint tool addresses", () =>
501+ Effect.scoped(
502+ Effect.gen(function* () {
503+ const alphaDir = yield* localAppDir("echo", "alpha");
504+ const betaDir = yield* localAppDir("echo", "beta");
505+ const executor = yield* createExecutor(
506+ makeTestConfig({
507+ plugins: [
508+ memoryCredentialsPlugin(),
509+ makeAppsPlugin({ sourceKinds: ["local-directory"] }),
510+ ] as const,
511+ }),
512+ );
513+
514+ yield* executor.apps.createSource({
515+ kind: "local-directory",
516+ slug: "alpha-source",
517+ app: "alpha-tools",
518+ path: alphaDir,
519+ });
520+ yield* executor.apps.createSource({
521+ kind: "local-directory",
522+ slug: "beta-source",
523+ app: "beta-tools",
524+ path: betaDir,
525+ });
526+
527+ const alphaSync = yield* executor.apps.syncSource("alpha-source");
528+ const betaSync = yield* executor.apps.syncSource("beta-source");
529+ expect(alphaSync.status).toBe("published");
530+ expect(betaSync.status).toBe("published");
531+
532+ const alphaIntegration = yield* executor.integrations.get(
533+ IntegrationSlug.make("alpha-tools"),
534+ );
535+ const betaIntegration = yield* executor.integrations.get(
536+ IntegrationSlug.make("beta-tools"),
537+ );
538+ expect(alphaIntegration?.name).toBe("alpha-tools");
539+ expect(betaIntegration?.name).toBe("beta-tools");
540+
541+ const alphaTools = yield* executor.tools.list({
542+ integration: IntegrationSlug.make("alpha-tools"),
543+ });
544+ const betaTools = yield* executor.tools.list({
545+ integration: IntegrationSlug.make("beta-tools"),
546+ });
547+ expect(alphaTools.map((tool) => String(tool.address))).toEqual([
548+ "tools.alpha-tools.org.published.echo",
549+ ]);
550+ expect(betaTools.map((tool) => String(tool.address))).toEqual([
551+ "tools.beta-tools.org.published.echo",
552+ ]);
553+ }),
554+ ),
555+ );
556+
557+ it.effect("rejects an app slug that collides with a non-app integration before publishing", () =>
558+ Effect.scoped(
559+ Effect.gen(function* () {
560+ const sourceDir = yield* localAppDir("echo", "collision");
561+ const executor = yield* createExecutor(
562+ makeTestConfig({
563+ plugins: [
564+ memoryCredentialsPlugin(),
565+ collidingPlugin,
566+ makeAppsPlugin({ sourceKinds: ["local-directory"] }),
567+ ] as const,
568+ }),
569+ );
570+ yield* executor.collider.seed();
571+ yield* executor.apps.createSource({
572+ kind: "local-directory",
573+ slug: "github-source",
574+ app: "github",
575+ path: sourceDir,
576+ });
577+
578+ const result = yield* executor.apps.syncSource("github-source");
579+ expect(result.status).toBe("failed");
580+ expect(result.errors?.[0]?.message).toContain("github");
581+ const tools = yield* executor.tools.list({ integration: IntegrationSlug.make("github") });
582+ expect(tools).toEqual([]);
583+ }),
584+ ),
585+ );
586+
587+ it.effect("keeps the app source panel reachable after a source-stage sync failure", () =>
588+ Effect.scoped(
589+ Effect.gen(function* () {
590+ const executor = yield* createExecutor(
591+ makeTestConfig({
592+ plugins: [
593+ memoryCredentialsPlugin(),
594+ makeAppsPlugin({ sourceKinds: ["local-directory"] }),
595+ ] as const,
596+ }),
597+ );
598+ yield* executor.apps.createSource({
599+ kind: "local-directory",
600+ slug: "missing-source",
601+ app: "missing-tools",
602+ path: "/definitely/missing/executor-app-source",
603+ });
604+
605+ const result = yield* executor.apps.syncSource("missing-source");
606+ expect(result.status).toBe("failed");
607+ expect(result.errors?.[0]?.stage).toBe("source");
608+ const integration = yield* executor.integrations.get(IntegrationSlug.make("missing-tools"));
609+ expect(integration?.kind).toBe("apps");
610+ expect(
611+ yield* executor.tools.list({ integration: IntegrationSlug.make("missing-tools") }),
612+ ).toEqual([]);
613+ }),
614+ ),
615+ );
616+
617+ it.effect("rejects two app sources with the same app slug", () =>
618+ Effect.scoped(
619+ Effect.gen(function* () {
620+ const firstDir = yield* localAppDir("first", "first");
621+ const secondDir = yield* localAppDir("second", "second");
622+ const executor = yield* createExecutor(
623+ makeTestConfig({
624+ plugins: [
625+ memoryCredentialsPlugin(),
626+ makeAppsPlugin({ sourceKinds: ["local-directory"] }),
627+ ] as const,
628+ }),
629+ );
630+ yield* executor.apps.createSource({
631+ kind: "local-directory",
632+ slug: "first-source",
633+ app: "shared-app",
634+ path: firstDir,
635+ });
636+ yield* executor.apps.createSource({
637+ kind: "local-directory",
638+ slug: "second-source",
639+ app: "shared-app",
640+ path: secondDir,
641+ });
642+
643+ const result = yield* executor.apps.syncSource("first-source");
644+ expect(result.status).toBe("failed");
645+ expect(result.errors?.[0]?.message).toContain("shared-app");
646+ const tools = yield* executor.tools.list({
647+ integration: IntegrationSlug.make("shared-app"),
648+ });
649+ expect(tools).toEqual([]);
650+ }),
651+ ),
652+ );
653+
654+ it.effect("deleteSource removes the app integration and republish recreates it", () =>
655+ Effect.scoped(
656+ Effect.gen(function* () {
657+ const sourceDir = yield* localAppDir("echo", "delete");
658+ const executor = yield* createExecutor(
659+ makeTestConfig({
660+ plugins: [
661+ memoryCredentialsPlugin(),
662+ makeAppsPlugin({ sourceKinds: ["local-directory"] }),
663+ ] as const,
664+ }),
665+ );
666+ yield* executor.apps.createSource({
667+ kind: "local-directory",
668+ slug: "delete-source",
669+ app: "delete-tools",
670+ path: sourceDir,
671+ });
672+ const firstSync = yield* executor.apps.syncSource("delete-source");
673+ expect(firstSync.status).toBe("published");
674+ expect(
675+ (yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") })).map(
676+ (tool) => String(tool.address),
677+ ),
678+ ).toEqual(["tools.delete-tools.org.published.echo"]);
679+
680+ yield* executor.apps.deleteSource("delete-source");
681+ expect(yield* executor.integrations.get(IntegrationSlug.make("delete-tools"))).toBe(null);
682+ expect(
683+ yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") }),
684+ ).toEqual([]);
685+
686+ yield* executor.apps.createSource({
687+ kind: "local-directory",
688+ slug: "delete-source",
689+ app: "delete-tools",
690+ path: sourceDir,
691+ });
692+ const secondSync = yield* executor.apps.syncSource("delete-source");
693+ expect(secondSync.status).toBe("published");
694+ expect(
695+ (yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") })).map(
696+ (tool) => String(tool.address),
697+ ),
698+ ).toEqual(["tools.delete-tools.org.published.echo"]);
699+ }),
700+ ),
701+ );
368702 });
369703
370704 describe("apps plugin invocation", () => {
@@ -392,7 +726,7 @@ describe("apps plugin invocation", () => { · 21 unmodified lines
392726 ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }),
393727 ),
394728 }),
395− toolRow: { name: "sync" },
729+ toolRow: { integration: "crm", name: "sync" },
396730 args: { crm: "tools.dealcloud.org.main" },
397731 } as never);
398732 expect(result).toMatchObject({
@@ -439,7 +773,7 @@ describe("apps plugin invocation", () => { · 40 unmodified lines
439773 ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }),
440774 ),
441775 }),
442− toolRow: { name: "sync" },
776+ toolRow: { integration: "crm", name: "sync" },
443777 args: { crm: "tools.dealcloud.org.main" },
444778 } as never);
445779 expect(result).toEqual({ fallback: true });
packages/plugins/apps/src/plugin/apps-plugin.ts+215−70
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 ToolName,
1010 ToolResult,
1111 definePlugin,
12+ IntegrationAlreadyExistsError,
1213 type PluginCtx,
1314 type ToolDef,
1415 } from "@executor-js/sdk";
@@ -33,11 +34,13 @@ import { · 18 unmodified lines
3334 type SyncDiagnostic,
3435 } from "../source/app-source";
3536 import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source";
36−import { fetchLocalDirectoryAppSource } from "../source/local-directory-source";
37+import {
38+ fetchLocalDirectoryAppSource,
39+ listLocalDirectoryDirs,
40+} from "../source/local-directory-source";
3741 import { AppSourceError, type AppSourceSnapshot } from "../source/app-source";
3842 import type { PublishError } from "../pipeline/publish";
3943
40−const APPS_INTEGRATION = IntegrationSlug.make("apps");
4144 const APPS_CONNECTION = ConnectionName.make("published");
4245 const APPS_NO_AUTH = AuthTemplateSlug.make("none");
4346
@@ -45,6 +48,10 @@ class AppPluginError extends Data.TaggedError("AppPluginError")<{ · 1 unmodified line
4548 readonly message: string;
4649 }> {}
4750
51+class AppSlugConflictError extends Data.TaggedError("AppSlugConflictError")<{
52+ readonly app: string;
53+}> {}
54+
4855 interface ProjectedToolSchema {
4956 readonly inputSchema?: unknown;
5057 readonly outputSchema?: unknown;
@@ -138,53 +145,109 @@ const sourceConfig = (input: CreateAppSourceInput): AppSourceConfig => { · 87 unmodified lines
138145 return { kind: "local-directory", path: input.path };
139146 };
140147
141−const ensureAppsCatalogConnection = (ctx: PluginCtx<AppsStore>): Effect.Effect<void, unknown> =>
148+const appDescription = (record: AppSourceRecord, description?: string): string => {
149+ if (description) return description;
150+ if (record.description) return record.description;
151+ if (record.config.kind === "git") return `Custom tools from ${record.config.url}`;
152+ return `Custom tools from ${record.config.path}`;
153+};
154+
155+const ensureAppIntegration = (
156+ ctx: PluginCtx<AppsStore>,
157+ record: AppSourceRecord,
158+ description?: string,
159+): Effect.Effect<void, unknown> =>
142160 Effect.gen(function* () {
161+ const appSlug = IntegrationSlug.make(record.app);
162+ const existing = yield* ctx.core.integrations.get(appSlug);
163+ if (existing && existing.kind !== "apps") return;
143164 yield* ctx.core.integrations.register({
144− slug: APPS_INTEGRATION,
145− name: "Apps",
146− description: "Published app tools",
165+ slug: appSlug,
166+ name: record.app,
167+ description: appDescription(record, description),
147168 config: {},
148169 });
170+ });
171+
172+const ensureAppConnection = (
173+ ctx: PluginCtx<AppsStore>,
174+ record: AppSourceRecord,
175+ description?: string,
176+): Effect.Effect<void, unknown> =>
177+ Effect.gen(function* () {
178+ const appSlug = IntegrationSlug.make(record.app);
179+ yield* ensureAppIntegration(ctx, record, description);
149180 const existing = yield* ctx.connections.get({
150181 owner: "org",
151− integration: APPS_INTEGRATION,
182+ integration: appSlug,
152183 name: APPS_CONNECTION,
153184 });
154185 if (existing) {
155186 yield* ctx.connections.refresh({
156187 owner: "org",
157− integration: APPS_INTEGRATION,
188+ integration: appSlug,
158189 name: APPS_CONNECTION,
159190 });
160191 return;
161192 }
162193 yield* ctx.connections.create({
163194 owner: "org",
164− integration: APPS_INTEGRATION,
195+ integration: appSlug,
165196 name: APPS_CONNECTION,
166197 template: APPS_NO_AUTH,
167198 values: {},
168199 });
169200 });
170201
171−const tokenItemId = (slug: string): ProviderItemId =>
172− ProviderItemId.make(`apps/source-tokens/${slug}`);
173−
174−const gitHubConnectionToken = (ctx: PluginCtx<AppsStore>): Effect.Effect<string | null, unknown> =>
202+const guardAppIntegration = (
203+ ctx: PluginCtx<AppsStore>,
204+ record: AppSourceRecord,
205+): Effect.Effect<void, unknown> =>
175206 Effect.gen(function* () {
176− const connections = yield* ctx.connections.list({
177− integration: IntegrationSlug.make("github"),
178− });
179− const connection = connections.find((item) => item.owner === "user") ?? connections[0];
180− if (!connection) return null;
181− return yield* ctx.connections.resolveValue({
182− owner: connection.owner,
183− integration: connection.integration,
184− name: connection.name,
185− });
207+ const appSlug = IntegrationSlug.make(record.app);
208+ const existing = yield* ctx.core.integrations.get(appSlug);
209+ if (existing && existing.kind !== "apps") {
210+ return yield* new IntegrationAlreadyExistsError({ slug: appSlug });
211+ }
212+ const sources = yield* ctx.storage.listSources();
213+ const duplicate = sources.find(
214+ (source) => source.slug !== record.slug && source.app === record.app,
215+ );
216+ if (duplicate) {
217+ return yield* new AppSlugConflictError({ app: record.app });
218+ }
186219 });
187220
221+const appRegistrationErrorToDiagnostic = (error: unknown): SyncDiagnostic => {
222+ if (Predicate.isTagged("IntegrationAlreadyExistsError")(error)) {
223+ const slug = String((error as IntegrationAlreadyExistsError).slug);
224+ return {
225+ stage: "project",
226+ message: `Integration slug collides with an existing integration: ${slug}`,
227+ diagnostics: [{ path: slug, message: `Integration slug already exists: ${slug}` }],
228+ };
229+ }
230+ if (Predicate.isTagged("AppSlugConflictError")(error)) {
231+ const app = (error as AppSlugConflictError).app;
232+ const message = `app source already exists for slug: ${app}`;
233+ return {
234+ stage: "project",
235+ message,
236+ diagnostics: [{ path: app, message }],
237+ };
238+ }
239+ return {
240+ stage: "project",
241+ message: "App publish failed",
242+ };
243+};
244+
245+const tokenItemId = (slug: string): ProviderItemId =>
246+ ProviderItemId.make(`apps/source-tokens/${slug}`);
247+
248+// A source authenticates only with its own explicitly provided token. No
249+// implicit credential sharing: a stored GitHub connection must never leak
250+// into git fetches the user did not tie to it.
188251 const gitTokenFor = (
189252 ctx: PluginCtx<AppsStore>,
190253 config: Extract<AppSourceConfig, { readonly kind: "git" }>,
@@ -196,9 +259,7 @@ const gitTokenFor = ( · 5 unmodified lines
196259 ProviderItemId.make(config.tokenItemId),
197260 );
198261 }
199− const url = yield* parseGitSourceUrl(config.url).pipe(Effect.result);
200− if (Result.isFailure(url) || url.success.hostname.toLowerCase() !== "github.com") return null;
201− return yield* gitHubConnectionToken(ctx);
262+ return null;
202263 });
203264
204265 const activeToolNamesFor = (
@@ -207,13 +268,7 @@ const activeToolNamesFor = ( · 2 unmodified lines
207268 ): Effect.Effect<readonly string[], unknown> =>
208269 ctx.storage
209270 .listActiveTools()
210− .pipe(
211− Effect.map((tools) =>
212− tools
213− .filter((tool) => tool.name.startsWith(`${app}__`) || tool.name === app)
214− .map((tool) => tool.name),
215− ),
216− );
271+ .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name)));
217272
218273 const makeAppsExtension = (
219274 ctx: PluginCtx<AppsStore>,
@@ -232,6 +287,15 @@ const makeAppsExtension = ( · 12 unmodified lines
232287 publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input),
233288 listSources: () => ctx.storage.listSources(),
234289 getSource: (slug: string) => ctx.storage.getSource(slug),
290+ listDirs: (input: { readonly path?: string; readonly includeHidden?: boolean }) =>
291+ Effect.gen(function* () {
292+ if (!sourceKinds.includes("local-directory")) {
293+ return yield* new AppPluginError({
294+ message: "app source kind is not enabled: local-directory",
295+ });
296+ }
297+ return yield* listLocalDirectoryDirs(input);
298+ }),
235299 createSource: (input: CreateAppSourceInput) =>
236300 Effect.gen(function* () {
237301 const config = sourceConfig(input);
@@ -276,11 +340,18 @@ const makeAppsExtension = ( · 38 unmodified lines
276340 updatedAt: now(),
277341 };
278342 yield* ctx.storage.putSource(record, "org");
343+ yield* ensureAppIntegration(ctx, record);
279344 return record;
280345 }),
281346 deleteSource: (slug: string) =>
282347 Effect.gen(function* () {
283348 const record = yield* ctx.storage.getSource(slug);
349+ if (record) {
350+ yield* ctx.storage.removePublished(record.app, "org");
351+ yield* ctx.core.integrations
352+ .remove(IntegrationSlug.make(record.app))
353+ .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
354+ }
284355 if (
285356 record?.config.kind === "git" &&
286357 record.config.tokenProvider &&
@@ -323,6 +394,17 @@ const makeAppsExtension = ( · 36 unmodified lines
323394 return { status: "failed", tools: [], errors: [diagnostic] };
324395 }
325396 if (record.sourceRef === checked.success.sourceRef) {
397+ const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result);
398+ if (Result.isFailure(guarded)) {
399+ const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure);
400+ const failed: AppSourceRecord = {
401+ ...record,
402+ status: { type: "failed", at: now(), errors: [diagnostic] },
403+ updatedAt: now(),
404+ };
405+ yield* ctx.storage.putSource(failed, "org");
406+ return { status: "failed", tools: [], errors: [diagnostic] };
407+ }
326408 const tools = yield* activeToolNamesFor(ctx, record.app);
327409 const updated: AppSourceRecord = {
328410 ...record,
@@ -330,7 +412,7 @@ const makeAppsExtension = ( · 1 unmodified line
330412 updatedAt: now(),
331413 };
332414 yield* ctx.storage.putSource(updated, "org");
333− yield* ensureAppsCatalogConnection(ctx);
415+ yield* ensureAppConnection(ctx, updated);
334416 return { status: "up-to-date", sourceRef: checked.success.sourceRef, tools };
335417 }
336418 }
@@ -361,16 +443,49 @@ const makeAppsExtension = ( · 24 unmodified lines
361443 }
362444 const snapshot: AppSourceSnapshot = fetched.success;
363445 if (record.sourceRef === snapshot.sourceRef) {
446+ const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result);
447+ if (Result.isFailure(guarded)) {
448+ const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure);
449+ const failed: AppSourceRecord = {
450+ ...record,
451+ description: snapshot.description,
452+ status: { type: "failed", at: now(), errors: [diagnostic] },
453+ updatedAt: now(),
454+ };
455+ yield* ctx.storage.putSource(failed, "org");
456+ return { status: "failed", tools: [], errors: [diagnostic] };
457+ }
364458 const tools = yield* activeToolNamesFor(ctx, record.app);
365459 const updated: AppSourceRecord = {
366460 ...record,
461+ description: snapshot.description,
367462 status: { type: "up-to-date", at: now(), tools },
368463 updatedAt: now(),
369464 };
370465 yield* ctx.storage.putSource(updated, "org");
371− yield* ensureAppsCatalogConnection(ctx);
466+ yield* ensureAppConnection(ctx, updated, snapshot.description);
372467 return { status: "up-to-date", sourceRef: snapshot.sourceRef, tools };
373468 }
469+ const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result);
470+ if (Result.isFailure(guarded)) {
471+ const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure);
472+ yield* ctx.storage.putSource(
473+ {
474+ ...record,
475+ sourceRef: snapshot.sourceRef,
476+ description: snapshot.description,
477+ status: { type: "failed", at: now(), errors: [diagnostic] },
478+ updatedAt: now(),
479+ },
480+ "org",
481+ );
482+ return {
483+ status: "failed",
484+ sourceRef: snapshot.sourceRef,
485+ tools: [],
486+ errors: [diagnostic],
487+ };
488+ }
374489 const published = yield* publish(
375490 { store: ctx.storage, executor: activeExecutor, bundler: activeBundler },
376491 {
@@ -412,7 +527,7 @@ const makeAppsExtension = ( · 35 unmodified lines
412527 },
413528 "org",
414529 );
415− yield* ensureAppsCatalogConnection(ctx);
530+ yield* ensureAppConnection(ctx, record, snapshot.description);
416531 return {
417532 status: published.success.noop ? "up-to-date" : "published",
418533 sourceRef: snapshot.sourceRef,
@@ -435,6 +550,7 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 16 unmodified lines
435550 definePlugin(() => ({
436551 id: "apps",
437552 packageName: "@executor-js/plugin-apps",
553+ clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] },
438554 pluginStorage: {
439555 [descriptorCollection.name]: descriptorCollection,
440556 [toolCollection.name]: toolCollection,
@@ -442,49 +558,55 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 1 unmodified line
442558 },
443559 storage: ({ blobs, pluginStorage }) => makeAppsStore({ blobs, pluginStorage }),
444560 extension: (ctx: PluginCtx<AppsStore>) => makeAppsExtension(ctx, options),
445− staticSources: () => [
446− {
447− id: "apps",
448− kind: "apps",
449− name: "Apps",
450− canRemove: false,
451− canRefresh: false,
452− tools: [],
453− },
454− ],
455− resolveTools: ({ storage }) =>
561+ resolveTools: ({ storage, connection }) =>
456562 storage.listActiveTools().pipe(
457563 Effect.map((tools) => ({
458− tools: tools.map(
459− (tool): ToolDef => ({
460− name: ToolName.make(tool.name),
461− description: tool.description,
462− inputSchema: tool.inputSchema,
463− outputSchema: tool.outputSchema,
464− annotations: {
465− ...(tool.annotations?.requiresApproval !== undefined
466− ? { requiresApproval: tool.annotations.requiresApproval }
467− : {}),
468− ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}),
469− },
470− }),
471− ),
564+ tools: tools
565+ .filter((tool) => tool.app === String(connection.integration))
566+ .map(
567+ (tool): ToolDef => ({
568+ name: ToolName.make(tool.name),
569+ description: tool.description,
570+ inputSchema: tool.inputSchema,
571+ outputSchema: tool.outputSchema,
572+ annotations: {
573+ ...(tool.annotations?.requiresApproval !== undefined
574+ ? { requiresApproval: tool.annotations.requiresApproval }
575+ : {}),
576+ ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}),
577+ },
578+ }),
579+ ),
472580 })),
473581 ),
474582 projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) =>
475− projectAppsToolSchema(ctx, String(toolRow.name), inputSchema, outputSchema),
583+ projectAppsToolSchema(
584+ ctx,
585+ String(toolRow.integration),
586+ String(toolRow.name),
587+ inputSchema,
588+ outputSchema,
589+ ),
476590 validateToolArgs: ({ ctx, toolRow, args }) =>
477591 Effect.gen(function* () {
478− const tool = yield* ctx.storage.getTool(String(toolRow.name));
592+ const tool = yield* ctx.storage.getToolForApp(
593+ String(toolRow.integration),
594+ String(toolRow.name),
595+ );
479596 if (!tool) return;
480597 const resolver = makePluginCtxAppsResolver({ ctx });
481598 yield* resolveIntegrationBindings(tool.integrations, args, resolver);
482599 }),
483600 invokeTool: ({ ctx, toolRow, args, invokeOptions }) =>
484601 Effect.gen(function* () {
485− const tool = yield* ctx.storage.getTool(String(toolRow.name));
602+ const tool = yield* ctx.storage.getToolForApp(
603+ String(toolRow.integration),
604+ String(toolRow.name),
605+ );
486606 if (!tool) {
487− return yield* new AppPluginError({ message: `app tool not found: ${toolRow.name}` });
607+ return yield* new AppPluginError({
608+ message: `app tool not found: ${toolRow.integration}.${toolRow.name}`,
609+ });
488610 }
489611 const bundle = yield* ctx.storage.getBlob(tool.bundleKey);
490612 if (!bundle) {
@@ -525,12 +647,13 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 34 unmodified lines
525647
526648 export const projectAppsToolSchema = (
527649 ctx: PluginCtx<AppsStore>,
650+ app: string,
528651 toolName: string,
529652 inputSchema: unknown,
530653 outputSchema: unknown,
531654 ): Effect.Effect<ProjectedToolSchema, unknown> =>
532655 Effect.gen(function* () {
533− const tool = yield* ctx.storage.getTool(toolName);
656+ const tool = yield* ctx.storage.getToolForApp(app, toolName);
534657 if (!tool || !inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
535658 return { inputSchema, outputSchema };
536659 }
@@ -546,12 +669,34 @@ export const projectAppsToolSchema = ( · 9 unmodified lines
546669 const enumValues = connections.map((connection) => String(connection.address));
547670 properties[field] =
548671 decl.mode === "many"
549− ? { type: "array", items: { type: "string", enum: enumValues } }
550− : { type: "string", enum: enumValues };
672+ ? {
673+ type: "array",
674+ items: { type: "string", enum: enumValues },
675+ default: enumValues,
676+ }
677+ : {
678+ type: "string",
679+ enum: enumValues,
680+ ...(enumValues[0] ? { default: enumValues[0] } : {}),
681+ };
551682 }
552− return { inputSchema: { ...schema, properties }, outputSchema };
683+ const projectedFields = new Set(Object.keys(tool.integrations));
684+ const required = Array.isArray(schema.required)
685+ ? schema.required.filter((field) => !projectedFields.has(String(field)))
686+ : undefined;
687+ // Rebuild without the `required` key rather than setting it to undefined:
688+ // an explicit undefined is not a JSON value and fails response encoding.
689+ const { required: _dropped, ...rest } = schema;
690+ return {
691+ inputSchema: {
692+ ...rest,
693+ properties,
694+ ...(required && required.length > 0 ? { required } : {}),
695+ },
696+ outputSchema,
697+ };
553698 });
554699
555700 export const appsPlugin = makeAppsPlugin();
556701
557−export { APPS_INTEGRATION, APPS_CONNECTION };
702+export { APPS_CONNECTION };
packages/plugins/apps/src/plugin/handlers.ts+64−1
@@ -5,12 +5,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines
55 import { addGroup, capture } from "@executor-js/api";
66 import { InternalError } from "@executor-js/sdk/shared";
77 import type { AppsExtension } from "./apps-plugin";
8−import { AppsGroup } from "./routes";
8+import { AppsBaseGroup, AppsGroup } from "./routes";
99
1010 export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()(
1111 "AppsExtensionService",
1212 ) {}
1313
14+const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup);
1415 const ExecutorApiWithApps = addGroup(AppsGroup);
1516
1617 const captureUnexpected = <A, E, R>(
@@ -18,6 +19,53 @@ const captureUnexpected = <A, E, R>( · 1 unmodified line
1819 ): Effect.Effect<A, InternalError, R> =>
1920 capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause))));
2021
22+export const AppsBaseHandlers = HttpApiBuilder.group(ExecutorApiWithAppsBase, "apps", (handlers) =>
23+ handlers
24+ .handle("listSources", () =>
25+ captureUnexpected(
26+ Effect.gen(function* () {
27+ const ext = yield* AppsExtensionService;
28+ const sources = yield* ext.listSources();
29+ return { sources };
30+ }),
31+ ),
32+ )
33+ .handle("createSource", ({ payload }) =>
34+ captureUnexpected(
35+ Effect.gen(function* () {
36+ const ext = yield* AppsExtensionService;
37+ const source = yield* ext.createSource(payload);
38+ return { source };
39+ }),
40+ ),
41+ )
42+ .handle("getSource", ({ params }) =>
43+ captureUnexpected(
44+ Effect.gen(function* () {
45+ const ext = yield* AppsExtensionService;
46+ const source = yield* ext.getSource(params.slug);
47+ return { source };
48+ }),
49+ ),
50+ )
51+ .handle("deleteSource", ({ params }) =>
52+ captureUnexpected(
53+ Effect.gen(function* () {
54+ const ext = yield* AppsExtensionService;
55+ return yield* ext.deleteSource(params.slug);
56+ }),
57+ ),
58+ )
59+ .handle("syncSource", ({ params }) =>
60+ captureUnexpected(
61+ Effect.gen(function* () {
62+ const ext = yield* AppsExtensionService;
63+ return yield* ext.syncSource(params.slug);
64+ }),
65+ ),
66+ ),
67+);
68+
2169 export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) =>
2270 handlers
2371 .handle("listSources", () =>
@@ -62,5 +110,20 @@ export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (h · 38 unmodified lines
62110 return yield* ext.syncSource(params.slug);
63111 }),
64112 ),
113+ )
114+ .handle("listDirs", ({ query }) =>
115+ captureUnexpected(
116+ Effect.gen(function* () {
117+ const ext = yield* AppsExtensionService;
118+ return yield* ext.listDirs({
119+ ...(query.path ? { path: query.path } : {}),
120+ includeHidden: query.includeHidden === "true" || query.includeHidden === "1",
121+ });
122+ }),
123+ ),
65124 ),
66125 );
126+
127+export const appsHandlersForSourceKinds = (
128+ sourceKinds: readonly ("git" | "local-directory")[] | undefined,
129+) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsHandlers : AppsBaseHandlers);
packages/plugins/apps/src/plugin/routes.ts+38−1
@@ -67,9 +67,31 @@ const SyncSourceResponse = Schema.Struct({ · 66 unmodified lines
6767 errors: Schema.optional(Schema.Array(SyncDiagnostic)),
6868 });
6969
70+const ListDirsQuery = Schema.Struct({
71+ path: Schema.optional(Schema.String),
72+ includeHidden: Schema.optional(Schema.String),
73+});
74+const ListDirsResponse = Schema.Struct({
75+ path: Schema.String,
76+ parent: Schema.NullOr(Schema.String),
77+ dirs: Schema.Array(
78+ Schema.Struct({
79+ name: Schema.String,
80+ path: Schema.String,
81+ isSymlink: Schema.Boolean,
82+ hasTools: Schema.Boolean,
83+ }),
84+ ),
85+ source: Schema.Struct({
86+ toolFiles: Schema.Array(Schema.String),
87+ skipped: Schema.Array(Schema.String),
88+ hasPackageJson: Schema.Boolean,
89+ }),
90+});
91+
7092 const DomainErrors = [InternalError] as const;
7193
72−export const AppsGroup = HttpApiGroup.make("apps")
94+export const AppsBaseGroup = HttpApiGroup.make("apps")
7395 .add(
7496 HttpApiEndpoint.get("listSources", "/apps/sources", {
7597 success: SourcesResponse,
@@ -104,3 +126,18 @@ export const AppsGroup = HttpApiGroup.make("apps") · 28 unmodified lines
104126 error: DomainErrors,
105127 }),
106128 );
129+
130+export const AppsGroup = AppsBaseGroup.add(
131+ // Local-directory browsing names directories on the host running Executor.
132+ // It is only safe for the local single-user host, and the plugin sourceKinds
133+ // gate keeps shared selfhost and cloud deployments from registering it.
134+ HttpApiEndpoint.get("listDirs", "/apps/fs/dirs", {
135+ query: ListDirsQuery,
136+ success: ListDirsResponse,
137+ error: DomainErrors,
138+ }),
139+);
140+
141+export const appsGroupForSourceKinds = (
142+ sourceKinds: readonly ("git" | "local-directory")[] | undefined,
143+) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsGroup : AppsBaseGroup);
packages/plugins/apps/src/plugin/store.test.ts+21−0
@@ -177,6 +177,27 @@ describe("apps store", () => { · 176 unmodified lines
177177 yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1");
178178 const active = yield* store.listActiveTools();
179179 expect(active.map((item) => item.name)).toEqual(["first"]);
180+ expect(active.map((item) => item.app)).toEqual(["crm"]);
181+ }),
182+ );
183+
184+ it.effect("removes a published app catalog", () =>
185+ Effect.gen(function* () {
186+ const store = makeAppsStore({
187+ blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"),
188+ pluginStorage: makeMemoryPluginStorage(),
189+ });
190+ yield* store.putPublished(
191+ descriptor([tool("first"), tool("second")]),
192+ "descriptor",
193+ "org",
194+ null,
195+ );
196+ yield* store.removePublished("crm", "org");
197+ const active = yield* store.listActiveTools();
198+ const descriptorRecord = yield* store.getDescriptorRecord("crm");
199+ expect(active).toEqual([]);
200+ expect(descriptorRecord).toBeNull();
180201 }),
181202 );
182203
packages/plugins/apps/src/plugin/store.ts+53−1
@@ -102,8 +102,9 @@ export interface AppsStore { · 101 unmodified lines
102102 owner: Owner,
103103 expectedSourceRef: string | null,
104104 ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>;
105+ readonly removePublished: (app: string, owner: Owner) => Effect.Effect<void, StorageFailure>;
105106 readonly listActiveTools: () => Effect.Effect<
106− readonly AppDescriptor["tools"][number][],
107+ readonly (AppDescriptor["tools"][number] & { readonly app: string })[],
107108 StorageFailure
108109 >;
109110 readonly getTool: (name: string) => Effect.Effect<
@@ -119,6 +120,22 @@ export interface AppsStore { · 9 unmodified lines
119120 } | null,
120121 StorageFailure
121122 >;
123+ readonly getToolForApp: (
124+ app: string,
125+ name: string,
126+ ) => Effect.Effect<
127+ {
128+ readonly app: string;
129+ readonly name: string;
130+ readonly bundleKey: string;
131+ readonly description: string;
132+ readonly inputSchema?: unknown;
133+ readonly outputSchema?: unknown;
134+ readonly integrations: AppDescriptor["tools"][number]["integrations"];
135+ readonly annotations?: AppDescriptor["tools"][number]["annotations"];
136+ } | null,
137+ StorageFailure
138+ >;
122139 readonly putSource: (
123140 record: AppSourceRecord,
124141 owner: Owner,
@@ -215,10 +232,27 @@ export const makeAppsStore = (input: { · 90 unmodified lines
215232 },
216233 });
217234 }),
235+ removePublished: (app, owner) =>
236+ Effect.gen(function* () {
237+ const existing = yield* tools.query({ where: { app } });
238+ const now = Date.now();
239+ const entries: PutManyEntry[] = existing
240+ .filter((entry) => !entry.data.tombstoned)
241+ .map((entry) => ({
242+ collection: toolCollection.name,
243+ key: entry.key,
244+ data: { ...entry.data, tombstoned: true, updatedAt: now },
245+ }));
246+ if (entries.length > 0) {
247+ yield* input.pluginStorage.putMany({ owner, entries });
248+ }
249+ yield* descriptors.remove({ owner, key: app });
250+ }),
218251 listActiveTools: () =>
219252 tools.query({ where: { tombstoned: false } }).pipe(
220253 Effect.map((entries) =>
221254 entries.map((entry) => ({
255+ app: entry.data.app,
222256 name: entry.data.name,
223257 sourcePath: "",
224258 source: { path: "", sourceHash: "" },
@@ -249,6 +283,24 @@ export const makeAppsStore = (input: { · 24 unmodified lines
249283 : null;
250284 }),
251285 ),
286+ getToolForApp: (app, name) =>
287+ tools.query({ where: { app, name, tombstoned: false }, limit: 1 }).pipe(
288+ Effect.map((entries) => {
289+ const entry = entries[0];
290+ return entry
291+ ? {
292+ app: entry.data.app,
293+ name: entry.data.name,
294+ bundleKey: entry.data.bundleKey,
295+ description: entry.data.description,
296+ inputSchema: entry.data.inputSchema,
297+ outputSchema: entry.data.outputSchema,
298+ integrations: entry.data.integrations,
299+ annotations: entry.data.annotations,
300+ }
301+ : null;
302+ }),
303+ ),
252304 putSource: (record, owner) =>
253305 sources.put({ owner, key: record.slug, data: record }).pipe(Effect.asVoid),
254306 listSources: () =>
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+459−0
@@ -0,0 +1,459 @@
1+import { useEffect, useMemo, useState } from "react";
2+import { Effect, Exit } from "effect";
3+
4+import { Button } from "@executor-js/react/components/button";
5+import {
6+ CardStack,
7+ CardStackContent,
8+ CardStackEntryField,
9+} from "@executor-js/react/components/card-stack";
10+import { FloatActions } from "@executor-js/react/components/float-actions";
11+import { Input } from "@executor-js/react/components/input";
12+import { Checkbox } from "@executor-js/react/components/checkbox";
13+import { Label } from "@executor-js/react/components/label";
14+import {
15+ Dialog,
16+ DialogContent,
17+ DialogDescription,
18+ DialogFooter,
19+ DialogHeader,
20+ DialogTitle,
21+} from "@executor-js/react/components/dialog";
22+import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
23+
24+import {
25+ createCustomToolSourceEffect,
26+ formatSyncErrors,
27+ listCustomToolDirectoriesEffect,
28+ parseGitSourceUrl,
29+ slugifyCustomToolsAppName,
30+ suggestCustomToolsAppName,
31+ syncCustomToolSourceEffect,
32+ validateCustomToolsAppSlug,
33+ validateGitSourceUrl,
34+ type CustomToolsDirectoryListing,
35+ type AppSourceKind,
36+} from "./custom-tools-client";
37+import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model";
38+
39+export default function AddCustomToolsSource(props: {
40+ readonly onComplete: (slug?: string) => void;
41+ readonly onCancel: () => void;
42+ readonly initialUrl?: string;
43+ readonly initialNamespace?: string;
44+ readonly sourceKinds?: readonly AppSourceKind[];
45+}) {
46+ const sourceKinds = props.sourceKinds ?? ["git"];
47+ const allowLocalDirectory = sourceKinds.includes("local-directory");
48+ const initialKind =
49+ props.initialUrl && props.initialUrl.startsWith("/") && allowLocalDirectory
50+ ? "local-directory"
51+ : "git";
52+ const [kind, setKind] = useState<AppSourceKind>(initialKind);
53+ const [url, setUrl] = useState(props.initialUrl ?? "");
54+ const [path, setPath] = useState(
55+ initialKind === "local-directory" ? (props.initialUrl ?? "") : "",
56+ );
57+ const [ref, setRef] = useState("");
58+ const [name, setName] = useState(
59+ props.initialNamespace ? slugifyCustomToolsAppName(props.initialNamespace) : "",
60+ );
61+ const [nameTouched, setNameTouched] = useState(props.initialNamespace !== undefined);
62+ const [token, setToken] = useState("");
63+ const [fieldError, setFieldError] = useState<string | null>(null);
64+ const [nameError, setNameError] = useState<string | null>(null);
65+ const [syncing, setSyncing] = useState(false);
66+ const [syncError, setSyncError] = useState<string | null>(null);
67+ const [browseOpen, setBrowseOpen] = useState(false);
68+ const [pathListing, setPathListing] = useState<CustomToolsDirectoryListing | null>(null);
69+
70+ const sourceValue = kind === "git" ? url : path;
71+ const effectiveName = useMemo(
72+ () => (nameTouched ? name : slugifyCustomToolsAppName(suggestCustomToolsAppName(sourceValue))),
73+ [name, nameTouched, sourceValue],
74+ );
75+ const slug = effectiveName;
76+ const slugAlreadyExists = useSlugAlreadyExists(slug);
77+
78+ useEffect(() => {
79+ if (kind !== "local-directory" || !path.trim().startsWith("/")) {
80+ setPathListing(null);
81+ return;
82+ }
83+ let active = true;
84+ const timeout = window.setTimeout(() => {
85+ void Effect.runPromiseExit(listCustomToolDirectoriesEffect({ path: path.trim() })).then(
86+ (exit) => {
87+ if (!active) return;
88+ setPathListing(Exit.isSuccess(exit) ? exit.value : null);
89+ },
90+ );
91+ }, 300);
92+ return () => {
93+ active = false;
94+ window.clearTimeout(timeout);
95+ };
96+ }, [kind, path]);
97+
98+ const submit = async () => {
99+ const nextFieldError =
100+ kind === "git"
101+ ? validateGitSourceUrl(url)
102+ : path.trim().startsWith("/")
103+ ? null
104+ : "Enter an absolute directory path.";
105+ const nextNameError = validateCustomToolsAppSlug(slug);
106+ setFieldError(nextFieldError);
107+ setNameError(nextNameError);
108+ setSyncError(null);
109+ if (nextFieldError || nextNameError) return;
110+ if (slugAlreadyExists) {
111+ setSyncError(`An integration named "${slug}" already exists. Choose another source name.`);
112+ return;
113+ }
114+
115+ setSyncing(true);
116+ const parsedGitUrl = kind === "git" ? parseGitSourceUrl(url) : null;
117+ const createExit = await Effect.runPromiseExit(
118+ createCustomToolSourceEffect(
119+ kind === "git"
120+ ? {
121+ kind: "git",
122+ slug,
123+ app: slug,
124+ url: parsedGitUrl?.ok ? parsedGitUrl.url : url.trim(),
125+ ...(ref.trim() ? { ref: ref.trim() } : {}),
126+ ...(token.trim() ? { token: token.trim() } : {}),
127+ }
128+ : { kind: "local-directory", slug, app: slug, path: path.trim() },
129+ ),
130+ );
131+ if (Exit.isFailure(createExit)) {
132+ setSyncError("Failed to create custom tools source.");
133+ setSyncing(false);
134+ return;
135+ }
136+ const syncExit = await Effect.runPromiseExit(
137+ syncCustomToolSourceEffect(createExit.value.source.slug),
138+ );
139+ if (Exit.isFailure(syncExit)) {
140+ setSyncError("Failed to sync custom tools source.");
141+ setSyncing(false);
142+ return;
143+ }
144+ const result = syncExit.value;
145+ if (result.status === "failed") {
146+ setSyncError(formatSyncErrors(result).join("\n") || "Sync failed.");
147+ setSyncing(false);
148+ return;
149+ }
150+ setSyncing(false);
151+ props.onComplete(createExit.value.source.app);
152+ };
153+
154+ return (
155+ <div className="flex flex-1 flex-col gap-6">
156+ <div>
157+ <h1 className="text-xl font-semibold text-foreground">Add custom tools</h1>
158+ <p className="mt-1 text-[13px] text-muted-foreground">
159+ Sync tools from a Git repository, then publish them to the Apps catalog.
160+ </p>
161+ </div>
162+
163+ <CardStack>
164+ <CardStackContent className="border-t-0">
165+ {allowLocalDirectory && (
166+ <CardStackEntryField
167+ label="Source type"
168+ description="- Git is portable. Local directories are local-only."
169+ >
170+ <div className="flex gap-2">
171+ <Button
172+ type="button"
173+ variant={kind === "git" ? "default" : "outline"}
174+ onClick={() => setKind("git")}
175+ disabled={syncing}
176+ >
177+ Git repository
178+ </Button>
179+ <Button
180+ type="button"
181+ variant={kind === "local-directory" ? "default" : "outline"}
182+ onClick={() => setKind("local-directory")}
183+ disabled={syncing}
184+ >
185+ Directory path
186+ </Button>
187+ </div>
188+ </CardStackEntryField>
189+ )}
190+
191+ {kind === "git" ? (
192+ <>
193+ <CardStackEntryField label="Git repository" description="- Any https Git remote.">
194+ <div className="space-y-1.5">
195+ <Input
196+ value={url}
197+ onChange={(event) => {
198+ setUrl((event.target as HTMLInputElement).value);
199+ setFieldError(null);
200+ setSyncError(null);
201+ }}
202+ onBlur={() => setFieldError(validateGitSourceUrl(url))}
203+ placeholder="https://github.com/acme/tools.git"
204+ className="font-mono text-sm"
205+ aria-invalid={fieldError ? true : undefined}
206+ />
207+ {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
208+ </div>
209+ </CardStackEntryField>
210+ <CardStackEntryField
211+ label="Ref (optional)"
212+ description="- Branch, tag, or commit SHA."
213+ >
214+ <Input
215+ value={ref}
216+ onChange={(event) => setRef((event.target as HTMLInputElement).value)}
217+ placeholder="main"
218+ className="font-mono text-sm"
219+ disabled={syncing}
220+ />
221+ </CardStackEntryField>
222+ <CardStackEntryField
223+ label="Token (optional)"
224+ description="- For private repositories. Never shown after saving."
225+ >
226+ <Input
227+ value={token}
228+ type="password"
229+ onChange={(event) => setToken((event.target as HTMLInputElement).value)}
230+ autoComplete="off"
231+ className="font-mono text-sm"
232+ disabled={syncing}
233+ />
234+ </CardStackEntryField>
235+ </>
236+ ) : (
237+ <CardStackEntryField
238+ label="Directory path"
239+ description="- Absolute path on this machine."
240+ >
241+ <div className="space-y-1.5">
242+ <div className="flex gap-2">
243+ <Input
244+ value={path}
245+ onChange={(event) => {
246+ setPath((event.target as HTMLInputElement).value);
247+ setFieldError(null);
248+ setSyncError(null);
249+ }}
250+ onBlur={() => {
251+ if (!path.trim().startsWith("/")) return;
252+ void Effect.runPromiseExit(
253+ listCustomToolDirectoriesEffect({ path: path.trim() }),
254+ ).then((exit) => {
255+ setPathListing(Exit.isSuccess(exit) ? exit.value : null);
256+ });
257+ }}
258+ placeholder="/Users/me/tools"
259+ className="font-mono text-sm"
260+ aria-invalid={fieldError ? true : undefined}
261+ />
262+ <Button
263+ type="button"
264+ variant="outline"
265+ onClick={() => setBrowseOpen(true)}
266+ disabled={syncing}
267+ >
268+ Browse
269+ </Button>
270+ </div>
271+ {pathListing && <DirectorySourceStatus listing={pathListing} />}
272+ {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
273+ </div>
274+ </CardStackEntryField>
275+ )}
276+
277+ <CardStackEntryField
278+ label="Source name"
279+ description="- Lowercase letters, numbers, and hyphens."
280+ >
281+ <div className="space-y-1.5">
282+ <Input
283+ value={effectiveName}
284+ onChange={(event) => {
285+ setNameTouched(true);
286+ setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value));
287+ setNameError(null);
288+ setSyncError(null);
289+ }}
290+ placeholder="custom-tools"
291+ className="text-sm"
292+ aria-invalid={nameError ? true : undefined}
293+ />
294+ {nameError && <p className="text-xs text-destructive">{nameError}</p>}
295+ {slugAlreadyExists && !syncing && !nameError && (
296+ <p className="text-xs text-destructive">
297+ An integration named &quot;{slug}&quot; already exists.
298+ </p>
299+ )}
300+ </div>
301+ </CardStackEntryField>
302+ </CardStackContent>
303+ </CardStack>
304+
305+ {syncError && <FormErrorAlert message={syncError} />}
306+
307+ <FloatActions>
308+ <Button type="button" variant="ghost" onClick={() => props.onCancel()} disabled={syncing}>
309+ Cancel
310+ </Button>
311+ <Button type="button" onClick={() => void submit()} disabled={syncing} loading={syncing}>
312+ Sync source
313+ </Button>
314+ </FloatActions>
315+ {browseOpen && (
316+ <DirectoryBrowserModal
317+ initialPath={path.trim() || undefined}
318+ onClose={() => setBrowseOpen(false)}
319+ onSelect={(selectedPath) => {
320+ setPath(selectedPath);
321+ setFieldError(null);
322+ setSyncError(null);
323+ setBrowseOpen(false);
324+ }}
325+ />
326+ )}
327+ </div>
328+ );
329+}
330+
331+function DirectoryBrowserModal(props: {
332+ readonly initialPath?: string;
333+ readonly onSelect: (path: string) => void;
334+ readonly onClose: () => void;
335+}) {
336+ const [currentPath, setCurrentPath] = useState(props.initialPath);
337+ const [includeHidden, setIncludeHidden] = useState(false);
338+ const [listing, setListing] = useState<CustomToolsDirectoryListing | null>(null);
339+ const [loading, setLoading] = useState(true);
340+ const [error, setError] = useState<string | null>(null);
341+
342+ useEffect(() => {
343+ let active = true;
344+ setLoading(true);
345+ setError(null);
346+ void Effect.runPromiseExit(
347+ listCustomToolDirectoriesEffect({ path: currentPath, includeHidden }),
348+ ).then((exit) => {
349+ if (!active) return;
350+ setLoading(false);
351+ if (Exit.isFailure(exit)) {
352+ setError("Unable to read this directory.");
353+ return;
354+ }
355+ setListing(exit.value);
356+ setCurrentPath(exit.value.path);
357+ });
358+ return () => {
359+ active = false;
360+ };
361+ }, [currentPath, includeHidden]);
362+
363+ const rows = listing ? directoryBrowserRows(listing) : [];
364+ const selectedPath = listing?.path ?? currentPath ?? "";
365+ const canSelect = Boolean(selectedPath) && !loading && !error;
366+ const hasToolFiles = (listing?.source.toolFiles.length ?? 0) > 0;
367+
368+ return (
369+ <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}>
370+ <DialogContent className="sm:max-w-[620px]">
371+ <DialogHeader className="border-b border-border/60 pb-4">
372+ <DialogTitle className="text-base">Choose directory</DialogTitle>
373+ <DialogDescription className="font-mono text-xs break-all">
374+ {selectedPath || "Home"}
375+ </DialogDescription>
376+ {listing && <DirectorySourceStatus listing={listing} />}
377+ </DialogHeader>
378+ <div className="space-y-3">
379+ <Label className="flex items-center gap-2 text-sm text-muted-foreground">
380+ <Checkbox
381+ checked={includeHidden}
382+ onCheckedChange={(checked) => setIncludeHidden(checked === true)}
383+ />
384+ Show hidden directories
385+ </Label>
386+ <div className="max-h-[340px] overflow-auto rounded-md border border-border">
387+ {loading ? (
388+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">Loading...</div>
389+ ) : error ? (
390+ <div className="px-3 py-8 text-center text-sm text-destructive">{error}</div>
391+ ) : rows.length === 0 ? (
392+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
393+ No subdirectories
394+ </div>
395+ ) : (
396+ <div role="listbox" aria-label="Directories" className="divide-y divide-border">
397+ {rows.map((row) => (
398+ <Button
399+ key={`${row.kind}:${row.path}`}
400+ type="button"
401+ variant="ghost"
402+ className="h-auto w-full justify-between gap-3 rounded-none px-3 py-2 text-left text-sm font-normal hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
403+ onClick={() => setCurrentPath(row.path)}
404+ >
405+ <span className="min-w-0 truncate font-mono">
406+ {row.kind === "parent" ? ".." : row.name}
407+ </span>
408+ <span className="flex shrink-0 items-center gap-2">
409+ {row.kind === "dir" && row.hasTools && (
410+ <span className="rounded-sm border border-border bg-secondary px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
411+ tools
412+ </span>
413+ )}
414+ {row.kind === "dir" && row.isSymlink && (
415+ <span className="font-mono text-[11px] text-muted-foreground">symlink</span>
416+ )}
417+ </span>
418+ </Button>
419+ ))}
420+ </div>
421+ )}
422+ </div>
423+ </div>
424+ <DialogFooter className="border-t border-border/60 pt-4">
425+ <Button type="button" variant="ghost" onClick={props.onClose}>
426+ Cancel
427+ </Button>
428+ <Button type="button" onClick={() => props.onSelect(selectedPath)} disabled={!canSelect}>
429+ {hasToolFiles ? "Select" : "Select anyway"}
430+ </Button>
431+ </DialogFooter>
432+ </DialogContent>
433+ </Dialog>
434+ );
435+}
436+
437+function DirectorySourceStatus(props: { readonly listing: CustomToolsDirectoryListing }) {
438+ const verdict = directorySourceVerdict(props.listing);
439+ if (verdict.type === "valid") {
440+ return (
441+ <p className="mt-2 text-xs text-foreground">
442+ {verdict.message}:{" "}
443+ {verdict.visibleTools.map((tool, index) => (
444+ <span key={tool}>
445+ {index > 0 ? ", " : ""}
446+ <span className="font-mono">{tool}</span>
447+ </span>
448+ ))}
449+ {verdict.moreCount > 0 && (
450+ <>
451+ {", "}
452+ <span className="font-mono">+{verdict.moreCount} more</span>
453+ </>
454+ )}
455+ </p>
456+ );
457+ }
458+ return <p className="mt-2 text-xs text-muted-foreground">{verdict.message}</p>;
459+}
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+292−0
@@ -0,0 +1,292 @@
1+import { useCallback, useEffect, useState } from "react";
2+import { Effect, Exit } from "effect";
3+
4+import {
5+ AlertDialog,
6+ AlertDialogAction,
7+ AlertDialogCancel,
8+ AlertDialogContent,
9+ AlertDialogDescription,
10+ AlertDialogFooter,
11+ AlertDialogHeader,
12+ AlertDialogTitle,
13+ AlertDialogTrigger,
14+} from "@executor-js/react/components/alert-dialog";
15+import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
16+import { Button } from "@executor-js/react/components/button";
17+import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
18+
19+import {
20+ listCustomToolSourcesEffect,
21+ removeCustomToolSourceEffect,
22+ syncCustomToolSourceEffect,
23+ type AppSourceRecord,
24+} from "./custom-tools-client";
25+import {
26+ sourceFailureLines,
27+ sourcePanelModel,
28+ syncNoticeFromResult,
29+ toolsCountLabel,
30+ type SyncNoticeModel,
31+} from "./source-panel-model";
32+
33+const integrationsHref = (): string => {
34+ const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/);
35+ return `${match?.[1] ?? ""}/integrations`;
36+};
37+
38+type LoadState =
39+ | { readonly status: "loading" }
40+ | { readonly status: "error"; readonly message: string }
41+ | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] };
42+
43+export default function CustomToolsAccountsPanel(props: { readonly integrationId: string }) {
44+ const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
45+ const [syncingSlug, setSyncingSlug] = useState<string | null>(null);
46+ const [removingSlug, setRemovingSlug] = useState<string | null>(null);
47+ const [notice, setNotice] = useState<Record<string, SyncNoticeModel | undefined>>({});
48+ const [removeError, setRemoveError] = useState<string | null>(null);
49+ const sourcesForApp = useCallback(
50+ (sources: readonly AppSourceRecord[]): readonly AppSourceRecord[] =>
51+ sources.filter((source) => source.app === props.integrationId),
52+ [props.integrationId],
53+ );
54+
55+ const loadSources = async () => {
56+ setLoadState({ status: "loading" });
57+ const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
58+ if (Exit.isFailure(exit)) {
59+ setLoadState({ status: "error", message: "Failed to load custom tools sources." });
60+ return;
61+ }
62+ setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) });
63+ };
64+
65+ useEffect(() => {
66+ let active = true;
67+ void (async () => {
68+ const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
69+ if (!active) return;
70+ if (Exit.isFailure(exit)) {
71+ setLoadState({ status: "error", message: "Failed to load custom tools sources." });
72+ return;
73+ }
74+ setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) });
75+ })();
76+ return () => {
77+ active = false;
78+ };
79+ }, [props.integrationId, sourcesForApp]);
80+
81+ const syncSource = async (source: AppSourceRecord) => {
82+ setSyncingSlug(source.slug);
83+ setNotice((current) => ({ ...current, [source.slug]: undefined }));
84+ const beforeTools =
85+ source.status.type === "published" || source.status.type === "up-to-date"
86+ ? source.status.tools
87+ : [];
88+ const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect(source.slug));
89+ if (Exit.isFailure(exit)) {
90+ setNotice((current) => ({
91+ ...current,
92+ [source.slug]: {
93+ status: "failed",
94+ message: "Sync failed.",
95+ added: [],
96+ removed: [],
97+ errors: ["Failed to sync custom tools source."],
98+ },
99+ }));
100+ setSyncingSlug(null);
101+ return;
102+ }
103+ setNotice((current) => ({
104+ ...current,
105+ [source.slug]: syncNoticeFromResult(exit.value, beforeTools),
106+ }));
107+ await loadSources();
108+ setSyncingSlug(null);
109+ };
110+
111+ const removeSource = async (source: AppSourceRecord) => {
112+ setRemovingSlug(source.slug);
113+ setRemoveError(null);
114+ const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug));
115+ if (Exit.isFailure(exit)) {
116+ setRemoveError("Failed to remove custom tools source.");
117+ setRemovingSlug(null);
118+ return;
119+ }
120+ await loadSources();
121+ setRemovingSlug(null);
122+ window.location.assign(integrationsHref());
123+ };
124+
125+ return (
126+ <div className="mx-auto max-w-3xl space-y-6 px-6 py-8">
127+ {loadState.status === "loading" && (
128+ <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">
129+ Loading sources...
130+ </div>
131+ )}
132+
133+ {loadState.status === "error" && (
134+ <Alert variant="destructive">
135+ <AlertTitle>Failed to load sources</AlertTitle>
136+ <AlertDescription>
137+ <div className="space-y-3">
138+ <p>{loadState.message}</p>
139+ <Button type="button" size="sm" variant="outline" onClick={() => void loadSources()}>
140+ Retry
141+ </Button>
142+ </div>
143+ </AlertDescription>
144+ </Alert>
145+ )}
146+
147+ {loadState.status === "ready" && loadState.sources.length === 0 && (
148+ <div className="rounded-lg border border-border p-4">
149+ <p className="text-sm font-medium text-foreground">No custom tool sources</p>
150+ <p className="mt-1 text-sm text-muted-foreground">
151+ Add a Git repository to publish tools into the Apps catalog.
152+ </p>
153+ </div>
154+ )}
155+
156+ {loadState.status === "ready" &&
157+ loadState.sources.map((source) => (
158+ <SourceDetail
159+ key={source.slug}
160+ source={source}
161+ notice={notice[source.slug] ?? null}
162+ removeError={removeError}
163+ syncing={syncingSlug === source.slug}
164+ removing={removingSlug === source.slug}
165+ onSync={() => void syncSource(source)}
166+ onRemove={() => void removeSource(source)}
167+ />
168+ ))}
169+ </div>
170+ );
171+}
172+
173+function SourceDetail(props: {
174+ readonly source: AppSourceRecord;
175+ readonly notice: SyncNoticeModel | null;
176+ readonly removeError: string | null;
177+ readonly syncing: boolean;
178+ readonly removing: boolean;
179+ readonly onSync: () => void;
180+ readonly onRemove: () => void;
181+}) {
182+ const { source, notice } = props;
183+ const model = sourcePanelModel(source);
184+ const failureLines = sourceFailureLines(source);
185+ const noticeHasDetails =
186+ notice !== null &&
187+ (notice.added.length > 0 ||
188+ notice.removed.length > 0 ||
189+ notice.errors.length > 0 ||
190+ notice.sourceRef !== undefined);
191+ return (
192+ <div className="min-w-0 rounded-lg border border-border bg-card">
193+ <div className="flex items-start justify-between gap-4 border-b border-border p-4">
194+ <div className="min-w-0">
195+ <h3 className="truncate text-sm font-medium text-foreground">{model.title}</h3>
196+ <p className="mt-1 truncate text-xs text-muted-foreground">{model.source}</p>
197+ </div>
198+ <div className="flex shrink-0 gap-2">
199+ <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}>
200+ Sync
201+ </Button>
202+ <AlertDialog>
203+ <AlertDialogTrigger asChild>
204+ <Button
205+ type="button"
206+ size="sm"
207+ variant="outline"
208+ className="border-destructive/30 text-destructive hover:bg-destructive/10"
209+ disabled={props.removing}
210+ >
211+ Remove
212+ </Button>
213+ </AlertDialogTrigger>
214+ <AlertDialogContent size="sm">
215+ <AlertDialogHeader>
216+ <AlertDialogTitle>Remove {source.app}?</AlertDialogTitle>
217+ <AlertDialogDescription>
218+ This removes {toolsCountLabel(model.tools.length)} from the catalog. The source is
219+ untouched and can be added again later.
220+ </AlertDialogDescription>
221+ </AlertDialogHeader>
222+ <AlertDialogFooter>
223+ <AlertDialogCancel>Cancel</AlertDialogCancel>
224+ <AlertDialogAction variant="destructive" onClick={props.onRemove}>
225+ {props.removing ? "Removing..." : "Remove source"}
226+ </AlertDialogAction>
227+ </AlertDialogFooter>
228+ </AlertDialogContent>
229+ </AlertDialog>
230+ </div>
231+ </div>
232+
233+ <div className="space-y-5 p-4">
234+ <div className="grid gap-3 text-sm sm:grid-cols-3">
235+ <Meta label="Status" value={model.status} />
236+ <Meta label="Source ref" value={model.sourceRef} mono />
237+ <Meta label="Tools" value={toolsCountLabel(model.tools.length)} />
238+ </div>
239+
240+ {failureLines.length > 0 && (
241+ <Alert variant="destructive">
242+ <AlertTitle>Last sync failed</AlertTitle>
243+ <AlertDescription>
244+ <div className="space-y-1">
245+ {failureLines.map((line) => (
246+ <p key={line}>{line}</p>
247+ ))}
248+ </div>
249+ </AlertDescription>
250+ </Alert>
251+ )}
252+
253+ {notice && (
254+ <Alert variant={notice.status === "failed" ? "destructive" : "default"}>
255+ <AlertTitle>{notice.message}</AlertTitle>
256+ {noticeHasDetails && (
257+ <AlertDescription>
258+ <div className="space-y-1">
259+ {notice.added.length > 0 && <p>Added: {notice.added.join(", ")}</p>}
260+ {notice.removed.length > 0 && <p>Removed: {notice.removed.join(", ")}</p>}
261+ {notice.errors.map((error) => (
262+ <p key={error}>{error}</p>
263+ ))}
264+ {notice.sourceRef && (
265+ <p className="font-mono text-xs text-muted-foreground">
266+ Commit {notice.sourceRef}
267+ </p>
268+ )}
269+ </div>
270+ </AlertDescription>
271+ )}
272+ </Alert>
273+ )}
274+
275+ {props.removeError && <FormErrorAlert message={props.removeError} />}
276+ </div>
277+ </div>
278+ );
279+}
280+
281+function Meta(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) {
282+ return (
283+ <div className="min-w-0">
284+ <p className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
285+ {props.label}
286+ </p>
287+ <p className={props.mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate text-sm"}>
288+ {props.value}
289+ </p>
290+ </div>
291+ );
292+}
packages/plugins/apps/src/react/custom-tools-client.ts+375−0
@@ -0,0 +1,375 @@
1+import { Data, Effect, Schema } from "effect";
2+import {
3+ EXECUTOR_ORG_HEADER,
4+ getActiveOrgSlug,
5+ getExecutorApiBaseUrl,
6+ getExecutorServerAuthorizationHeader,
7+} from "@executor-js/react/api/server-connection";
8+
9+export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
10+export const CUSTOM_TOOLS_LABEL = "Custom tools";
11+
12+export type AppSourceKind = "git" | "local-directory";
13+
14+export interface SyncDiagnostic {
15+ readonly stage: "source" | "discover" | "bundle" | "collect" | "project";
16+ readonly message: string;
17+ readonly diagnostics?: readonly { readonly path: string; readonly message: string }[];
18+}
19+
20+export type SourceStatus =
21+ | { readonly type: "pending" }
22+ | {
23+ readonly type: "published" | "up-to-date";
24+ readonly at: number;
25+ readonly tools: readonly string[];
26+ }
27+ | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] };
28+
29+export type AppSourceRecord =
30+ | {
31+ readonly slug: string;
32+ readonly app: string;
33+ readonly kind: "git";
34+ readonly config: {
35+ readonly kind: "git";
36+ readonly url: string;
37+ readonly ref?: string;
38+ readonly tokenProvider?: string;
39+ readonly tokenItemId?: string;
40+ };
41+ readonly sourceRef?: string;
42+ readonly description?: string;
43+ readonly status: SourceStatus;
44+ readonly updatedAt: number;
45+ }
46+ | {
47+ readonly slug: string;
48+ readonly app: string;
49+ readonly kind: "local-directory";
50+ readonly config: { readonly kind: "local-directory"; readonly path: string };
51+ readonly sourceRef?: string;
52+ readonly description?: string;
53+ readonly status: SourceStatus;
54+ readonly updatedAt: number;
55+ };
56+
57+export interface SourcesListResponse {
58+ readonly sources: readonly AppSourceRecord[];
59+}
60+
61+export interface SourceDetailResponse {
62+ readonly source: AppSourceRecord | null;
63+}
64+
65+export type CreateSourceRequest =
66+ | {
67+ readonly kind: "git";
68+ readonly slug?: string;
69+ readonly app?: string;
70+ readonly url: string;
71+ readonly ref?: string;
72+ readonly token?: string;
73+ }
74+ | {
75+ readonly kind: "local-directory";
76+ readonly slug?: string;
77+ readonly app?: string;
78+ readonly path: string;
79+ };
80+
81+export interface CreateSourceResponse {
82+ readonly source: AppSourceRecord;
83+}
84+
85+export type SyncSourceResult =
86+ | {
87+ readonly status: "published" | "up-to-date";
88+ readonly sourceRef: string;
89+ readonly tools: readonly string[];
90+ readonly errors?: undefined;
91+ }
92+ | {
93+ readonly status: "failed";
94+ readonly sourceRef?: string;
95+ readonly tools: readonly string[];
96+ readonly errors: readonly SyncDiagnostic[];
97+ };
98+
99+export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
100+
101+const customToolsFetch: CustomToolsFetch = (input, init = {}) => {
102+ const headers = new Headers(init.headers);
103+ const authorization = getExecutorServerAuthorizationHeader();
104+ if (authorization && !headers.has("authorization")) {
105+ headers.set("authorization", authorization);
106+ }
107+ const orgSlug = getActiveOrgSlug();
108+ if (orgSlug && !headers.has(EXECUTOR_ORG_HEADER)) {
109+ headers.set(EXECUTOR_ORG_HEADER, orgSlug);
110+ }
111+ const url =
112+ typeof input === "string" && input.startsWith("/")
113+ ? new URL(input, getExecutorApiBaseUrl()).toString()
114+ : input;
115+ return fetch(url, { ...init, headers });
116+};
117+
118+export interface CustomToolsDirectoryEntry {
119+ readonly name: string;
120+ readonly path: string;
121+ readonly isSymlink: boolean;
122+ readonly hasTools: boolean;
123+}
124+
125+export interface CustomToolsDirectorySourceShape {
126+ readonly toolFiles: readonly string[];
127+ readonly skipped: readonly string[];
128+ readonly hasPackageJson: boolean;
129+}
130+
131+export interface CustomToolsDirectoryListing {
132+ readonly path: string;
133+ readonly parent: string | null;
134+ readonly dirs: readonly CustomToolsDirectoryEntry[];
135+ readonly source: CustomToolsDirectorySourceShape;
136+}
137+
138+export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
139+ readonly message: string;
140+}> {}
141+
142+export const slugifyCustomToolsAppName = (value: string): string =>
143+ value
144+ .trim()
145+ .toLowerCase()
146+ .replace(/[^a-z0-9-]+/g, "-")
147+ .replace(/^-+|-+$/g, "")
148+ .slice(0, 80);
149+
150+export const validateCustomToolsAppSlug = (value: string): string | null => {
151+ if (!value.trim()) return "Enter a source name.";
152+ if (value !== slugifyCustomToolsAppName(value)) {
153+ return "Use lowercase letters, numbers, and hyphens.";
154+ }
155+ return null;
156+};
157+
158+export const parseGitSourceUrl = (
159+ raw: string,
160+):
161+ | { readonly ok: true; readonly url: string; readonly name: string }
162+ | { readonly ok: false; readonly message: string } => {
163+ const value = raw.trim();
164+ if (!value) return { ok: false, message: "Enter a Git repository URL." };
165+ if (!URL.canParse(value)) return { ok: false, message: "Enter an https Git repository URL." };
166+ const parsed = new URL(value);
167+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
168+ return { ok: false, message: "Git repository URLs must use http or https." };
169+ }
170+ if (parsed.username || parsed.password) {
171+ return { ok: false, message: "Do not include credentials in the URL. Use the token field." };
172+ }
173+ const segments = parsed.pathname.split("/").filter(Boolean);
174+ const last = segments.at(-1)?.replace(/\.git$/, "") ?? "";
175+ if (!last) return { ok: false, message: "Enter a repository URL with a path." };
176+ parsed.hash = "";
177+ return { ok: true, url: parsed.toString(), name: last };
178+};
179+
180+export const suggestCustomToolsAppName = (urlOrPath: string): string => {
181+ const parsed = parseGitSourceUrl(urlOrPath);
182+ if (parsed.ok) return parsed.name;
183+ const pathParts = urlOrPath.split("/").filter(Boolean);
184+ return pathParts.at(-1) ?? "";
185+};
186+
187+export const validateGitSourceUrl = (url: string): string | null => {
188+ const parsed = parseGitSourceUrl(url);
189+ return parsed.ok ? null : parsed.message;
190+};
191+
192+const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
193+const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
194+
195+const isRecord = (value: unknown): value is Record<string, unknown> =>
196+ value !== null && typeof value === "object" && !Array.isArray(value);
197+
198+const responseErrorMessage = (body: unknown, fallback: string): string =>
199+ isRecord(body) && typeof body.message === "string"
200+ ? body.message
201+ : isRecord(body) && typeof body.error === "string"
202+ ? body.error
203+ : fallback;
204+
205+const parseJsonResponseEffect = <A>(
206+ response: Response,
207+ fallback: string,
208+): Effect.Effect<A, CustomToolsClientError> =>
209+ Effect.gen(function* () {
210+ const text = yield* Effect.tryPromise({
211+ try: () => response.text(),
212+ catch: () => new CustomToolsClientError({ message: fallback }),
213+ });
214+ const body =
215+ text.length > 0
216+ ? yield* decodeJsonText(text).pipe(
217+ Effect.mapError(() => new CustomToolsClientError({ message: fallback })),
218+ )
219+ : null;
220+ if (!response.ok) {
221+ return yield* new CustomToolsClientError({
222+ message: responseErrorMessage(body, fallback),
223+ });
224+ }
225+ return body as A;
226+ });
227+
228+export const listCustomToolSourcesEffect = (
229+ fetchImpl: CustomToolsFetch = customToolsFetch,
230+): Effect.Effect<SourcesListResponse, CustomToolsClientError> =>
231+ Effect.tryPromise({
232+ try: () => fetchImpl("/api/apps/sources", { credentials: "same-origin" }),
233+ catch: () => new CustomToolsClientError({ message: "Failed to load custom tools sources." }),
234+ }).pipe(
235+ Effect.flatMap((response) =>
236+ parseJsonResponseEffect<SourcesListResponse>(
237+ response,
238+ "Failed to load custom tools sources.",
239+ ),
240+ ),
241+ );
242+
243+export const listCustomToolSources = (
244+ fetchImpl: CustomToolsFetch = customToolsFetch,
245+): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));
246+
247+export const getCustomToolSourceEffect = (
248+ slug: string,
249+ fetchImpl: CustomToolsFetch = customToolsFetch,
250+): Effect.Effect<SourceDetailResponse, CustomToolsClientError> =>
251+ Effect.tryPromise({
252+ try: () =>
253+ fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, {
254+ credentials: "same-origin",
255+ }),
256+ catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }),
257+ }).pipe(
258+ Effect.flatMap((response) =>
259+ parseJsonResponseEffect<SourceDetailResponse>(
260+ response,
261+ "Failed to load custom tools source.",
262+ ),
263+ ),
264+ );
265+
266+export const createCustomToolSourceEffect = (
267+ input: CreateSourceRequest,
268+ fetchImpl: CustomToolsFetch = customToolsFetch,
269+): Effect.Effect<CreateSourceResponse, CustomToolsClientError> =>
270+ Effect.tryPromise({
271+ try: () =>
272+ fetchImpl("/api/apps/sources", {
273+ method: "POST",
274+ headers: { "content-type": "application/json" },
275+ credentials: "same-origin",
276+ body: encodeJsonText(input),
277+ }),
278+ catch: () => new CustomToolsClientError({ message: "Failed to create custom tools source." }),
279+ }).pipe(
280+ Effect.flatMap((response) =>
281+ parseJsonResponseEffect<CreateSourceResponse>(
282+ response,
283+ "Failed to create custom tools source.",
284+ ),
285+ ),
286+ );
287+
288+export const listCustomToolDirectoriesEffect = (
289+ input: { readonly path?: string; readonly includeHidden?: boolean } = {},
290+ fetchImpl: CustomToolsFetch = customToolsFetch,
291+): Effect.Effect<CustomToolsDirectoryListing, CustomToolsClientError> =>
292+ Effect.tryPromise({
293+ try: () => {
294+ const query = new URLSearchParams();
295+ if (input.path) query.set("path", input.path);
296+ if (input.includeHidden) query.set("includeHidden", "true");
297+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
298+ return fetchImpl(`/api/apps/fs/dirs${suffix}`, { credentials: "same-origin" });
299+ },
300+ catch: () => new CustomToolsClientError({ message: "Failed to browse directories." }),
301+ }).pipe(
302+ Effect.flatMap((response) =>
303+ parseJsonResponseEffect<CustomToolsDirectoryListing>(
304+ response,
305+ "Failed to browse directories.",
306+ ),
307+ ),
308+ );
309+
310+export const syncCustomToolSourceEffect = (
311+ slug: string,
312+ fetchImpl: CustomToolsFetch = customToolsFetch,
313+): Effect.Effect<SyncSourceResult, CustomToolsClientError> =>
314+ Effect.tryPromise({
315+ try: () =>
316+ fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}/sync`, {
317+ method: "POST",
318+ credentials: "same-origin",
319+ }),
320+ catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools source." }),
321+ }).pipe(
322+ Effect.flatMap((response) =>
323+ parseJsonResponseEffect<SyncSourceResult>(response, "Failed to sync custom tools source."),
324+ ),
325+ );
326+
327+export const removeCustomToolSourceEffect = (
328+ slug: string,
329+ fetchImpl: CustomToolsFetch = customToolsFetch,
330+): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> =>
331+ Effect.tryPromise({
332+ try: () =>
333+ fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, {
334+ method: "DELETE",
335+ credentials: "same-origin",
336+ }),
337+ catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }),
338+ }).pipe(
339+ Effect.flatMap((response) =>
340+ parseJsonResponseEffect<{ readonly removed: boolean }>(
341+ response,
342+ "Failed to remove custom tools source.",
343+ ),
344+ ),
345+ );
346+
347+export const syncStatusLabel = (result: SyncSourceResult): string => {
348+ if (result.status === "published") return `Published ${result.tools.length} tools.`;
349+ if (result.status === "up-to-date") return "Already up to date.";
350+ return "Sync failed.";
351+};
352+
353+export const formatDiagnostics = (
354+ errors: readonly SyncDiagnostic[] | undefined,
355+): readonly string[] =>
356+ (errors ?? []).flatMap((entry) => {
357+ const head = `${entry.stage}: ${entry.message}`;
358+ const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`) ?? [];
359+ return details.length > 0 ? [head, ...details] : [head];
360+ });
361+
362+export const formatSyncErrors = (result: SyncSourceResult): readonly string[] =>
363+ result.status === "failed" ? formatDiagnostics(result.errors) : [];
364+
365+export const toolDiff = (
366+ before: readonly string[],
367+ after: readonly string[],
368+): { readonly added: readonly string[]; readonly removed: readonly string[] } => {
369+ const beforeSet = new Set(before);
370+ const afterSet = new Set(after);
371+ return {
372+ added: after.filter((tool) => !beforeSet.has(tool)),
373+ removed: before.filter((tool) => !afterSet.has(tool)),
374+ };
375+};
packages/plugins/apps/src/react/custom-tools-client.web.test.ts+267−0
@@ -0,0 +1,267 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import appsClientPlugin, {
5+ CUSTOM_TOOLS_LABEL,
6+ CUSTOM_TOOLS_PLUGIN_KEY,
7+ createCustomToolSourceEffect,
8+ formatSyncErrors,
9+ listCustomToolDirectoriesEffect,
10+ listCustomToolSources,
11+ parseGitSourceUrl,
12+ removeCustomToolSourceEffect,
13+ slugifyCustomToolsAppName,
14+ suggestCustomToolsAppName,
15+ syncCustomToolSourceEffect,
16+ syncStatusLabel,
17+ type AppSourceRecord,
18+ type CustomToolsFetch,
19+} from "./plugin-client";
20+import {
21+ directorySourceVerdict,
22+ sourcePanelModel,
23+ syncNoticeFromResult,
24+} from "./source-panel-model";
25+import { directoryBrowserRows } from "./source-panel-model";
26+
27+const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
28+ new Response(JSON.stringify(body), {
29+ status: 200,
30+ headers: { "content-type": "application/json" },
31+ ...init,
32+ });
33+
34+const demoSource: AppSourceRecord = {
35+ slug: "demo-tools",
36+ app: "demo-tools",
37+ kind: "git",
38+ config: {
39+ kind: "git",
40+ url: "https://gitlab.com/acme/demo-tools.git",
41+ ref: "main",
42+ tokenProvider: "default",
43+ tokenItemId: "apps/source-tokens/demo-tools",
44+ },
45+ sourceRef: "abc123def456",
46+ description: "Demo tools",
47+ status: {
48+ type: "published",
49+ at: Date.parse("2026-07-06T12:00:00.000Z"),
50+ tools: ["repo-summary", "stale-issues"],
51+ },
52+ updatedAt: Date.parse("2026-07-06T12:00:00.000Z"),
53+};
54+
55+describe("custom tools console client", () => {
56+ it("registers the Apps integration plugin", () => {
57+ const plugin = appsClientPlugin({ sourceKinds: ["git"] });
58+
59+ expect(plugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY);
60+ expect(plugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL);
61+ });
62+
63+ it("validates and suggests generic https Git repository URLs", () => {
64+ expect(parseGitSourceUrl("https://github.com/UsefulSoftwareCo/executor.git")).toMatchObject({
65+ ok: true,
66+ name: "executor",
67+ url: "https://github.com/UsefulSoftwareCo/executor.git",
68+ });
69+ expect(parseGitSourceUrl("https://gitlab.com/acme/tools")).toMatchObject({
70+ ok: true,
71+ name: "tools",
72+ });
73+ expect(parseGitSourceUrl("ssh://gitlab.com/acme/tools")).toMatchObject({
74+ ok: false,
75+ message: "Git repository URLs must use http or https.",
76+ });
77+ expect(parseGitSourceUrl("https://x:secret@gitlab.com/acme/tools")).toMatchObject({
78+ ok: false,
79+ message: "Do not include credentials in the URL. Use the token field.",
80+ });
81+ expect(suggestCustomToolsAppName("https://gitlab.com/acme/tools.git")).toBe("tools");
82+ expect(slugifyCustomToolsAppName("Acme Tools!")).toBe("acme-tools");
83+ });
84+
85+ it("uses the new source routes and keeps token-bearing input out of read responses", async () => {
86+ const calls: Array<{ readonly url: string; readonly init?: RequestInit }> = [];
87+ let createBody = "";
88+ const fetchImpl: CustomToolsFetch = async (input, init) => {
89+ const url = String(input);
90+ calls.push({ url, init });
91+ if (url === "/api/apps/sources" && init?.method === "POST") {
92+ createBody = String(init.body);
93+ return jsonResponse({ source: demoSource });
94+ }
95+ if (url === "/api/apps/sources") {
96+ return jsonResponse({ sources: [demoSource] });
97+ }
98+ if (url === "/api/apps/sources/demo-tools/sync") {
99+ return jsonResponse({
100+ status: "up-to-date",
101+ sourceRef: "abc123def456",
102+ tools: ["repo-summary", "stale-issues"],
103+ });
104+ }
105+ if (url === "/api/apps/sources/demo-tools") {
106+ return jsonResponse({ removed: true });
107+ }
108+ if (url === "/api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true") {
109+ return jsonResponse({
110+ path: "/Users/ada/tools",
111+ parent: "/Users/ada",
112+ dirs: [
113+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true },
114+ ],
115+ source: { toolFiles: ["greet.ts"], skipped: [], hasPackageJson: true },
116+ });
117+ }
118+ return jsonResponse({ error: "not found" }, { status: 404 });
119+ };
120+
121+ const created = await Effect.runPromise(
122+ createCustomToolSourceEffect(
123+ {
124+ kind: "git",
125+ slug: "demo-tools",
126+ app: "demo-tools",
127+ url: "https://gitlab.com/acme/demo-tools.git",
128+ token: "ghp_secret",
129+ },
130+ fetchImpl,
131+ ),
132+ );
133+ const listed = await listCustomToolSources(fetchImpl);
134+ const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl));
135+ const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl));
136+ const dirs = await Effect.runPromise(
137+ listCustomToolDirectoriesEffect({ path: "/Users/ada/tools", includeHidden: true }, fetchImpl),
138+ );
139+
140+ expect(created.source.slug).toBe("demo-tools");
141+ expect(createBody).toContain("ghp_secret");
142+ expect(JSON.stringify(listed)).not.toContain("ghp_secret");
143+ expect(syncStatusLabel(synced)).toBe("Already up to date.");
144+ expect(removed).toEqual({ removed: true });
145+ expect(dirs.dirs).toEqual([
146+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true },
147+ ]);
148+ expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([
149+ "POST /api/apps/sources",
150+ "GET /api/apps/sources",
151+ "POST /api/apps/sources/demo-tools/sync",
152+ "DELETE /api/apps/sources/demo-tools",
153+ "GET /api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true",
154+ ]);
155+ });
156+
157+ it("models the source panel and sync notice", () => {
158+ const panel = sourcePanelModel(demoSource, {
159+ now: Date.parse("2026-07-06T12:05:00.000Z"),
160+ });
161+ const notice = syncNoticeFromResult(
162+ {
163+ status: "published",
164+ sourceRef: "def456abc123",
165+ tools: ["repo-summary", "stale-issues", "extra-tool"],
166+ },
167+ demoSource.status.type === "published" ? demoSource.status.tools : [],
168+ );
169+
170+ expect(panel.title).toBe("demo-tools");
171+ expect(panel.source).toBe("Git repository: https://gitlab.com/acme/demo-tools.git @ main");
172+ expect(panel.status).toBe("Published 5m ago");
173+ expect(panel.sourceRef).toBe("abc123def456");
174+ expect(panel.tools).toEqual(["repo-summary", "stale-issues"]);
175+ expect(notice.message).toBe("Published 3 tools.");
176+ expect(notice.added).toEqual(["extra-tool"]);
177+ expect(notice.sourceRef).toBe("def456abc123");
178+ });
179+
180+ it("models the directory browser rows", () => {
181+ expect(
182+ directoryBrowserRows({
183+ path: "/Users/ada/tools",
184+ parent: "/Users/ada",
185+ dirs: [
186+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: false },
187+ { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true, hasTools: false },
188+ ],
189+ source: { toolFiles: [], skipped: [], hasPackageJson: false },
190+ }),
191+ ).toEqual([
192+ { kind: "parent", name: "..", path: "/Users/ada" },
193+ {
194+ kind: "dir",
195+ name: "alpha",
196+ path: "/Users/ada/tools/alpha",
197+ isSymlink: false,
198+ hasTools: false,
199+ },
200+ {
201+ kind: "dir",
202+ name: "linked",
203+ path: "/Users/ada/tools/linked",
204+ isSymlink: true,
205+ hasTools: false,
206+ },
207+ ]);
208+ });
209+
210+ it("models directory source verdicts", () => {
211+ expect(
212+ directorySourceVerdict({
213+ path: "/repo",
214+ parent: null,
215+ dirs: [{ name: "tools", path: "/repo/tools", isSymlink: false, hasTools: false }],
216+ source: { toolFiles: [], skipped: [], hasPackageJson: false },
217+ }),
218+ ).toEqual({
219+ type: "empty-tools",
220+ message: "tools/ has no tool files (tools/<name>.ts)",
221+ });
222+ expect(
223+ directorySourceVerdict({
224+ path: "/repo",
225+ parent: null,
226+ dirs: [],
227+ source: { toolFiles: [], skipped: [], hasPackageJson: false },
228+ }),
229+ ).toEqual({
230+ type: "missing-tools",
231+ message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.",
232+ });
233+ expect(
234+ directorySourceVerdict({
235+ path: "/repo",
236+ parent: null,
237+ dirs: [],
238+ source: {
239+ toolFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"],
240+ skipped: [],
241+ hasPackageJson: true,
242+ },
243+ }),
244+ ).toEqual({
245+ type: "valid",
246+ message: "6 tools found",
247+ visibleTools: ["a", "b", "c", "d", "e"],
248+ moreCount: 1,
249+ });
250+ });
251+
252+ it("formats failed sync diagnostics by stage", () => {
253+ const errors = formatSyncErrors({
254+ status: "failed",
255+ tools: [],
256+ errors: [
257+ {
258+ stage: "collect",
259+ message: "schema export failed",
260+ diagnostics: [{ path: "tools/x.ts", message: "unsupported schema" }],
261+ },
262+ ],
263+ });
264+
265+ expect(errors).toEqual(["collect: schema export failed", "tools/x.ts: unsupported schema"]);
266+ });
267+});
packages/plugins/apps/src/react/index.ts+2−0
@@ -0,0 +1,2 @@
1+export { default } from "./plugin-client";
2+export * from "./custom-tools-client";
packages/plugins/apps/src/react/plugin-client.tsx+16−0
@@ -0,0 +1,16 @@
1+import { defineClientPlugin } from "@executor-js/sdk/client";
2+
3+import { makeAppsIntegrationPlugin } from "./source-plugin";
4+import type { AppSourceKind } from "./custom-tools-client";
5+
6+export { makeAppsIntegrationPlugin } from "./source-plugin";
7+export * from "./custom-tools-client";
8+
9+export default function appsClientPlugin(config?: {
10+ readonly sourceKinds?: readonly AppSourceKind[];
11+}) {
12+ return defineClientPlugin({
13+ id: "apps",
14+ integrationPlugin: makeAppsIntegrationPlugin(config),
15+ });
16+}
packages/plugins/apps/src/react/source-panel-model.ts+156−0
@@ -0,0 +1,156 @@
1+import type {
2+ AppSourceRecord,
3+ CustomToolsDirectoryListing,
4+ SyncSourceResult,
5+} from "./custom-tools-client";
6+import {
7+ formatDiagnostics,
8+ formatSyncErrors,
9+ syncStatusLabel,
10+ toolDiff,
11+} from "./custom-tools-client";
12+
13+export interface SourcePanelModel {
14+ readonly title: string;
15+ readonly source: string;
16+ readonly sourceRef: string;
17+ readonly status: string;
18+ readonly tools: readonly string[];
19+}
20+
21+export interface SyncNoticeModel {
22+ readonly status: SyncSourceResult["status"];
23+ readonly message: string;
24+ readonly added: readonly string[];
25+ readonly removed: readonly string[];
26+ readonly errors: readonly string[];
27+ readonly sourceRef?: string;
28+}
29+
30+export type DirectoryBrowserRow =
31+ | { readonly kind: "parent"; readonly name: ".."; readonly path: string }
32+ | {
33+ readonly kind: "dir";
34+ readonly name: string;
35+ readonly path: string;
36+ readonly isSymlink: boolean;
37+ readonly hasTools: boolean;
38+ };
39+
40+export type DirectorySourceVerdict =
41+ | {
42+ readonly type: "valid";
43+ readonly message: string;
44+ readonly visibleTools: readonly string[];
45+ readonly moreCount: number;
46+ }
47+ | { readonly type: "empty-tools"; readonly message: string }
48+ | { readonly type: "missing-tools"; readonly message: string };
49+
50+const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
51+
52+export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {
53+ const diffMs = Math.max(0, now - timestamp);
54+ const min = Math.floor(diffMs / 60_000);
55+ if (min < 1) return "just now";
56+ if (min < 60) return `${min}m ago`;
57+ const hrs = Math.floor(min / 60);
58+ if (hrs < 24) return `${hrs}h ago`;
59+ const days = Math.floor(hrs / 24);
60+ if (days < 7) return `${days}d ago`;
61+ return new Date(timestamp).toLocaleDateString();
62+};
63+
64+export const toolsCountLabel = (count: number): string =>
65+ `${count} ${count === 1 ? "tool" : "tools"}`;
66+
67+export const sourcePanelModel = (
68+ source: AppSourceRecord,
69+ options?: { readonly now?: number },
70+): SourcePanelModel => {
71+ const tools =
72+ source.status.type === "published" || source.status.type === "up-to-date"
73+ ? source.status.tools
74+ : [];
75+ const sourceLabel = source.config.kind === "git" ? source.config.url : source.config.path;
76+ const pin = source.config.kind === "git" && source.config.ref ? ` @ ${source.config.ref}` : "";
77+ const status =
78+ source.status.type === "pending"
79+ ? "Pending sync"
80+ : source.status.type === "failed"
81+ ? `Failed ${formatRelativeSyncTime(source.status.at, options?.now)}`
82+ : `${source.status.type === "published" ? "Published" : "Up to date"} ${formatRelativeSyncTime(
83+ source.status.at,
84+ options?.now,
85+ )}`;
86+ return {
87+ title: source.app,
88+ source: `${source.kind === "git" ? "Git repository" : "Local directory"}: ${sourceLabel}${pin}`,
89+ sourceRef: shortRef(source.sourceRef),
90+ status,
91+ tools,
92+ };
93+};
94+
95+export const sourceFailureLines = (source: AppSourceRecord): readonly string[] =>
96+ source.status.type === "failed" ? formatDiagnostics(source.status.errors) : [];
97+
98+export const syncNoticeFromResult = (
99+ result: SyncSourceResult,
100+ beforeTools: readonly string[],
101+): SyncNoticeModel => {
102+ const diff =
103+ result.status === "failed" ? { added: [], removed: [] } : toolDiff(beforeTools, result.tools);
104+ return {
105+ status: result.status,
106+ message: syncStatusLabel(result),
107+ added: diff.added,
108+ removed: diff.removed,
109+ errors: formatSyncErrors(result),
110+ ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}),
111+ };
112+};
113+
114+export const directoryBrowserRows = (
115+ listing: CustomToolsDirectoryListing,
116+): readonly DirectoryBrowserRow[] => [
117+ ...(listing.parent
118+ ? [{ kind: "parent" as const, name: ".." as const, path: listing.parent }]
119+ : []),
120+ ...listing.dirs.map((dir) => ({
121+ kind: "dir" as const,
122+ name: dir.name,
123+ path: dir.path,
124+ isSymlink: dir.isSymlink,
125+ hasTools: dir.hasTools,
126+ })),
127+];
128+
129+const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, "");
130+
131+export const directorySourceVerdict = (
132+ listing: CustomToolsDirectoryListing,
133+): DirectorySourceVerdict => {
134+ const source = listing.source;
135+ if (source.toolFiles.length > 0) {
136+ const toolNames = source.toolFiles.map(toolNameFromFile);
137+ const visibleTools = toolNames.slice(0, 5);
138+ const moreCount = Math.max(0, toolNames.length - visibleTools.length);
139+ return {
140+ type: "valid",
141+ message: `${toolsCountLabel(source.toolFiles.length)} found`,
142+ visibleTools,
143+ moreCount,
144+ };
145+ }
146+ if (listing.dirs.some((dir) => dir.name === "tools" && !dir.isSymlink)) {
147+ return {
148+ type: "empty-tools",
149+ message: "tools/ has no tool files (tools/<name>.ts)",
150+ };
151+ }
152+ return {
153+ type: "missing-tools",
154+ message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.",
155+ };
156+};
packages/plugins/apps/src/react/source-plugin.tsx+36−0
@@ -0,0 +1,36 @@
1+import { lazy } from "react";
2+import type { IntegrationPlugin } from "@executor-js/sdk/client";
3+
4+import {
5+ CUSTOM_TOOLS_LABEL,
6+ CUSTOM_TOOLS_PLUGIN_KEY,
7+ type AppSourceKind,
8+} from "./custom-tools-client";
9+
10+const importAccounts = () => import("./CustomToolsAccountsPanel");
11+
12+export const makeAppsIntegrationPlugin = (config?: {
13+ readonly sourceKinds?: readonly AppSourceKind[];
14+}): IntegrationPlugin => {
15+ const sourceKinds = config?.sourceKinds ?? ["git"];
16+ const Add = lazy(async () => {
17+ const mod = await import("./AddCustomToolsSource");
18+ const AddCustomToolsSource = mod.default;
19+ return {
20+ default: (props: Parameters<typeof mod.default>[0]) => (
21+ <AddCustomToolsSource {...props} sourceKinds={sourceKinds} />
22+ ),
23+ };
24+ });
25+ const Accounts = lazy(importAccounts);
26+ return {
27+ key: CUSTOM_TOOLS_PLUGIN_KEY,
28+ label: CUSTOM_TOOLS_LABEL,
29+ add: Add,
30+ accounts: Accounts,
31+ preload: () => {
32+ void import("./AddCustomToolsSource");
33+ void importAccounts();
34+ },
35+ };
36+};
packages/plugins/apps/src/source/app-source.test.ts+100−2
@@ -1,7 +1,7 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */
22 import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
33 import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
4−import { tmpdir } from "node:os";
4+import { homedir, tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { deflateSync } from "node:zlib";
77
@@ -14,7 +14,7 @@ import { parsePack, walkTree } from "../git-client/packfile"; · 6 unmodified lines
1414 import { authForHost, checkRefs, uploadPack } from "../git-client/transport";
1515 import { PUBLISH_LIMITS } from "../pipeline/publish";
1616 import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source";
17−import { fetchLocalDirectoryAppSource } from "./local-directory-source";
17+import { fetchLocalDirectoryAppSource, listLocalDirectoryDirs } from "./local-directory-source";
1818
1919 const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
2020 const textDecoder = new TextDecoder();
@@ -227,6 +227,7 @@ describe("git app sources", () => { · 206 unmodified lines
227227 it.effect("rejects private git hosts under cloud posture", () =>
228228 Effect.gen(function* () {
229229 for (const url of [
230+ "http://localhost/repo.git",
230231 "https://localhost/repo.git",
231232 "https://127.0.0.1/repo.git",
232233 "https://[::ffff:127.0.0.1]/repo.git",
@@ -239,6 +240,11 @@ describe("git app sources", () => { · 6 unmodified lines
239240 const exit = yield* Effect.exit(parseGitSourceUrl(url));
240241 expect(Exit.isFailure(exit)).toBe(true);
241242 }
243+ expect(
244+ yield* parseGitSourceUrl("http://127.0.0.1:9999/repo.git", {
245+ allowPrivateHosts: true,
246+ }),
247+ ).toBeInstanceOf(URL);
242248 expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL);
243249 }),
244250 );
@@ -461,6 +467,98 @@ describe("local-directory app sources", () => { · 216 unmodified lines
461467 expect(Exit.isFailure(parent)).toBe(true);
462468 });
463469
470+ it("lists home by default and immediate subdirectories only", async () => {
471+ const result = await run(listLocalDirectoryDirs({}));
472+ expect(result.path).toBe(homedir());
473+ expect(result.dirs.every((entry) => entry.path.startsWith(result.path))).toBe(true);
474+ });
475+
476+ it("lists subdirectories, filters dotdirs, and can include hidden dirs", async () => {
477+ const root = await mkdtemp();
478+ await mkdir(join(root, "alpha"));
479+ await mkdir(join(root, ".hidden"));
480+ await writeFile(join(root, "file.txt"), "not a directory");
481+
482+ const visible = await run(listLocalDirectoryDirs({ path: root }));
483+ const hidden = await run(listLocalDirectoryDirs({ path: root, includeHidden: true }));
484+
485+ expect(visible.dirs.map((entry) => entry.name)).toEqual(["alpha"]);
486+ expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]);
487+ });
488+
489+ it("reports app source shape and subdirectories with tools folders", async () => {
490+ const root = await mkdtemp();
491+ const valid = join(root, "valid-app");
492+ const emptyTools = join(root, "empty-tools");
493+ const noTools = join(root, "no-tools");
494+ const nested = join(root, "parent-with-tools");
495+ await mkdir(join(valid, "tools"), { recursive: true });
496+ await mkdir(join(valid, "workflows"), { recursive: true });
497+ await mkdir(join(valid, "ui"), { recursive: true });
498+ await mkdir(join(valid, "skills"), { recursive: true });
499+ await mkdir(join(emptyTools, "tools"), { recursive: true });
500+ await mkdir(noTools, { recursive: true });
501+ await mkdir(join(nested, "tools"), { recursive: true });
502+ await writeFile(join(valid, "package.json"), "{}");
503+ await writeFile(join(valid, "tools", "a.ts"), "export default {};");
504+ await writeFile(join(valid, "tools", "b.tsx"), "export default {};");
505+ await writeFile(join(valid, "tools", "bad_name.ts"), "export default {};");
506+
507+ const parentListing = await run(listLocalDirectoryDirs({ path: root }));
508+ const validListing = await run(listLocalDirectoryDirs({ path: valid }));
509+ const emptyListing = await run(listLocalDirectoryDirs({ path: emptyTools }));
510+ const noToolsListing = await run(listLocalDirectoryDirs({ path: noTools }));
511+
512+ expect(parentListing.dirs).toEqual(
513+ expect.arrayContaining([
514+ expect.objectContaining({ name: "valid-app", hasTools: true }),
515+ expect.objectContaining({ name: "empty-tools", hasTools: true }),
516+ expect.objectContaining({ name: "no-tools", hasTools: false }),
517+ expect.objectContaining({ name: "parent-with-tools", hasTools: true }),
518+ ]),
519+ );
520+ expect(validListing.source).toEqual({
521+ toolFiles: ["a.ts", "b.tsx"],
522+ skipped: ["workflows", "ui", "skills"],
523+ hasPackageJson: true,
524+ });
525+ expect(emptyListing.source).toEqual({
526+ toolFiles: [],
527+ skipped: [],
528+ hasPackageJson: false,
529+ });
530+ expect(noToolsListing.source).toEqual({
531+ toolFiles: [],
532+ skipped: [],
533+ hasPackageJson: false,
534+ });
535+ });
536+
537+ it("rejects parent traversal in directory listings", async () => {
538+ const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" }));
539+ expect(Exit.isFailure(exit)).toBe(true);
540+ });
541+
542+ it("marks symlinked directories and does not descend through them", async () => {
543+ const root = await mkdtemp();
544+ const target = await mkdtemp();
545+ await mkdir(join(target, "nested"));
546+ await symlink(target, join(root, "linked"));
547+
548+ const listed = await run(listLocalDirectoryDirs({ path: root }));
549+ expect(listed.dirs).toContainEqual({
550+ name: "linked",
551+ path: join(root, "linked"),
552+ isSymlink: true,
553+ hasTools: false,
554+ });
555+
556+ const followed = await Effect.runPromiseExit(
557+ listLocalDirectoryDirs({ path: join(root, "linked") }),
558+ );
559+ expect(Exit.isFailure(followed)).toBe(true);
560+ });
561+
464562 it("does not read symlinks that escape the source root", async () => {
465563 const root = await mkdtemp();
466564 const outside = await mkdtemp();
packages/plugins/apps/src/source/local-directory-source.ts+162−4
@@ -1,11 +1,13 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-tagged-error -- boundary: filesystem path validation and fs errors normalize to AppSourceError */
22 import { createHash } from "node:crypto";
33 import { constants } from "node:fs";
4−import { lstat, open, readdir, realpath } from "node:fs/promises";
5−import { isAbsolute, resolve, sep } from "node:path";
4+import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
5+import { homedir } from "node:os";
6+import { dirname, isAbsolute, resolve, sep } from "node:path";
67
78 import { Effect, Schema } from "effect";
89
10+import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover";
911 import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";
1012 import { AppSourceError, type AppSourceSnapshot } from "./app-source";
1113 import {
@@ -23,10 +25,35 @@ export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { · 11 unmodified lines
2325 readonly skipped: readonly SourceSkippedFile[];
2426 }
2527
28+export interface LocalDirectoryDirsInput {
29+ readonly path?: string;
30+ readonly includeHidden?: boolean;
31+}
32+
33+export interface LocalDirectoryDirEntry {
34+ readonly name: string;
35+ readonly path: string;
36+ readonly isSymlink: boolean;
37+ readonly hasTools: boolean;
38+}
39+
40+export interface LocalDirectorySourceShape {
41+ readonly toolFiles: readonly string[];
42+ readonly skipped: readonly string[];
43+ readonly hasPackageJson: boolean;
44+}
45+
46+export interface LocalDirectoryDirsResult {
47+ readonly path: string;
48+ readonly parent: string | null;
49+ readonly dirs: readonly LocalDirectoryDirEntry[];
50+ readonly source: LocalDirectorySourceShape;
51+}
52+
2653 const sha256 = (bytes: Uint8Array | string): string =>
2754 createHash("sha256").update(bytes).digest("hex");
2855
29−const validateRoot = (path: string): Effect.Effect<string, AppSourceError> =>
56+export const validateLocalDirectoryPath = (path: string): Effect.Effect<string, AppSourceError> =>
3057 Effect.try({
3158 try: () => {
3259 if (!isAbsolute(path)) {
@@ -175,11 +202,65 @@ const descriptionFor = ( · 142 unmodified lines
175202 return typeof description === "string" ? description : undefined;
176203 });
177204
205+const lstatOrNull = (path: string): Effect.Effect<Awaited<ReturnType<typeof lstat>> | null> =>
206+ Effect.tryPromise({
207+ try: () => lstat(path),
208+ catch: (cause) =>
209+ new AppSourceError({ message: "failed to stat local-directory path", path, cause }),
210+ }).pipe(Effect.catch(() => Effect.succeed(null)));
211+
212+const hasNonSymlinkDirectory = (path: string): Effect.Effect<boolean> =>
213+ Effect.gen(function* () {
214+ const entry = yield* lstatOrNull(path);
215+ return entry !== null && entry.isDirectory() && !entry.isSymbolicLink();
216+ });
217+
218+const hasNonSymlinkFile = (path: string): Effect.Effect<boolean> =>
219+ Effect.gen(function* () {
220+ const entry = yield* lstatOrNull(path);
221+ return entry !== null && entry.isFile() && !entry.isSymbolicLink();
222+ });
223+
224+const listLocalDirectorySourceShape = (
225+ root: string,
226+): Effect.Effect<LocalDirectorySourceShape, AppSourceError> =>
227+ Effect.gen(function* () {
228+ const toolsPath = `${root}${sep}tools`;
229+ const toolsStat = yield* lstatOrNull(toolsPath);
230+ const toolFiles: string[] = [];
231+ if (toolsStat?.isDirectory() && !toolsStat.isSymbolicLink()) {
232+ const toolEntries = yield* Effect.tryPromise({
233+ try: () => readdir(toolsPath, { withFileTypes: true }),
234+ catch: (cause) =>
235+ new AppSourceError({
236+ message: "failed to read local-directory tools folder",
237+ path: toolsPath,
238+ cause,
239+ }),
240+ });
241+ for (const entry of toolEntries) {
242+ if (!entry.isFile()) continue;
243+ if (toolKeyFromPath(`tools/${entry.name}`) === null) continue;
244+ toolFiles.push(entry.name);
245+ }
246+ toolFiles.sort((a, b) => a.localeCompare(b));
247+ }
248+ const skipped: string[] = [];
249+ for (const name of RESERVED_ARTIFACT_DIRS) {
250+ if (yield* hasNonSymlinkDirectory(`${root}${sep}${name}`)) skipped.push(name);
251+ }
252+ return {
253+ toolFiles: toolFiles.slice(0, 20),
254+ skipped,
255+ hasPackageJson: yield* hasNonSymlinkFile(`${root}${sep}package.json`),
256+ };
257+ });
258+
178259 export const fetchLocalDirectoryAppSource = (
179260 input: LocalDirectoryAppSourceInput,
180261 ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>
181262 Effect.gen(function* () {
182− const root = yield* validateRoot(input.path);
263+ const root = yield* validateLocalDirectoryPath(input.path);
183264 const rootReal = yield* Effect.tryPromise({
184265 try: () => realpath(root),
185266 catch: (cause) =>
@@ -199,6 +280,83 @@ export const fetchLocalDirectoryAppSource = ( · 13 unmodified lines
199280 };
200281 });
201282
283+export const listLocalDirectoryDirs = (
284+ input: LocalDirectoryDirsInput,
285+): Effect.Effect<LocalDirectoryDirsResult, AppSourceError> =>
286+ Effect.gen(function* () {
287+ const root = yield* validateLocalDirectoryPath(input.path?.trim() || homedir());
288+ const rootStat = yield* Effect.tryPromise({
289+ try: () => lstat(root),
290+ catch: (cause) =>
291+ new AppSourceError({
292+ message: "failed to read local-directory source",
293+ path: root,
294+ cause,
295+ }),
296+ });
297+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
298+ return yield* new AppSourceError({
299+ message: "local-directory source path must be a directory",
300+ path: root,
301+ });
302+ }
303+ const entries = yield* Effect.tryPromise({
304+ try: () => readdir(root),
305+ catch: (cause) =>
306+ new AppSourceError({
307+ message: "failed to read local-directory source",
308+ path: root,
309+ cause,
310+ }),
311+ });
312+ const dirs: LocalDirectoryDirEntry[] = [];
313+ const source = yield* listLocalDirectorySourceShape(root);
314+ for (const name of entries) {
315+ if (!input.includeHidden && name.startsWith(".")) continue;
316+ const child = `${root}${sep}${name}`;
317+ const entryStat = yield* Effect.tryPromise({
318+ try: () => lstat(child),
319+ catch: (cause) =>
320+ new AppSourceError({
321+ message: "failed to stat local-directory child",
322+ path: child,
323+ cause,
324+ }),
325+ }).pipe(Effect.catch(() => Effect.succeed(null)));
326+ if (!entryStat) continue;
327+ if (entryStat.isDirectory()) {
328+ dirs.push({
329+ name,
330+ path: child,
331+ isSymlink: false,
332+ hasTools: yield* hasNonSymlinkDirectory(`${child}${sep}tools`),
333+ });
334+ continue;
335+ }
336+ if (!entryStat.isSymbolicLink()) continue;
337+ const targetStat = yield* Effect.tryPromise({
338+ try: () => stat(child),
339+ catch: (cause) =>
340+ new AppSourceError({
341+ message: "failed to stat local-directory symlink target",
342+ path: child,
343+ cause,
344+ }),
345+ }).pipe(Effect.catch(() => Effect.succeed(null)));
346+ if (targetStat?.isDirectory()) {
347+ dirs.push({ name, path: child, isSymlink: true, hasTools: false });
348+ }
349+ }
350+ dirs.sort((a, b) => a.name.localeCompare(b.name));
351+ const parent = dirname(root);
352+ return {
353+ path: root,
354+ parent: parent === root ? null : parent,
355+ dirs,
356+ source,
357+ };
358+ });
359+
202360 export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({
203361 fetch: () => fetchLocalDirectoryAppSource(input),
204362 });
packages/plugins/apps/src/testing/conformance.ts+97−0
@@ -41,6 +41,103 @@ export const appToolExecutorConformance = ( · 40 unmodified lines
4141 }),
4242 );
4343
44+ it.effect("collects Standard Schema input JSON schema extensions", () =>
45+ Effect.gen(function* () {
46+ const bundled = yield* bundle(`
47+ import { z } from "zod";
48+ import { defineTool } from "executor:app";
49+ export default defineTool({
50+ description: "Zod schema",
51+ input: z.object({ name: z.string(), count: z.number().optional() }),
52+ handler(input) { return input; },
53+ });
54+ `);
55+ const collected = yield* makeExecutor().collect(bundled.code, {
56+ fileSlug: "conformance",
57+ sourcePath: "tools/conformance.ts",
58+ });
59+ const inputSchema = collected.tools[0]?.inputSchema as {
60+ readonly properties?: Record<string, { readonly type?: string }>;
61+ readonly required?: readonly string[];
62+ };
63+ expect(inputSchema.properties?.name).toMatchObject({ type: "string" });
64+ expect(inputSchema.properties?.count).toBeDefined();
65+ expect(inputSchema.required ?? []).toContain("name");
66+ expect(inputSchema.required ?? []).not.toContain("count");
67+ expect(inputSchema).not.toHaveProperty("$schema");
68+ }),
69+ );
70+
71+ it.effect("rejects integration keys that collide with Standard Schema input fields", () =>
72+ Effect.gen(function* () {
73+ const bundled = yield* bundle(`
74+ import { z } from "zod";
75+ import { defineTool, integration } from "executor:app";
76+ export default defineTool({
77+ description: "Collision",
78+ integrations: { crm: integration("dealcloud") },
79+ input: z.object({ crm: z.string() }),
80+ handler(input) { return input; },
81+ });
82+ `);
83+ const error = yield* makeExecutor()
84+ .collect(bundled.code, {
85+ fileSlug: "conformance",
86+ sourcePath: "tools/conformance.ts",
87+ })
88+ .pipe(Effect.flip);
89+ expect(error).toMatchObject({
90+ kind: "collect",
91+ message: expect.stringContaining('integration key "crm" collides with input field'),
92+ diagnostics: [
93+ {
94+ path: "tools/conformance.ts",
95+ message: "integration key collides with input: crm",
96+ },
97+ ],
98+ });
99+ }),
100+ );
101+
102+ it.effect("rejects Standard Schema libraries without JSON schema extensions", () =>
103+ Effect.gen(function* () {
104+ const bundled = yield* bundle(`
105+ import { defineTool } from "executor:app";
106+ const input = {
107+ "~standard": {
108+ vendor: "fake",
109+ version: 1,
110+ validate(value) { return { value }; },
111+ },
112+ };
113+ export default defineTool({
114+ description: "Missing JSON schema extension",
115+ input,
116+ handler(value) { return value; },
117+ });
118+ `);
119+ const error = yield* makeExecutor()
120+ .collect(bundled.code, {
121+ fileSlug: "conformance",
122+ sourcePath: "tools/conformance.ts",
123+ })
124+ .pipe(Effect.flip);
125+ expect(error).toMatchObject({
126+ kind: "collect",
127+ message: expect.stringContaining(
128+ "schema library does not expose the Standard Schema jsonSchema extension",
129+ ),
130+ diagnostics: [
131+ {
132+ path: "tools/conformance.ts",
133+ message:
134+ "input schema library does not expose the Standard Schema jsonSchema extension",
135+ },
136+ ],
137+ });
138+ }),
139+ );
140+
44141 it.effect("invokes handlers with split input and integrations", () =>
45142 Effect.gen(function* () {
46143 const bundled = yield* bundle(`
packages/plugins/apps/tsconfig.json+2−1
@@ -9,6 +9,7 @@ · 8 unmodified lines
99 "types": ["bun-types", "node"],
1010 "noUnusedLocals": true,
1111 "noImplicitOverride": true,
12+ "jsx": "react-jsx",
1213 "plugins": [
1314 {
1415 "name": "@effect/language-service",
@@ -18,5 +19,5 @@ · 3 unmodified lines
1819 }
1920 ]
2021 },
21− "include": ["src/**/*.ts"]
22+ "include": ["src/**/*.ts", "src/**/*.tsx"]
2223 }
packages/react/src/components/json-schema-form.tsx+31−8
@@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines
9595 return undefined;
9696 };
9797
98+export const emptyEnumGuidance = (description: string | undefined): string => {
99+ const integration = description?.match(/^Connection to use for .+ \(([^)]+)\)$/)?.[1];
100+ return integration ? `Connect ${integration} first.` : "No options available.";
101+};
102+
98103 const DEFAULT_BY_TYPE: Record<string, () => unknown> = {
99104 string: () => "",
100105 number: () => 0,
@@ -105,9 +110,10 @@ const DEFAULT_BY_TYPE: Record<string, () => unknown> = { · 4 unmodified lines
105110 };
106111
107112 /** Seed used when the user clicks "Add" for an optional property or array item.
108− * Never auto-injected on load — only on explicit add. */
113+ * Schema defaults can also be displayed without being injected into JSON. */
109114 export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => {
110115 const s = deepResolve(schema, root);
116+ if (s.default !== undefined) return s.default;
111117 if (s.const !== undefined) return s.const;
112118 if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
113119 const t = primaryType(s);
@@ -223,8 +229,8 @@ export function JsonSchemaForm(props: { · 109 unmodified lines
223229 }
224230
225231 // ---------------------------------------------------------------------------
226−// ObjectFields — renders an object's properties (required first, then present
227−// optionals, then "Add" rows for absent optionals)
232+// ObjectFields — renders an object's properties (required/default-backed first,
233+// then present optionals, then "Add" rows for absent optionals)
228234 // ---------------------------------------------------------------------------
229235
230236 function ObjectFields(props: {
@@ -242,11 +248,15 @@ function ObjectFields(props: { · 11 unmodified lines
242248 const requiredSet = new Set(schema.required ?? []);
243249 const entries = Object.entries(properties);
244250
245− const present = entries.filter(
246− ([key]: [string, JsonSchema]) => key in value || requiredSet.has(key),
247− );
251+ const present = entries.filter(([key, propSchema]: [string, JsonSchema]) => {
252+ const resolved = deepResolve(propSchema, root);
253+ return key in value || requiredSet.has(key) || resolved.default !== undefined;
254+ });
248255 const addable = entries.filter(
249− ([key]: [string, JsonSchema]) => !requiredSet.has(key) && !(key in value),
256+ ([key, propSchema]: [string, JsonSchema]) =>
257+ !requiredSet.has(key) &&
258+ !(key in value) &&
259+ deepResolve(propSchema, root).default === undefined,
250260 );
251261
252262 // No properties → render nothing (no "takes no arguments" noise); a zero-arg
@@ -316,6 +326,7 @@ function JsonSchemaField(props: { · 63 unmodified lines
316326 const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props;
317327 const schema = deepResolve(props.schema, root);
318328 const description = schemaDescription(schema);
329+ const fieldValue = value === undefined && schema.default !== undefined ? schema.default : value;
319330
320331 const header = (
321332 <div className="flex items-center justify-between gap-2">
@@ -353,7 +364,7 @@ function JsonSchemaField(props: { · 31 unmodified lines
353364 schema={schema}
354365 root={root}
355366 fieldId={fieldId}
356− value={value}
367+ value={fieldValue}
357368 onChange={onChange}
358369 depth={depth}
359370 disabled={disabled}
@@ -407,6 +418,18 @@ function FieldControl(props: { · 47 unmodified lines
407418 );
408419 }
409420
421+ if (Array.isArray(schema.enum) && schema.enum.length === 0) {
422+ const guidance = emptyEnumGuidance(schemaDescription(schema));
423+ return (
424+ <div className="space-y-1">
425+ <NativeSelect id={fieldId} className="w-full text-xs" value="" disabled={true}>
426+ <NativeSelectOption value="">{guidance}</NativeSelectOption>
427+ </NativeSelect>
428+ <p className="text-[11px] leading-4 text-muted-foreground">{guidance}</p>
429+ </div>
430+ );
431+ }
432+
410433 // enum — native select. Optional/unset gets a leading empty option.
411434 if (Array.isArray(schema.enum) && schema.enum.length > 0) {
412435 const options = schema.enum;
packages/react/src/components/tool-run-panel.test.ts+18−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 type Owner,
1010 } from "@executor-js/sdk/shared";
1111
12−import { toolProxyAddress } from "./tool-run-panel";
12+import { shouldRenderConnectionPicker, toolProxyAddress } from "./tool-run-panel";
1313
1414 const connection = (input: { readonly owner: Owner; readonly name: string }): Connection => ({
1515 owner: input.owner,
@@ -22,6 +22,23 @@ const connection = (input: { readonly owner: Owner; readonly name: string }): Co · 6 unmodified lines
2222 expiresAt: null,
2323 });
2424
25+describe("shouldRenderConnectionPicker", () => {
26+ it("hides the picker when there is only one connection", () => {
27+ expect(shouldRenderConnectionPicker([connection({ owner: "org", name: "published" })])).toBe(
28+ false,
29+ );
30+ });
31+
32+ it("keeps the picker when there are multiple connections", () => {
33+ expect(
34+ shouldRenderConnectionPicker([
35+ connection({ owner: "org", name: "prod" }),
36+ connection({ owner: "user", name: "scratch" }),
37+ ]),
38+ ).toBe(true);
39+ });
40+});
41+
2542 describe("toolProxyAddress", () => {
2643 it("builds the address under the SELECTED connection's own owner — user", () => {
2744 expect(
packages/react/src/components/tool-run-panel.tsx+27−22
@@ -54,6 +54,9 @@ export const toolProxyAddress = (input: { · 53 unmodified lines
5454 }): string =>
5555 `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`;
5656
57+export const shouldRenderConnectionPicker = (connections: readonly Connection[]): boolean =>
58+ connections.length > 1;
59+
5760 type RunResult =
5861 | {
5962 readonly kind: "completed";
@@ -261,33 +264,35 @@ export function ToolRunPanel(props: { · 201 unmodified lines
261264 if (connections.length === 0) {
262265 return <p className="text-sm text-muted-foreground">Add a connection to test tools.</p>;
263266 }
267+ const renderConnectionPicker = shouldRenderConnectionPicker(connections);
264268
265269 return (
266270 <div className="space-y-4">
267271 <CardStack>
268272 <CardStackContent className="space-y-4 px-4 py-4">
269− {/* Connection picker — which account to run against. */}
270− <div className="space-y-1.5">
271− <Label htmlFor="tool-run-connection">Connection</Label>
272− <NativeSelect
273− id="tool-run-connection"
274− className="w-full"
275− value={selectedConnection ?? ""}
276− onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
277− setSelectedConnection(e.target.value as ConnectionName)
278− }
279− disabled={running}
280− >
281− {connections.map((connection: Connection) => (
282− <NativeSelectOption key={connection.name} value={connection.name}>
283− {connection.identityLabel || connection.name}
284− </NativeSelectOption>
285− ))}
286− </NativeSelect>
287− <p className="text-xs text-muted-foreground">
288− Pick the account whose credentials you want to verify.
289− </p>
290− </div>
273+ {renderConnectionPicker && (
274+ <div className="space-y-1.5">
275+ <Label htmlFor="tool-run-connection">Connection</Label>
276+ <NativeSelect
277+ id="tool-run-connection"
278+ className="w-full"
279+ value={selectedConnection ?? ""}
280+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
281+ setSelectedConnection(e.target.value as ConnectionName)
282+ }
283+ disabled={running}
284+ >
285+ {connections.map((connection: Connection) => (
286+ <NativeSelectOption key={connection.name} value={connection.name}>
287+ {connection.identityLabel || connection.name}
288+ </NativeSelectOption>
289+ ))}
290+ </NativeSelect>
291+ <p className="text-xs text-muted-foreground">
292+ Pick the account whose credentials you want to verify.
293+ </p>
294+ </div>
295+ )}
291296
292297 {/* Arguments editor — dynamic form built from the input JSON Schema,
293298 with a Raw JSON escape hatch. Both views share the `argsJson`
packages/react/src/lib/integration-detail-tabs.ts+16−0
@@ -0,0 +1,16 @@
1+export type IntegrationDetailSearchTab = "accounts" | "source" | "tools";
2+export type IntegrationDetailInternalTab = "accounts" | "tools";
3+
4+export const integrationDetailTabForAddCompletion = (
5+ pluginKey: string,
6+): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined);
7+
8+export const integrationDetailInternalTabFromSearch = (
9+ tab: IntegrationDetailSearchTab | undefined,
10+): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts");
11+
12+export const integrationDetailSearchTabForInternal = (
13+ integrationKind: string | undefined,
14+ tab: IntegrationDetailInternalTab,
15+): IntegrationDetailSearchTab =>
16+ tab === "tools" ? "tools" : integrationKind === "apps" ? "source" : "accounts";
packages/react/src/pages/integration-add.tsx+11−1
@@ -1,8 +1,11 @@
11 import { Suspense } from "react";
2+import { useAtomRefresh } from "@effect/atom-react";
23 import { Link, useNavigate } from "@tanstack/react-router";
34 import { useIntegrationPlugins } from "@executor-js/sdk/client";
5+import { integrationsOptimisticAtom } from "../api/atoms";
46 import { trackEvent } from "../api/analytics";
57 import { useExecutorDocumentTitle } from "../lib/document-title";
8+import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs";
69
710 // ---------------------------------------------------------------------------
811 // Page
@@ -18,6 +21,7 @@ export function AddIntegrationPage(props: { · 9 unmodified lines
1821 const { pluginKey, url, preset, namespace } = props;
1922 const navigate = useNavigate();
2023 const integrationPlugins = useIntegrationPlugins();
24+ const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
2125
2226 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
2327
@@ -59,9 +63,15 @@ export function AddIntegrationPage(props: { · 35 unmodified lines
5963 plugin_key: pluginKey,
6064 ...(slug ? { integration_slug: slug } : {}),
6165 });
66+ refreshIntegrations();
67+ const tab = integrationDetailTabForAddCompletion(pluginKey);
6268 void navigate(
6369 slug
64− ? { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: slug } }
70+ ? {
71+ to: "/{-$orgSlug}/integrations/$namespace",
72+ params: { namespace: slug },
73+ search: tab ? { tab } : {},
74+ }
6575 : { to: "/{-$orgSlug}" },
6676 );
6777 }}
packages/react/src/pages/integration-detail.tsx+41−5
@@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines
3636 import { useExecutorDocumentTitle } from "../lib/document-title";
3737 import { ErrorState } from "../components/error-state";
3838 import { isAsyncResultLoading } from "../lib/async-result";
39+import {
40+ integrationDetailInternalTabFromSearch,
41+ integrationDetailSearchTabForInternal,
42+ type IntegrationDetailInternalTab,
43+ type IntegrationDetailSearchTab,
44+} from "../lib/integration-detail-tabs";
3945
4046 // v2: the route's `namespace` param is the integration slug. Tools belong to
4147 // the integration's per-owner connections; a tool's policy id is
@@ -52,7 +58,10 @@ type ToolRow = { · 10 unmodified lines
5258 readonly static?: boolean;
5359 };
5460
55−export function IntegrationDetailPage(props: { namespace: string }) {
61+export function IntegrationDetailPage(props: {
62+ namespace: string;
63+ tab?: IntegrationDetailSearchTab;
64+}) {
5665 const { namespace } = props;
5766 const slug = IntegrationSlug.make(namespace);
5867 const integrationPlugins = useIntegrationPlugins();
@@ -89,7 +98,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 30 unmodified lines
8998 const [deleting, setDeleting] = useState(false);
9099 const [refreshing, setRefreshing] = useState(false);
91100 const [editSheetOpen, setEditSheetOpen] = useState(false);
92− const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");
101+ const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() =>
102+ integrationDetailInternalTabFromSearch(props.tab),
103+ );
93104 const [manualAccountHandoff, setManualAccountHandoff] =
94105 useState<IntegrationAccountHandoff | null>(null);
95106 const [locationSearch] = useState(() =>
@@ -101,9 +112,14 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines
101112 setEditSheetOpen(false);
102113 }, [namespace]);
103114
115+ useEffect(() => {
116+ setActiveTab(integrationDetailInternalTabFromSearch(props.tab));
117+ }, [namespace, props.tab]);
118+
104119 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
105120 useExecutorDocumentTitle(integrationData?.name || namespace);
106121 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
122+ const isAppsIntegration = integrationData?.kind === "apps";
107123 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
108124 const canRefresh = integrationData?.canRefresh ?? false;
109125 const canRemove = integrationData?.canRemove ?? false;
@@ -153,6 +169,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines
153169 // Find the plugin edit component based on integration kind
154170 const editPlugin = useMemo(() => {
155171 if (!integrationData) return null;
172+ if (integrationData.kind === "apps") {
173+ return integrationPlugins.find((p) => p.key === "apps") ?? null;
174+ }
156175 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
157176 }, [integrationData, integrationPlugins]);
158177
@@ -347,6 +366,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 188 unmodified lines
347366 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });
348367 };
349368
369+ const handleTabChange = (value: string) => {
370+ const nextTab = value === "tools" ? "tools" : "accounts";
371+ setActiveTab(nextTab);
372+ void navigate({
373+ to: "/{-$orgSlug}/integrations/$namespace",
374+ params: { namespace },
375+ search: {
376+ tab: integrationDetailSearchTabForInternal(integrationData?.kind, nextTab),
377+ },
378+ });
379+ };
380+
350381 return (
351382 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
352383 {/* Header bar */}
@@ -363,7 +394,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 10 unmodified lines
363394 </div>
364395
365396 <div className="flex shrink-0 items-center gap-2">
366− {!confirmDelete && !isBuiltInIntegration && integrationData && (
397+ {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && (
367398 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
368399 Edit
369400 </Button>
@@ -381,6 +412,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines
381412 )}
382413
383414 {canRemove &&
415+ !isAppsIntegration &&
384416 (confirmDelete ? (
385417 <div className="flex items-center gap-2">
386418 <Button
@@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines
415447
416448 <Tabs
417449 value={currentTab}
418− onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}
450+ onValueChange={handleTabChange}
419451 className="min-h-0 flex-1 gap-0 overflow-hidden"
420452 >
421453 <div className="shrink-0 border-b border-border/60 px-4 py-2">
422454 <TabsList variant="line">
423− {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}
455+ {!isBuiltInIntegration && (
456+ <TabsTrigger value="accounts">
457+ {isAppsIntegration ? "Source" : "Accounts"}
458+ </TabsTrigger>
459+ )}
424460 <TabsTrigger value="tools">Tools</TabsTrigger>
425461 </TabsList>
426462 </div>
packages/react/src/pages/integrations.tsx+52−0
@@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines
4949 import { isAsyncResultLoading } from "../lib/async-result";
5050
5151 const KIND_TO_PLUGIN_KEY: Record<string, string> = {
52+ apps: "apps",
5253 openapi: "openapi",
5354 mcp: "mcp",
5455 graphql: "graphql",
@@ -66,6 +67,41 @@ const bestDetection = ( · 11 unmodified lines
6667 ): IntegrationDetectionResult | undefined =>
6768 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
6869
70+// Hosts where a two-segment path (owner/repo) is unambiguously a git remote.
71+const GIT_REPO_HOSTS = new Set(["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"]);
72+
73+// Deliberately conservative: only claim URLs that are unambiguously git
74+// remotes (a `.git` suffix, or owner/repo on a known git host). Anything else
75+// falls through to the server-side detection so OpenAPI/MCP/GraphQL URLs keep
76+// their existing flows.
77+const detectGitRepository = (
78+ raw: string,
79+): { readonly endpoint: string; readonly slug: string } | null => {
80+ const value = raw.trim();
81+ if (!URL.canParse(value)) return null;
82+ const parsed = new URL(value);
83+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
84+ if (parsed.username || parsed.password) return null;
85+ const segments = parsed.pathname.split("/").filter(Boolean);
86+ const knownGitHost =
87+ GIT_REPO_HOSTS.has(parsed.hostname.toLowerCase()) &&
88+ segments.length === 2 &&
89+ parsed.search === "";
90+ if (!parsed.pathname.endsWith(".git") && !knownGitHost) return null;
91+ const name = segments.at(-1)?.replace(/\.git$/, "") ?? "";
92+ if (!name) return null;
93+ parsed.hash = "";
94+ return {
95+ endpoint: parsed.toString(),
96+ slug: name
97+ .trim()
98+ .toLowerCase()
99+ .replace(/[^a-z0-9-]+/g, "-")
100+ .replace(/^-+|-+$/g, "")
101+ .slice(0, 80),
102+ };
103+};
104+
69105 // ---------------------------------------------------------------------------
70106 // Page
71107 // ---------------------------------------------------------------------------
@@ -176,6 +212,22 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 104 unmodified lines
176212 if (!trimmed) return;
177213 setDetecting(true);
178214 setError(null);
215+ const gitRepository = detectGitRepository(trimmed);
216+ if (gitRepository && integrationPlugins.some((p) => p.key === "apps")) {
217+ trackEvent("integration_detect_submitted", {
218+ success: true,
219+ detected_kind: "apps",
220+ confidence: "high",
221+ });
222+ trackEvent("integration_add_started", { plugin_key: "apps", via: "detect" });
223+ closeAndReset();
224+ void navigate({
225+ to: "/{-$orgSlug}/integrations/add/$pluginKey",
226+ params: { pluginKey: "apps" },
227+ search: { url: gitRepository.endpoint, namespace: gitRepository.slug },
228+ });
229+ return;
230+ }
179231 // Detection is read-only — it inspects a URL and returns candidates without
180232 // mutating the catalog, so it invalidates nothing.
181233 const exit = await doDetect({
packages/react/src/routes/integrations.$namespace.tsx+16−1
@@ -1,10 +1,25 @@
1+import { Schema } from "effect";
12 import { createFileRoute } from "@tanstack/react-router";
23
34 import { IntegrationDetailPage } from "../pages/integration-detail";
5+import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
6+
7+const SearchParams = Schema.toStandardSchemaV1(
8+ Schema.Struct({
9+ tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
10+ }),
11+);
412
513 export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
14+ validateSearch: SearchParams,
615 component: () => {
716 const { namespace } = Route.useParams();
8− return <IntegrationDetailPage namespace={namespace} />;
17+ const { tab } = Route.useSearch();
18+ return (
19+ <IntegrationDetailPage
20+ namespace={namespace}
21+ tab={tab as IntegrationDetailSearchTab | undefined}
22+ />
23+ );
924 },
1025 });