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
37 },37 },
38 "devDependencies": {38 "devDependencies": {
39 "@effect/vitest": "catalog:",39 "@effect/vitest": "catalog:",
40 "@types/node": "catalog:",
40 "bun-types": "catalog:",41 "bun-types": "catalog:",
41 "typescript": "catalog:",42 "typescript": "catalog:",
42 "vitest": "catalog:"43 "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 */
2import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join, resolve } from "node:path";
5import { spawn, type Subprocess } from "bun";
6
7const binary = process.argv[2];
8if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts <packed-binary>");
9
10const AUTH_TOKEN = "packed-apps-smoke-token";
11const READY_TIMEOUT_MS = 45_000;
12
13const fail = (message: string): never => {
14 throw new Error(`[smoke-packed-apps] FAIL: ${message}`);
15};
16
17const 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
31const 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
76const 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
192await 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
2import { existsSync } from "node:fs";2import { existsSync } from "node:fs";
3import { createRequire } from "node:module";3import { createRequire } from "node:module";
4import { resolve, join, dirname } from "node:path";4import { resolve, join, dirname } from "node:path";
5import { fileURLToPath } from "node:url";5import { fileURLToPath, pathToFileURL } from "node:url";
6import { parseArgs } from "node:util";6import { parseArgs } from "node:util";
7import { $ } from "bun";7import { $ } from "bun";
8 8
@@ -11,6 +11,8 @@ const cliRoot = resolve(repoRoot, "apps/cli"); · 2 unmodified lines
11const webRoot = resolve(repoRoot, "apps/local");11const webRoot = resolve(repoRoot, "apps/local");
12const distDir = resolve(cliRoot, "dist");12const distDir = resolve(cliRoot, "dist");
13const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";13const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";
14const WORKERD_VERSION = "1.20260708.1";
15const WORKER_BUNDLER_DIRNAME = "worker-bundler";
14 16
15const resolveQuickJsWasmPath = (): string => {17const resolveQuickJsWasmPath = (): string => {
16 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));18 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
@@ -33,6 +35,34 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 16 unmodified lines
33 return wasmPath;35 return wasmPath;
34};36};
35 37
38const 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
48const 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
36// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
37// Metadata67// Metadata
38// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
@@ -119,6 +149,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines
119};149};
120 150
121const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");151const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");
152const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd");
122 153
123const isCurrentPlatform = (t: Target) =>154const isCurrentPlatform = (t: Target) =>
124 t.os === process.platform && t.arch === process.arch && !t.abi;155 t.os === process.platform && t.arch === process.arch && !t.abi;
@@ -211,6 +242,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines
211 }242 }
212};243};
213 244
245const 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
214// ---------------------------------------------------------------------------280// ---------------------------------------------------------------------------
215// Build mode281// Build mode
216// ---------------------------------------------------------------------------282// ---------------------------------------------------------------------------
@@ -333,6 +399,8 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 116 unmodified lines
333 399
334 const quickJsWasmPath = resolveQuickJsWasmPath();400 const quickJsWasmPath = resolveQuickJsWasmPath();
335 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();401 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
402 const workerBundlerDistPath = resolveWorkerBundlerDistPath();
403 const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
336 404
337 try {405 try {
338 for (const target of targets) {406 for (const target of targets) {
@@ -375,12 +443,35 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 36 unmodified lines
375 await cp(libsqlNative, join(binDir, "libsql.node"));443 await cp(libsqlNative, join(binDir, "libsql.node"));
376 }444 }
377 445
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
378 // Smoke test on current platform460 // Smoke test on current platform
379 if (isCurrentPlatform(target)) {461 if (isCurrentPlatform(target)) {
380 const bin = join(binDir, binaryName(target));462 const bin = join(binDir, binaryName(target));
381 console.log(` Smoke test: ${bin} --version`);463 console.log(` Smoke test: ${bin} --version`);
382 const version = await $`${bin} --version`.text();464 const version = await $`${bin} --version`.text();
383 console.log(` OK: ${version.trim()}`);465 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");
384 }475 }
385 476
386 // Variant package.json. All variants publish to the SAME npm package477 // Variant package.json. All variants publish to the SAME npm package
apps/cli/src/native-bindings.ts+19−5
@@ -1,11 +1,10 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Native-binding bootstrap for the `bun build --compile` binary.2// Native-binding bootstrap for the `bun build --compile` binary.
3//3//
4// `bun --compile` bundles JS into bunfs but does NOT include `.node` native4// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside5// wasm sidecars, or runtime-read package files. build.ts copies those artifacts
6// the binary fails. build.ts copies each platform's `.node` next to the6// next to the executable; here we publish their on-disk paths via env vars the
7// executable (`libsql.node`, `keyring.node`); here we publish their on-disk7// loaders read.
8// paths via env vars the loaders read.
9//8//
10// This MUST be the FIRST import in main.ts. ES modules evaluate every import9// This MUST be the FIRST import in main.ts. ES modules evaluate every import
11// before the importer's own body, and libSQL resolves its native addon EAGERLY10// before the importer's own body, and libSQL resolves its native addon EAGERLY
@@ -43,3 +42,18 @@ if ( · 31 unmodified lines
43) {42) {
44 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;43 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
45}44}
45
46const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
47if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
48 process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
49}
50
51const workerBundlerOnDisk = join(execDir, "worker-bundler");
52if (
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
8 "skipLibCheck": true,8 "skipLibCheck": true,
9 "noEmit": true,9 "noEmit": true,
10 "declaration": true,10 "declaration": true,
11 "types": ["bun-types"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": ["src", "bin"]13 "include": ["src", "bin"]
14}14}
apps/cloud/executor.config.ts+2−0
@@ -75,6 +75,7 @@ export default defineExecutorConfig({ · 74 unmodified lines
75 workosCredentials,75 workosCredentials,
76 workosVaultClient,76 workosVaultClient,
77 activeToolkitSlug,77 activeToolkitSlug,
78 allowLocalNetwork,
78 workerLoader,79 workerLoader,
79 }: CloudPluginDeps = {}) =>80 }: CloudPluginDeps = {}) =>
80 [81 [
@@ -94,6 +95,7 @@ export default defineExecutorConfig({ · 13 unmodified lines
94 }95 }
95 : {}),96 : {}),
96 sourceKinds: ["git"],97 sourceKinds: ["git"],
98 allowPrivateGitHosts: allowLocalNetwork === true,
97 }),99 }),
98 toolkitsPlugin({ activeToolkitSlug }),100 toolkitsPlugin({ activeToolkitSlug }),
99 workosVaultPlugin({101 workosVaultPlugin({
apps/desktop/src/sidecar/native-bindings.ts+18−5
@@ -1,11 +1,9 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Native-binding bootstrap for manually compiled sidecar binaries.2// Native-binding bootstrap for manually compiled sidecar binaries.
3//3//
4// `bun --compile` bundles JS into bunfs but does NOT include `.node` native4// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside5// wasm sidecars, or runtime-read package files. If this dev sidecar is compiled
6// the binary fails. If this dev sidecar is compiled directly, copy each6// directly, copy those artifacts next to the executable; here we publish their
7// platform's `.node` next to the executable (`libsql.node`, `keyring.node`);
8// here we publish their
9// on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts.7// on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts.
10//8//
11// This MUST be the FIRST import in server.ts. ES modules evaluate every import9// This MUST be the FIRST import in server.ts. ES modules evaluate every import
@@ -44,3 +42,18 @@ if ( · 32 unmodified lines
44) {42) {
45 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;43 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
46}44}
45
46const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
47if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
48 process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
49}
50
51const workerBundlerOnDisk = join(execDir, "worker-bundler");
52if (
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
32 32
33interface SelfHostPluginDeps {33interface SelfHostPluginDeps {
34 readonly activeToolkitSlug?: string;34 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;
35}39}
36 40
37export default defineExecutorConfig({41export default defineExecutorConfig({
38 plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>42 plugins: ({ activeToolkitSlug, sourceKinds }: SelfHostPluginDeps = {}) =>
39 [43 [
40 openApiHttpPlugin({44 openApiHttpPlugin({
41 presets: [...googleCatalog, ...microsoftCatalog],45 presets: [...googleCatalog, ...microsoftCatalog],
@@ -46,7 +50,7 @@ export default defineExecutorConfig({ · 4 unmodified lines
46 appsHttpPlugin({50 appsHttpPlugin({
47 executor: makeWorkerdAppToolExecutor(),51 executor: makeWorkerdAppToolExecutor(),
48 bundler: makeWorkerBundlerBackend(),52 bundler: makeWorkerBundlerBackend(),
49 sourceKinds: ["git", "local-directory"],53 sourceKinds: sourceKinds ?? ["git"],
50 allowPrivateGitHosts: true,54 allowPrivateGitHosts: true,
51 }),55 }),
52 toolkitsPlugin({ activeToolkitSlug }),56 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
98 expect(body.isError).toBe(false);98 expect(body.isError).toBe(false);
99});99});
100 100
101test("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
101test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => {120test("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 });
102 const root = mkdtempSync(join(tmpdir(), "eh-app-src-"));131 const root = mkdtempSync(join(tmpdir(), "eh-app-src-"));
103 mkdirSync(join(root, "tools"), { recursive: true });132 mkdirSync(join(root, "tools"), { recursive: true });
104 writeFileSync(133 writeFileSync(
@@ -121,51 +150,56 @@ test("apps source sync publishes and invokes a local-directory tool over HTTP", · 16 unmodified lines
121 );150 );
122 writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" }));151 writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" }));
123 152
124 const created = await handler(153 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test fixture must dispose the booted app after assertions
125 new Request("http://localhost/api/apps/sources", {154 try {
126 method: "POST",155 const created = await localDirectoryApp.handler(
127 headers: { "content-type": "application/json" },156 new Request("http://localhost/api/apps/sources", {
128 body: JSON.stringify({157 method: "POST",
129 kind: "local-directory",158 headers: { "content-type": "application/json" },
130 slug: "local-greeter",159 body: JSON.stringify({
131 app: "local-greeter",160 kind: "local-directory",
132 path: root,161 slug: "local-greeter",
162 app: "local-greeter",
163 path: root,
164 }),
133 }),165 }),
134 }),166 );
135 );167 expect(created.status).toBe(200);
136 expect(created.status).toBe(200);
137 168
138 const synced = await handler(169 const synced = await localDirectoryApp.handler(
139 new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }),170 new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }),
140 );171 );
141 expect(synced.status).toBe(200);172 expect(synced.status).toBe(200);
142 const syncBody = (await synced.json()) as {173 const syncBody = (await synced.json()) as {
143 readonly status: string;174 readonly status: string;
144 readonly tools: readonly string[];175 readonly tools: readonly string[];
145 readonly errors?: readonly unknown[];176 readonly errors?: readonly unknown[];
146 };177 };
147 expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published");178 expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published");
148 expect(syncBody.tools).toEqual(["greeter"]);179 expect(syncBody.tools).toEqual(["greeter"]);
149 180
150 const listed = await handler(new Request("http://localhost/api/tools"));181 const listed = await localDirectoryApp.handler(new Request("http://localhost/api/tools"));
151 expect(listed.status).toBe(200);182 expect(listed.status).toBe(200);
152 const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>;183 const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>;
153 expect(184 expect(
154 tools.some((tool) => tool.address === "tools.apps.org.published.greeter"),185 tools.some((tool) => tool.address === "tools.local-greeter.org.published.greeter"),
155 JSON.stringify(tools.map((tool) => tool.address)),186 JSON.stringify(tools.map((tool) => tool.address)),
156 ).toBe(true);187 ).toBe(true);
157 188
158 const invoked = await handler(189 const invoked = await localDirectoryApp.handler(
159 new Request("http://localhost/api/executions", {190 new Request("http://localhost/api/executions", {
160 method: "POST",191 method: "POST",
161 headers: { "content-type": "application/json" },192 headers: { "content-type": "application/json" },
162 body: JSON.stringify({193 body: JSON.stringify({
163 code: 'export default await tools.apps.org.published.greeter({ name: "Ada" })',194 code: 'export default await tools["local-greeter.org.published.greeter"]({ name: "Ada" })',
195 }),
164 }),196 }),
165 }),197 );
166 );198 expect(invoked.status).toBe(200);
167 expect(invoked.status).toBe(200);199 const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean };
168 const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean };200 expect(output.isError).toBe(false);
169 expect(output.isError).toBe(false);201 expect(output.text).toContain("hello Ada");
170 expect(output.text).toContain("hello Ada");202 } finally {
203 await localDirectoryApp.dispose();
204 }
171});205});
apps/host-selfhost/src/testing/test-app.ts+20−1
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5 composePluginApi,5 composePluginApi,
6 ExecutorApp,6 ExecutorApp,
7 IdentityProvider,7 IdentityProvider,
8 PluginsProvider,
8 type Principal,9 type Principal,
9 textFailureStrategy,10 textFailureStrategy,
10 Unauthorized,11 Unauthorized,
@@ -22,6 +23,7 @@ import { · 11 unmodified lines
22 SelfHostHostConfig,23 SelfHostHostConfig,
23 SelfHostPluginsProvider,24 SelfHostPluginsProvider,
24} from "../execution";25} from "../execution";
26import executorConfig from "../../executor.config";
25import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";27import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
26import {28import {
27 makeSelfHostMcpSessionStore,29 makeSelfHostMcpSessionStore,
@@ -165,6 +167,8 @@ export interface MakeSelfHostTestAppOptions { · 137 unmodified lines
165 readonly identity: Layer.Layer<IdentityProvider>;167 readonly identity: Layer.Layer<IdentityProvider>;
166 /** Override the SQLite path (defaults to the config data dir). */168 /** Override the SQLite path (defaults to the config data dir). */
167 readonly dbPath?: string;169 readonly dbPath?: string;
170 /** Test-only override for executor.config.ts plugin dependencies. */
171 readonly pluginDeps?: Parameters<typeof executorConfig.plugins>[0];
168}172}
169 173
170export interface SelfHostTestHandler {174export interface SelfHostTestHandler {
@@ -185,6 +189,21 @@ export const makeSelfHostTestApp = async ( · 14 unmodified lines
185 });189 });
186 190
187 const sessionStore = makeSelfHostMcpSessionStore(dbHandle);191 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;
188 207
189 const { toWebHandler } = ExecutorApp.make({208 const { toWebHandler } = ExecutorApp.make({
190 plugins: selfHostPlugins,209 plugins: selfHostPlugins,
@@ -197,7 +216,7 @@ export const makeSelfHostTestApp = async ( · 6 unmodified lines
197 sessions: selfHostMcpSessions(sessionStore),216 sessions: selfHostMcpSessions(sessionStore),
198 reporter: selfHostMcpReporter,217 reporter: selfHostMcpReporter,
199 },218 },
200 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },219 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
201 errorCapture: ErrorCaptureLive,220 errorCapture: ErrorCaptureLive,
202 },221 },
203 extensions: {222 extensions: {
apps/local/executor.config.ts+11−0
@@ -1,4 +1,9 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import {
3 makeWorkerBundlerBackend,
4 makeWorkerdAppToolExecutor,
5} from "@executor-js/plugin-apps/selfhost";
6import { appsHttpPlugin } from "@executor-js/plugin-apps/api";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";7import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import {8import {
4 googleCatalog,9 googleCatalog,
@@ -38,6 +43,12 @@ export default defineExecutorConfig({ · 33 unmodified lines
38 }),43 }),
39 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),44 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
40 graphqlHttpPlugin(),45 graphqlHttpPlugin(),
46 appsHttpPlugin({
47 executor: makeWorkerdAppToolExecutor(),
48 bundler: makeWorkerBundlerBackend(),
49 sourceKinds: ["git", "local-directory"],
50 allowPrivateGitHosts: true,
51 }),
41 toolkitsPlugin({ activeToolkitSlug }),52 toolkitsPlugin({ activeToolkitSlug }),
42 keychainPlugin(),53 keychainPlugin(),
43 fileSecretsPlugin(),54 fileSecretsPlugin(),
apps/local/package.json+1−0
@@ -29,6 +29,7 @@ · 28 unmodified lines
29 "@executor-js/fumadb": "workspace:*",29 "@executor-js/fumadb": "workspace:*",
30 "@executor-js/host-mcp": "workspace:*",30 "@executor-js/host-mcp": "workspace:*",
31 "@executor-js/integrations-registry": "workspace:*",31 "@executor-js/integrations-registry": "workspace:*",
32 "@executor-js/plugin-apps": "workspace:*",
32 "@executor-js/plugin-desktop-settings": "workspace:*",33 "@executor-js/plugin-desktop-settings": "workspace:*",
33 "@executor-js/plugin-example": "workspace:*",34 "@executor-js/plugin-example": "workspace:*",
34 "@executor-js/plugin-file-secrets": "workspace:*",35 "@executor-js/plugin-file-secrets": "workspace:*",
bun.lock+16−0
@@ -50,6 +50,7 @@ · 49 unmodified lines
50 },50 },
51 "devDependencies": {51 "devDependencies": {
52 "@effect/vitest": "catalog:",52 "@effect/vitest": "catalog:",
53 "@types/node": "catalog:",
53 "bun-types": "catalog:",54 "bun-types": "catalog:",
54 "typescript": "catalog:",55 "typescript": "catalog:",
55 "vitest": "catalog:",56 "vitest": "catalog:",
@@ -270,6 +271,7 @@ · 214 unmodified lines
270 "@executor-js/fumadb": "workspace:*",271 "@executor-js/fumadb": "workspace:*",
271 "@executor-js/host-mcp": "workspace:*",272 "@executor-js/host-mcp": "workspace:*",
272 "@executor-js/integrations-registry": "workspace:*",273 "@executor-js/integrations-registry": "workspace:*",
274 "@executor-js/plugin-apps": "workspace:*",
273 "@executor-js/plugin-desktop-settings": "workspace:*",275 "@executor-js/plugin-desktop-settings": "workspace:*",
274 "@executor-js/plugin-example": "workspace:*",276 "@executor-js/plugin-example": "workspace:*",
275 "@executor-js/plugin-file-secrets": "workspace:*",277 "@executor-js/plugin-file-secrets": "workspace:*",
@@ -801,16 +803,30 @@ · 525 unmodified lines
801 "zod": "4.3.6",803 "zod": "4.3.6",
802 },804 },
803 "devDependencies": {805 "devDependencies": {
806 "@effect/atom-react": "catalog:",
804 "@effect/vitest": "catalog:",807 "@effect/vitest": "catalog:",
808 "@executor-js/react": "workspace:*",
805 "@types/node": "catalog:",809 "@types/node": "catalog:",
810 "@types/react": "catalog:",
806 "bun-types": "catalog:",811 "bun-types": "catalog:",
807 "effect": "catalog:",812 "effect": "catalog:",
813 "react": "catalog:",
808 "tsup": "catalog:",814 "tsup": "catalog:",
809 "vitest": "catalog:",815 "vitest": "catalog:",
810 },816 },
811 "peerDependencies": {817 "peerDependencies": {
818 "@effect/atom-react": "catalog:",
819 "@executor-js/react": "workspace:*",
820 "@tanstack/react-router": "catalog:",
812 "effect": "catalog:",821 "effect": "catalog:",
822 "react": "catalog:",
813 },823 },
824 "optionalPeers": [
825 "@effect/atom-react",
826 "@executor-js/react",
827 "@tanstack/react-router",
828 "react",
829 ],
814 },830 },
815 "packages/plugins/desktop-settings": {831 "packages/plugins/desktop-settings": {
816 "name": "@executor-js/plugin-desktop-settings",832 "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.
7import { execFile } from "node:child_process";
8import { promisify } from "node:util";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12
13import { scenario } from "../src/scenario";
14import { Target } from "../src/services";
15import type { Identity, Target as TargetShape } from "../src/target";
16
17const execFileAsync = promisify(execFile);
18
19const 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
32type GuestOs = "macos" | "linux" | "windows";
33
34interface SyncResponse {
35 readonly status: "published" | "up-to-date" | "failed";
36 readonly tools: readonly string[];
37}
38
39interface ExecuteResponse {
40 readonly structured: unknown;
41 readonly text: string;
42}
43
44const 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
50const 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
66const 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
83const fixturePath = (slug: string): string =>
84 guestOs() === "windows"
85 ? `C:/Users/Administrator/apps-packed-fixture-${slug}`
86 : `/tmp/apps-packed-fixture-${slug}`;
87
88const windowsFixtureCommand = (path: string): string => `
89$ErrorActionPreference = 'Stop'
90Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue
91New-Item -ItemType Directory -Force -Path '${path}/tools' | Out-Null
92Set-Content -NoNewline -Encoding UTF8 -Path '${path}/tools/greet.ts' -Value @'
93${"import"} { z } from "zod";
94${"import"} { defineTool } from "executor:app";
95
96const Input = z.object({ name: z.string() });
97
98export default defineTool({
99 description: "Greet",
100 input: Input,
101 async handler(input) {
102 return { greeting: "hello " + input.name };
103 },
104});
105'@
106Set-Content -NoNewline -Encoding UTF8 -Path '${path}/package.json' -Value '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}'
107Set-Content -NoNewline -Encoding UTF8 -Path '${path}/bun.lock' -Value ''
108`;
109
110const unixFixtureCommand = (path: string): string => `
111set -eu
112rm -rf '${path}'
113mkdir -p '${path}/tools'
114cat >'${path}/tools/greet.ts' <<'EOF'
115${"import"} { z } from "zod";
116${"import"} { defineTool } from "executor:app";
117
118const Input = z.object({ name: z.string() });
119
120export default defineTool({
121 description: "Greet",
122 input: Input,
123 async handler(input) {
124 return { greeting: "hello " + input.name };
125 },
126});
127EOF
128printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json'
129: >'${path}/bun.lock'
130`;
131
132const 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
142const 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
151const 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
180const 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
199const 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
206const 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
245scenario(
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.
5import { execFile } from "node:child_process";
6import { promisify } from "node:util";
7
8import { expect, it } from "@effect/vitest";
9import { Effect } from "effect";
10
11import { scenario } from "../src/scenario";
12
13const execFileAsync = promisify(execFile);
14
15const 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
28type DesktopOs = "macos" | "linux" | "windows";
29
30interface CurlResult<T> {
31 readonly status: number;
32 readonly body: T;
33 readonly text: string;
34}
35
36interface SyncResponse {
37 readonly status: "published" | "up-to-date" | "failed";
38 readonly tools: readonly string[];
39}
40
41interface ExecuteResponse {
42 readonly structured: unknown;
43 readonly text: string;
44}
45
46const NAME = "Desktop sidecar · local-directory custom tool publishes and executes";
47const guestIp = process.env.E2E_DESKTOP_VM_IP;
48const os: DesktopOs =
49 process.env.E2E_TARGET === "desktop-windows"
50 ? "windows"
51 : process.env.E2E_TARGET === "desktop-linux"
52 ? "linux"
53 : "macos";
54
55const token = (): string => (os === "linux" ? "desktop-linux-e2e" : "desktop-macos-e2e");
56
57const 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
78const fixturePath = (slug: string): string =>
79 os === "linux"
80 ? `/home/admin/apps-sidecar-fixture-${slug}`
81 : `/Users/admin/apps-sidecar-fixture-${slug}`;
82
83const writeFixtureCommand = (path: string): string => `
84set -eu
85rm -rf '${path}'
86mkdir -p '${path}/tools'
87cat >'${path}/tools/greet.ts' <<'EOF'
88${"import"} { z } from "zod";
89${"import"} { defineTool } from "executor:app";
90
91const Input = z.object({ name: z.string() });
92
93export default defineTool({
94 description: "Greet",
95 input: Input,
96 async handler(input) {
97 return { greeting: "hello " + input.name };
98 },
99});
100EOF
101printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json'
102: >'${path}/bun.lock'
103`;
104
105const 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
113const removeFixture = (path: string): Effect.Effect<void> =>
114 Effect.promise(() => ssh(`rm -rf '${path}'`).then(() => undefined)).pipe(Effect.ignore);
115
116const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
117
118const 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
154const 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
164const deleteSource = (slug: string): Effect.Effect<void> =>
165 Effect.promise(() =>
166 expectGuestRequest(`/api/apps/sources/${slug}`, { method: "DELETE" }).then(() => undefined),
167 ).pipe(Effect.ignore);
168
169const 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
205if (!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 @@
148e7995f99423b64c8adf2aa07186571d9e1f66e
2b8de904b34df6cad1dc29a49986f3703c0fe87f4
3251a747551ac772f029d3f37e2e708e8fed20ee9
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.
4import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5import { homedir } from "node:os";
6import { join } from "node:path";
7
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10
11import { scenario } from "../src/scenario";
12import { Browser, Cli, RunDir, Target } from "../src/services";
13import { withLocalServer } from "./local-server";
14
15scenario(
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 `
39import { z } from "zod";
40import { defineTool } from "executor:app";
41
42export 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 */
2import { readFile } from "node:fs/promises";
3import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4import { join } from "node:path";
5
6import { expect } from "@effect/vitest";
7import { Effect } from "effect";
8
9import { scenario } from "../src/scenario";
10import { Api, Browser, Target } from "../src/services";
11import type { Identity, Target as TargetShape } from "../src/target";
12import type { BrowserSurface } from "../src/surfaces/browser";
13
14const textEncoder = new TextEncoder();
15const FIXTURE_GIT_TOKEN = "fixture-token";
16const EXPECTED_GIT_AUTHORIZATION = `Basic ${btoa(`git:${FIXTURE_GIT_TOKEN}`)}`;
17
18const 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
28const 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
34const FLUSH = textEncoder.encode("0000");
35
36const 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
49const 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
60const 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
72const 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
127interface ToolRow {
128 readonly address: string;
129 readonly name: string;
130 readonly integration: string;
131}
132
133interface ExecuteResponse {
134 readonly status: "completed" | "paused";
135 readonly text: string;
136 readonly structured: unknown;
137 readonly isError?: boolean;
138}
139
140const 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
158const 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
179const 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
197const 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
204const 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
210const 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
254const 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
295const 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
316const 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
340const 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
356scenario(
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
87 await vm.ssh(87 await vm.ssh(
88 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +88 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +
89 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +89 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
90 `--auth-token desktop-linux-e2e ` +
90 `>/tmp/executor-daemon.log 2>&1 &`,91 `>/tmp/executor-daemon.log 2>&1 &`,
91 );92 );
92 if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {93 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
78 await vm.ssh(78 await vm.ssh(
79 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +79 `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +
80 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +80 `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
81 `--auth-token desktop-macos-e2e ` +
81 `>/tmp/executor-daemon.log 2>&1 &`,82 `>/tmp/executor-daemon.log 2>&1 &`,
82 );83 );
83 if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {84 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
362 362
363describe("tool catalog sync safety", () => {363describe("tool catalog sync safety", () => {
364 it.effect(364 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",
366 () =>366 () =>
367 Effect.scoped(367 Effect.scoped(
368 Effect.gen(function* () {368 Effect.gen(function* () {
369 let empty = false;369 let empty = false;
370 const guardedPlugin = definePlugin(() => ({370 const guardedPlugin = definePlugin(() => ({
371 id: "guarded" as const,371 id: "guarded" as const,
372 remoteToolCatalog: true,
372 credentialProviders: [memoryProvider()],373 credentialProviders: [memoryProvider()],
373 storage: () => ({}),374 storage: () => ({}),
374 resolveTools: () =>375 resolveTools: () =>
@@ -424,6 +425,66 @@ describe("tool catalog sync safety", () => { · 49 unmodified lines
424 ),425 ),
425 );426 );
426 427
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
427 it.effect("explicit refresh accepts an authoritative empty catalog", () =>488 it.effect("explicit refresh accepts an authoritative empty catalog", () =>
428 Effect.scoped(489 Effect.scoped(
429 Effect.gen(function* () {490 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
2167 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));2167 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
2168 }2168 }
2169 2169
2170 if (mode === "background" && result.tools.length === 0) {2170 if (
2171 mode === "background" &&
2172 runtime.plugin.remoteToolCatalog === true &&
2173 result.tools.length === 0
2174 ) {
2171 const keptRows = yield* core.findMany("tool", { where });2175 const keptRows = yield* core.findMany("tool", { where });
2172 if (keptRows.length > 0) {2176 if (keptRows.length > 0) {
2173 const reason =2177 const reason =
packages/kernel/runtime-workerd-subprocess/src/index.test.ts+41−1
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { stat } from "node:fs/promises";2import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
3import { join } from "node:path";4import { join } from "node:path";
4import * as Data from "effect/Data";5import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -9,6 +10,7 @@ import { · 3 unmodified lines
9 createWorkerdModuleRunner,10 createWorkerdModuleRunner,
10 isWorkerdAvailable,11 isWorkerdAvailable,
11 makeWorkerdSubprocessExecutor,12 makeWorkerdSubprocessExecutor,
13 workerdBinaryCandidatesForTest,
12 WORKERD_VERSION,14 WORKERD_VERSION,
13} from "./index";15} from "./index";
14 16
@@ -107,6 +109,44 @@ it("exports the pinned workerd version", () => { · 92 unmodified lines
107 expect(WORKERD_VERSION).toBe("1.20260708.1");109 expect(WORKERD_VERSION).toBe("1.20260708.1");
108});110});
109 111
112describe("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
110describe.skipIf(!isWorkerdAvailable())("runtime-workerd-subprocess", () => {150describe.skipIf(!isWorkerdAvailable())("runtime-workerd-subprocess", () => {
111 it("boots and executes a trivial module", async () => {151 it("boots and executes a trivial module", async () => {
112 const runner = makeRunner();152 const runner = makeRunner();
packages/kernel/runtime-workerd-subprocess/src/index.ts+125−30
@@ -1,11 +1,14 @@
1/* 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 */1/* 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 */
2import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";2import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";
3import { existsSync, readFileSync } from "node:fs";
3import { access, mkdir, rm, writeFile } from "node:fs/promises";4import { access, mkdir, rm, writeFile } from "node:fs/promises";
4import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";5import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
6import { createRequire } from "node:module";
5import { createServer as createNetServer, type Server as NetServer } from "node:net";7import { createServer as createNetServer, type Server as NetServer } from "node:net";
6import type { Readable } from "node:stream";8import type { Readable } from "node:stream";
7import { tmpdir } from "node:os";9import { tmpdir } from "node:os";
8import { dirname, join } from "node:path";10import { dirname, join } from "node:path";
11import { rootCertificates } from "node:tls";
9import { fileURLToPath } from "node:url";12import { fileURLToPath } from "node:url";
10 13
11import {14import {
@@ -77,6 +80,7 @@ type PendingRequest = { · 65 unmodified lines
77};80};
78 81
79const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));82const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
83const packageRequire = createRequire(import.meta.url);
80 84
81const normalizeError = (cause: unknown): Error =>85const normalizeError = (cause: unknown): Error =>
82 cause instanceof Error ? cause : new Error(String(cause));86 cause instanceof Error ? cause : new Error(String(cause));
@@ -150,16 +154,86 @@ const reserveLocalPort = (): Promise<{ · 67 unmodified lines
150 });154 });
151 });155 });
152 156
153const findWorkerdBin = async (configured?: string): Promise<string> => {157const workerdBinaryName = (platform: NodeJS.Platform = process.platform): string =>
154 if (configured) return configured;158 platform === "win32" ? "workerd.exe" : "workerd";
159
160const 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
170const 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
183const 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
200export 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};
155 217
156 const candidates = [218const workerdBinaryCandidates = (): readonly string[] => {
157 join(packageDir, "node_modules", "workerd", "bin", "workerd"),219 const roots = [packageDir, join(packageDir, "..", "..", ".."), process.cwd()];
158 join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"),220 const platformPackage = workerdPlatformPackage();
159 join(process.cwd(), "node_modules", "workerd", "bin", "workerd"),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),
160 ];229 ];
230};
231
232const findWorkerdBin = async (configured?: string): Promise<string> => {
233 if (configured) return configured;
234 if (process.env.EXECUTOR_WORKERD_BIN) return process.env.EXECUTOR_WORKERD_BIN;
161 235
162 for (const candidate of candidates) {236 for (const candidate of workerdBinaryCandidates()) {
163 try {237 try {
164 await access(candidate);238 await access(candidate);
165 return candidate;239 return candidate;
@@ -168,36 +242,41 @@ const findWorkerdBin = async (configured?: string): Promise<string> => { · 2 unmodified lines
168 }242 }
169 }243 }
170 244
171 return "workerd";245 if (workerdPlatformPackage() === null) throw new Error("workerd is unavailable on this platform");
172};246 const pathWorkerd = spawnSync("workerd", ["--version"], {
173
174export const isWorkerdAvailable = (workerdBin?: string): boolean => {
175 const configured = workerdBin ?? "workerd";
176 const result = spawnSync(configured, ["--version"], {
177 stdio: "ignore",247 stdio: "ignore",
178 timeout: 5000,248 timeout: 5000,
179 });249 });
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};
181 255
182 const local = spawnSync(256export const isWorkerdAvailable = (workerdBin?: string): boolean => {
183 join(packageDir, "node_modules", "workerd", "bin", "workerd"),257 const configured = workerdBin ?? process.env.EXECUTOR_WORKERD_BIN;
184 ["--version"],258 if (configured) {
185 {259 const result = spawnSync(configured, ["--version"], {
186 stdio: "ignore",260 stdio: "ignore",
187 timeout: 5000,261 timeout: 5000,
188 },262 });
189 );263 return result.error === undefined && result.status === 0;
190 if (local.error === undefined && local.status === 0) return true;264 }
191 265
192 const workspace = spawnSync(266 for (const candidate of workerdBinaryCandidates()) {
193 join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"),267 const result = spawnSync(candidate, ["--version"], {
194 ["--version"],
195 {
196 stdio: "ignore",268 stdio: "ignore",
197 timeout: 5000,269 timeout: 5000,
198 },270 });
199 );271 if (result.error === undefined && result.status === 0) return true;
200 return workspace.error === undefined && workspace.status === 0;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;
201};280};
202 281
203const capnpString = (value: string): string => JSON.stringify(value);282const capnpString = (value: string): string => JSON.stringify(value);
@@ -225,9 +304,19 @@ const buildConfig = (input: { · 21 unmodified lines
225 return `( name = ${capnpString(name)}, ${field} = embed ${capnpString(name)} )`;304 return `( name = ${capnpString(name)}, ${field} = embed ${capnpString(name)} )`;
226 })305 })
227 .join(",\n ");306 .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 ");
228 const services = [312 const services = [
229 '( name = "main", worker = .mainWorker )',313 '( name = "main", worker = .mainWorker )',
230 '( name = "blocked", worker = .blockedWorker )',314 '( name = "blocked", worker = .blockedWorker )',
315 ...(input.globalOutbound === "internet"
316 ? [
317 `( name = "internet", network = ( allow = ["public"], tlsOptions = ( trustedCertificates = [\n ${trustedCertificates}\n ] ) ) )`,
318 ]
319 : []),
231 ...(input.hostPort === null320 ...(input.hostPort === null
232 ? []321 ? []
233 : [`( name = "host", external = ( address = "127.0.0.1:${input.hostPort}", http = () ) )`]),322 : [`( name = "host", external = ( address = "127.0.0.1:${input.hostPort}", http = () ) )`]),
@@ -426,7 +515,10 @@ export const createWorkerdModuleRunner = ( · 192 unmodified lines
426 });515 });
427 proc.stderr.setEncoding("utf8");516 proc.stderr.setEncoding("utf8");
428 proc.stdout.resume();517 proc.stdout.resume();
429 proc.stderr.resume();518 let stderrTail = "";
519 proc.stderr.on("data", (chunk: string) => {
520 stderrTail = (stderrTail + chunk).slice(-4000);
521 });
430 proc.on("exit", (code, signal) => {522 proc.on("exit", (code, signal) => {
431 lastExitAt = Date.now();523 lastExitAt = Date.now();
432 if (processState?.proc === proc) {524 if (processState?.proc === proc) {
@@ -444,7 +536,10 @@ export const createWorkerdModuleRunner = ( · 11 unmodified lines
444 await waitReady(listenPort, token, startupTimeoutMs);536 await waitReady(listenPort, token, startupTimeoutMs);
445 } catch (cause) {537 } catch (cause) {
446 await disposeState(true);538 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}`);
448 }543 }
449 })();544 })();
450 545
packages/plugins/apps/package.json+25−1
@@ -20,6 +20,8 @@ · 19 unmodified lines
20 "./api": "./src/api.ts",20 "./api": "./src/api.ts",
21 "./authoring": "./src/authoring.ts",21 "./authoring": "./src/authoring.ts",
22 "./cloud": "./src/cloud.ts",22 "./cloud": "./src/cloud.ts",
23 "./client": "./src/react/plugin-client.tsx",
24 "./react": "./src/react/index.ts",
23 "./selfhost": "./src/selfhost.ts",25 "./selfhost": "./src/selfhost.ts",
24 "./testing": "./src/testing/index.ts"26 "./testing": "./src/testing/index.ts"
25 },27 },
@@ -39,14 +41,36 @@ · 13 unmodified lines
39 "zod": "4.3.6"41 "zod": "4.3.6"
40 },42 },
41 "devDependencies": {43 "devDependencies": {
44 "@effect/atom-react": "catalog:",
42 "@effect/vitest": "catalog:",45 "@effect/vitest": "catalog:",
46 "@executor-js/react": "workspace:*",
43 "@types/node": "catalog:",47 "@types/node": "catalog:",
48 "@types/react": "catalog:",
44 "bun-types": "catalog:",49 "bun-types": "catalog:",
45 "effect": "catalog:",50 "effect": "catalog:",
51 "react": "catalog:",
46 "tsup": "catalog:",52 "tsup": "catalog:",
47 "vitest": "catalog:"53 "vitest": "catalog:"
48 },54 },
49 "peerDependencies": {55 "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 }
51 }75 }
52}76}
packages/plugins/apps/src/api.ts+4−4
@@ -1,8 +1,8 @@
1import { definePlugin } from "@executor-js/sdk/core";1import { definePlugin } from "@executor-js/sdk/core";
2 2
3import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin";3import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin";
4import { AppsExtensionService, AppsHandlers } from "./plugin/handlers";4import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers";
5import { AppsGroup } from "./plugin/routes";5import { appsGroupForSourceKinds } from "./plugin/routes";
6 6
7export { AppsGroup } from "./plugin/routes";7export { AppsGroup } from "./plugin/routes";
8export { AppsHandlers, AppsExtensionService } from "./plugin/handlers";8export { AppsHandlers, AppsExtensionService } from "./plugin/handlers";
@@ -12,7 +12,7 @@ export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; · 3 unmodified lines
12 12
13export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({13export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({
14 ...makeAppsPlugin(options),14 ...makeAppsPlugin(options),
15 routes: () => AppsGroup,15 routes: () => appsGroupForSourceKinds(options?.sourceKinds),
16 handlers: () => AppsHandlers,16 handlers: () => appsHandlersForSourceKinds(options?.sourceKinds),
17 extensionService: AppsExtensionService,17 extensionService: AppsExtensionService,
18}));18}));
packages/plugins/apps/src/executor/app-tool-executor.ts+40−10
@@ -1,7 +1,6 @@
1/* 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 */1/* 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 */
2import { Buffer } from "node:buffer";2import { Buffer } from "node:buffer";
3import { Data, Effect, Predicate } from "effect";3import { Data, Effect, Predicate } from "effect";
4import { z } from "zod";
5 4
6import { validToolKey } from "../pipeline/discover";5import { validToolKey } from "../pipeline/discover";
7import { stableStringify } from "../pipeline/descriptor";6import { stableStringify } from "../pipeline/descriptor";
@@ -114,13 +113,39 @@ const validateStandard = async ( · 106 unmodified lines
114 return isRecord(result) && "value" in result ? result.value : value;113 return isRecord(result) && "value" in result ? result.value : value;
115};114};
116 115
117const jsonSchemaFor = (schema: unknown): unknown => {116const jsonSchemaFor = (
117 schema: unknown,
118 side: "input" | "output",
119 toolName: string,
120 sourcePath: string,
121): unknown => {
118 if (schema === undefined) return undefined;122 if (schema === undefined) return undefined;
119 if (isRecord(schema) && isRecord(schema["~standard"])) {123 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 }
120 try {137 try {
121 return z.toJSONSchema(schema as unknown as z.ZodType);138 const converted = jsonSchema[side]({ target: "draft-2020-12" });
122 } catch {139 if (!isRecord(converted)) return converted;
123 return {};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 });
124 }149 }
125 }150 }
126 return schema;151 return schema;
@@ -243,7 +268,7 @@ const collectFromExport = ( · 116 unmodified lines
243 sourcePath: string,268 sourcePath: string,
244): CollectedModule => {269): CollectedModule => {
245 if (isDefinedTool(exported)) {270 if (isDefinedTool(exported)) {
246 const inputJsonSchema = jsonSchemaFor(exported.input);271 const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath);
247 const integrations = collectIntegrations(272 const integrations = collectIntegrations(
248 fileSlug,273 fileSlug,
249 sourcePath,274 sourcePath,
@@ -257,7 +282,7 @@ const collectFromExport = ( · 7 unmodified lines
257 description: exported.description,282 description: exported.description,
258 integrations,283 integrations,
259 inputSchema: projectInputSchema(inputJsonSchema, integrations),284 inputSchema: projectInputSchema(inputJsonSchema, integrations),
260 outputSchema: jsonSchemaFor(exported.output),285 outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath),
261 annotations: exported.annotations,286 annotations: exported.annotations,
262 },287 },
263 ],288 ],
@@ -296,7 +321,7 @@ const collectFromExport = ( · 32 unmodified lines
296 diagnostics: [{ path: sourcePath, message: `duplicate tool name "${toolName}"` }],321 diagnostics: [{ path: sourcePath, message: `duplicate tool name "${toolName}"` }],
297 });322 });
298 }323 }
299 const inputJsonSchema = jsonSchemaFor(value.input);324 const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath);
300 const integrations = collectIntegrations(325 const integrations = collectIntegrations(
301 toolName,326 toolName,
302 sourcePath,327 sourcePath,
@@ -310,7 +335,7 @@ const collectFromExport = ( · 7 unmodified lines
310 description: value.description,335 description: value.description,
311 integrations,336 integrations,
312 inputSchema: projectInputSchema(inputJsonSchema, integrations),337 inputSchema: projectInputSchema(inputJsonSchema, integrations),
313 outputSchema: jsonSchemaFor(value.output),338 outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath),
314 annotations: value.annotations,339 annotations: value.annotations,
315 });340 });
316 }341 }
@@ -442,7 +467,12 @@ export const makeInProcessAppToolExecutor = (): AppToolExecutor => ({ · 125 unmodified lines
442 message: `tool not found in bundle: ${entry.toolName}`,467 message: `tool not found in bundle: ${entry.toolName}`,
443 });468 });
444 }469 }
445 const inputJsonSchema = jsonSchemaFor(tool.input);470 const inputJsonSchema = jsonSchemaFor(
471 tool.input,
472 "input",
473 entry.toolName,
474 entry.toolName,
475 );
446 const integrations = collectIntegrations(476 const integrations = collectIntegrations(
447 entry.toolName,477 entry.toolName,
448 entry.toolName,478 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
112 if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null;112 if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null;
113 return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) };113 return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) };
114};114};
115const jsonSchemaFor = (schema) => schema === undefined ? undefined : (isRecord(schema) && isRecord(schema["~standard"]) ? {} : schema);115const 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};
116const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => {131const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => {
117 if (declarations === undefined) return {};132 if (declarations === undefined) return {};
118 if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]);133 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
138};153};
139const collectFromExport = (exported, fileSlug, sourcePath) => {154const collectFromExport = (exported, fileSlug, sourcePath) => {
140 if (isDefinedTool(exported)) {155 if (isDefinedTool(exported)) {
141 const inputJsonSchema = jsonSchemaFor(exported.input);156 const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath);
142 const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema);157 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 }] };
144 }159 }
145 if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]);160 if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]);
146 const tools = [];161 const tools = [];
@@ -151,10 +166,10 @@ const collectFromExport = (exported, fileSlug, sourcePath) => { · 4 unmodified lines
151 if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]);166 if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]);
152 const toolName = fileSlug + "__" + key;167 const toolName = fileSlug + "__" + key;
153 if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]);168 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);
155 const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema);170 const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema);
156 seen.add(toolName);171 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 });
158 }173 }
159 if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]);174 if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]);
160 return { tools };175 return { tools };
@@ -220,7 +235,7 @@ export default class AppExecutor extends WorkerEntrypoint { · 59 unmodified lines
220 const exported = await resolveDefault();235 const exported = await resolveDefault();
221 const tool = selectTool(exported, input.toolName);236 const tool = selectTool(exported, input.toolName);
222 if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName);237 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);
224 const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema);239 const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema);
225 const split = splitInvokeInput(bridge, input.toolName, input.input, integrations);240 const split = splitInvokeInput(bridge, input.toolName, input.input, integrations);
226 const decoded = await validateStandard(tool.input, split.input, "input");241 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 */
2import { Effect } from "effect";2import { Effect } from "effect";
3 3
4import {4import {
@@ -47,7 +47,7 @@ const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: strin · 42 unmodified lines
47 ? cause47 ? cause
48 : new AppExecutorError({48 : new AppExecutorError({
49 kind,49 kind,
50 message,50 message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`,
51 cause,51 cause,
52 });52 });
53 53
@@ -86,7 +86,22 @@ const integrationDeclaration = (value) => { · 32 unmodified lines
86 if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null;86 if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null;
87 return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) };87 return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) };
88};88};
89const jsonSchemaFor = (schema) => schema === undefined ? undefined : (isRecord(schema) && isRecord(schema["~standard"]) ? {} : schema);89const 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};
90const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => {105const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => {
91 if (declarations === undefined) return {};106 if (declarations === undefined) return {};
92 if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]);107 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
116};131};
117const collectFromExport = (exported, fileSlug, sourcePath) => {132const collectFromExport = (exported, fileSlug, sourcePath) => {
118 if (isDefinedTool(exported)) {133 if (isDefinedTool(exported)) {
119 const inputJsonSchema = jsonSchemaFor(exported.input);134 const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath);
120 const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema);135 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 }] };
122 }137 }
123 if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]);138 if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]);
124 const tools = [];139 const tools = [];
@@ -129,10 +144,10 @@ const collectFromExport = (exported, fileSlug, sourcePath) => { · 4 unmodified lines
129 if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]);144 if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]);
130 const toolName = fileSlug + "__" + key;145 const toolName = fileSlug + "__" + key;
131 if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]);146 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);
133 const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema);148 const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema);
134 seen.add(toolName);149 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 });
136 }151 }
137 if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]);152 if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]);
138 return { tools };153 return { tools };
@@ -208,7 +223,7 @@ export default { · 69 unmodified lines
208 const exported = await resolveDefault();223 const exported = await resolveDefault();
209 const tool = selectTool(exported, input.toolName);224 const tool = selectTool(exported, input.toolName);
210 if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName);225 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);
212 const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema);227 const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema);
213 const split = splitInvokeInput(env, input.toolName, input.input, integrations);228 const split = splitInvokeInput(env, input.toolName, input.input, integrations);
214 const decoded = await validateStandard(tool.input, split.input, "input");229 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
136 136
137export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {137export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {
138 const url = new URL(input);138 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 }
140 if (url.username || url.password)146 if (url.username || url.password)
141 throw new Error("git source URL must not include embedded credentials");147 throw new Error("git source URL must not include embedded credentials");
142 if (hasTokenLikeQuery(url))148 if (hasTokenLikeQuery(url))
143 throw new Error("git source URL must not include token query parameters");149 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) {
145 throw new Error("git source URL host is not allowed");151 throw new Error("git source URL host is not allowed");
146 }152 }
147 return url;153 return url;
packages/plugins/apps/src/pipeline/discover.ts+12−3
@@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines
29const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;29const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;
30const DEFERRED_RE = /^(workflows|ui|skills)\//;30const DEFERRED_RE = /^(workflows|ui|skills)\//;
31 31
32/** Reserved top-level app source folders whose contents are deferred by discovery. */
33export 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. */
36export 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. */
39export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path);
40
32export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key);41export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key);
33 42
34export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => {43export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => {
@@ -39,9 +48,9 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 4 unmodified lines
39 48
40 for (const [path] of files) {49 for (const [path] of files) {
41 if (path === "executor.json") continue;50 if (path === "executor.json") continue;
42 const tool = path.match(TOOL_RE);51 const tool = toolKeyFromPath(path);
43 if (tool) {52 if (tool) {
44 const name = tool[1]!;53 const name = tool;
45 if (seen.has(name)) {54 if (seen.has(name)) {
46 diagnostics.push({ path, message: `duplicate tool identity: ${name}` });55 diagnostics.push({ path, message: `duplicate tool identity: ${name}` });
47 } else {56 } else {
@@ -54,7 +63,7 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 6 unmodified lines
54 diagnostics.push({ path, message: "file does not match the expected layout for tools/" });63 diagnostics.push({ path, message: "file does not match the expected layout for tools/" });
55 continue;64 continue;
56 }65 }
57 if (DEFERRED_RE.test(path)) skipped.push({ path, reason: "not supported yet" });66 if (isReservedArtifactPath(path)) skipped.push({ path, reason: "not supported yet" });
58 }67 }
59 68
60 if (diagnostics.length > 0) {69 if (diagnostics.length > 0) {
packages/plugins/apps/src/pipeline/publish.test.ts+29−1
@@ -58,7 +58,18 @@ const makeMemoryStore = (): AppsStore & { · 57 unmodified lines
58 for (const key of rows.keys()) rows.delete(key);58 for (const key of rows.keys()) rows.delete(key);
59 for (const item of next.tools) rows.set(item.name, item);59 for (const item of next.tools) rows.set(item.name, item);
60 }),60 }),
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 ),
62 getTool: (name) =>73 getTool: (name) =>
63 Effect.sync(() => {74 Effect.sync(() => {
64 const row = descriptor?.tools.find((item) => item.name === name);75 const row = descriptor?.tools.find((item) => item.name === name);
@@ -75,6 +86,23 @@ const makeMemoryStore = (): AppsStore & { · 10 unmodified lines
75 }86 }
76 : null;87 : null;
77 }),88 }),
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 }),
78 putSource: () => Effect.void,106 putSource: () => Effect.void,
79 listSources: () => Effect.succeed([]),107 listSources: () => Effect.succeed([]),
80 getSource: () => Effect.succeed(null),108 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
63 sourcePath: "tools/greet.ts",63 sourcePath: "tools/greet.ts",
64 });64 });
65 expect(collected.tools.map((tool) => tool.toolName)).toEqual(["greet"]);65 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 });
66 const invoked = yield* executor.invoke(71 const invoked = yield* executor.invoke(
67 bundled.code,72 bundled.code,
68 { toolName: "greet" },73 { toolName: "greet" },
packages/plugins/apps/src/pipeline/worker-bundler.ts+25−14
@@ -1,4 +1,5 @@
1/* 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 */1/* 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 */
2import { existsSync } from "node:fs";
2import { readFile } from "node:fs/promises";3import { readFile } from "node:fs/promises";
3import { dirname, join } from "node:path";4import { dirname, join } from "node:path";
4import { fileURLToPath } from "node:url";5import { fileURLToPath } from "node:url";
@@ -7,7 +8,6 @@ import { · 2 unmodified lines
7 createWorkerdModuleRunner,8 createWorkerdModuleRunner,
8 WORKERD_VERSION,9 WORKERD_VERSION,
9} from "@executor-js/runtime-workerd-subprocess";10} from "@executor-js/runtime-workerd-subprocess";
10import { build } from "esbuild";
11import { Effect } from "effect";11import { Effect } from "effect";
12 12
13import { AppExecutorError } from "../executor/app-tool-executor";13import { AppExecutorError } from "../executor/app-tool-executor";
@@ -18,7 +18,9 @@ import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker · 4 unmodified lines
18 18
19const textEncoder = new TextEncoder();19const textEncoder = new TextEncoder();
20const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));20const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
21const workerBundlerDir = join(packageDir, "node_modules", "@cloudflare", "worker-bundler");21const workerBundlerDir =
22 process.env.EXECUTOR_WORKER_BUNDLER_DIR ??
23 join(packageDir, "node_modules", "@cloudflare", "worker-bundler");
22 24
23const executorAppSource = `25const executorAppSource = `
24const makeIntegrationDeclaration = (state) => Object.freeze({26const makeIntegrationDeclaration = (state) => Object.freeze({
@@ -79,6 +81,10 @@ export default { · 54 unmodified lines
79 if (code === null) {81 if (code === null) {
80 return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule });82 return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule });
81 }83 }
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 }
82 for (const [path, module] of Object.entries(result.modules)) {88 for (const [path, module] of Object.entries(result.modules)) {
83 const source = moduleCode(module) ?? "";89 const source = moduleCode(module) ?? "";
84 if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) {90 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
97 readonly source: string;103 readonly source: string;
98 readonly wasm: Uint8Array;104 readonly wasm: Uint8Array;
99}> => {105}> => {
100 const entry = join(workerBundlerDir, "dist", "index.js");106 const distDir = join(workerBundlerDir, "dist");
101 const [result, wasm] = await Promise.all([107 const bundledEntry = join(distDir, "index.bundled.js");
102 build({108 const [sourceFromDisk, wasm] = await Promise.all([
103 entryPoints: [entry],109 existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null),
104 bundle: true,110 readFile(join(distDir, "esbuild.wasm")),
105 format: "esm",
106 platform: "browser",
107 write: false,
108 external: ["./esbuild.wasm"],
109 logLevel: "silent",
110 }),
111 readFile(join(workerBundlerDir, "dist", "esbuild.wasm")),
112 ]);111 ]);
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 });
113 const source = result.outputFiles[0]?.text;124 const source = result.outputFiles[0]?.text;
114 if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");125 if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
115 return { source, wasm };126 return { source, wasm };
packages/plugins/apps/src/plugin/apps-plugin.test.ts+369−35
@@ -1,10 +1,12 @@
1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: test temporarily overrides global fetch and restores it in finally */1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: test temporarily overrides global fetch and restores it in finally */
2import { readFile } from "node:fs/promises";2import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
3import { join } from "node:path";4import { join } from "node:path";
4 5
5import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
6import { Effect, Exit } from "effect";7import { Effect, Exit } from "effect";
7import { ToolResult } from "@executor-js/sdk";8import { IntegrationSlug, ToolResult, createExecutor, definePlugin } from "@executor-js/sdk";
9import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
8 10
9import { FLUSH, pktLine } from "../git-client/pktline";11import { FLUSH, pktLine } from "../git-client/pktline";
10import { bundleEntry } from "../pipeline/bundle";12import { bundleEntry } from "../pipeline/bundle";
@@ -19,6 +21,45 @@ const bundle = (source: string) => · 8 unmodified lines
19 entry: "tools/sync.ts",21 entry: "tools/sync.ts",
20 });22 });
21 23
24const 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
49const 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
22const concat = (parts: readonly Uint8Array[]): Uint8Array => {63const concat = (parts: readonly Uint8Array[]): Uint8Array => {
23 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));64 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
24 let offset = 0;65 let offset = 0;
@@ -49,15 +90,14 @@ const sideBand = (bytes: Uint8Array): Uint8Array => { · 24 unmodified lines
49 90
50const makeSyncStore = (): AppsStore & {91const makeSyncStore = (): AppsStore & {
51 readonly sources: Map<string, AppSourceRecord>;92 readonly sources: Map<string, AppSourceRecord>;
52 readonly descriptor: () => AppDescriptor | null;93 readonly descriptor: (app: string) => AppDescriptor | null;
53} => {94} => {
54 const blobs = new Map<string, string>();95 const blobs = new Map<string, string>();
55 const sources = new Map<string, AppSourceRecord>();96 const sources = new Map<string, AppSourceRecord>();
56 let descriptor: AppDescriptor | null = null;97 const descriptors = new Map<string, { descriptor: AppDescriptor; descriptorKey: string }>();
57 let descriptorKey: string | null = null;
58 return {98 return {
59 sources,99 sources,
60 descriptor: () => descriptor,100 descriptor: (app) => descriptors.get(app)?.descriptor ?? null,
61 putBlob: (body) =>101 putBlob: (body) =>
62 Effect.sync(() => {102 Effect.sync(() => {
63 const key = `blob:${blobs.size}`;103 const key = `blob:${blobs.size}`;
@@ -65,31 +105,63 @@ const makeSyncStore = (): AppsStore & { · 1 unmodified line
65 return key;105 return key;
66 }),106 }),
67 getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null),107 getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null),
68 getDescriptorRecord: () =>108 getDescriptorRecord: (app) =>
69 Effect.sync(() =>109 Effect.sync(() => {
70 descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null,110 const current = descriptors.get(app);
71 ),111 return current
112 ? {
113 sourceRef: current.descriptor.sourceRef,
114 descriptorKey: current.descriptorKey,
115 }
116 : null;
117 }),
72 putPublished: (next, nextDescriptorKey) =>118 putPublished: (next, nextDescriptorKey) =>
73 Effect.sync(() => {119 Effect.sync(() => {
74 descriptor = next;120 descriptors.set(next.app, { descriptor: next, descriptorKey: nextDescriptorKey });
75 descriptorKey = nextDescriptorKey;121 }),
122 removePublished: (app) =>
123 Effect.sync(() => {
124 descriptors.delete(app);
76 }),125 }),
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 ),
78 getTool: (name) =>132 getTool: (name) =>
79 Effect.sync(() => {133 Effect.sync(() => {
134 const descriptor = [...descriptors.values()].find((entry) =>
135 entry.descriptor.tools.some((item) => item.name === name),
136 )?.descriptor;
80 const tool = descriptor?.tools.find((item) => item.name === name);137 const tool = descriptor?.tools.find((item) => item.name === name);
81 return tool138 if (!descriptor || !tool) return null;
82 ? {139 return {
83 app: descriptor!.app,140 app: descriptor.app,
84 name: tool.name,141 name: tool.name,
85 bundleKey: tool.bundleKey,142 bundleKey: tool.bundleKey,
86 description: tool.description,143 description: tool.description,
87 inputSchema: tool.inputSchema,144 inputSchema: tool.inputSchema,
88 outputSchema: tool.outputSchema,145 outputSchema: tool.outputSchema,
89 integrations: tool.integrations,146 integrations: tool.integrations,
90 annotations: tool.annotations,147 annotations: tool.annotations,
91 }148 };
92 : null;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 };
93 }),165 }),
94 putSource: (record) =>166 putSource: (record) =>
95 Effect.sync(() => {167 Effect.sync(() => {
@@ -145,6 +217,7 @@ const makeInvokeStore = (input: { · 49 unmodified lines
145 getBlob: () => Effect.succeed(input.bundle),217 getBlob: () => Effect.succeed(input.bundle),
146 getDescriptorRecord: () => Effect.succeed(null),218 getDescriptorRecord: () => Effect.succeed(null),
147 putPublished: () => Effect.void,219 putPublished: () => Effect.void,
220 removePublished: () => Effect.void,
148 listActiveTools: () => Effect.succeed([]),221 listActiveTools: () => Effect.succeed([]),
149 getTool: () =>222 getTool: () =>
150 Effect.succeed({223 Effect.succeed({
@@ -154,6 +227,14 @@ const makeInvokeStore = (input: { · 3 unmodified lines
154 description: "Sync",227 description: "Sync",
155 integrations: input.integrations,228 integrations: input.integrations,
156 }),229 }),
230 getToolForApp: () =>
231 Effect.succeed({
232 app: "crm",
233 name: "sync",
234 bundleKey: "bundle",
235 description: "Sync",
236 integrations: input.integrations,
237 }),
157 putSource: () => Effect.void,238 putSource: () => Effect.void,
158 listSources: () => Effect.succeed([]),239 listSources: () => Effect.succeed([]),
159 getSource: () => Effect.succeed(null),240 getSource: () => Effect.succeed(null),
@@ -190,6 +271,7 @@ describe("apps plugin schema projection", () => { · 30 unmodified lines
190 getBlob: () => Effect.succeed(null),271 getBlob: () => Effect.succeed(null),
191 getDescriptorRecord: () => Effect.succeed(null),272 getDescriptorRecord: () => Effect.succeed(null),
192 putPublished: () => Effect.void,273 putPublished: () => Effect.void,
274 removePublished: () => Effect.void,
193 listActiveTools: () => Effect.succeed([]),275 listActiveTools: () => Effect.succeed([]),
194 putSource: () => Effect.void,276 putSource: () => Effect.void,
195 listSources: () => Effect.succeed([]),277 listSources: () => Effect.succeed([]),
@@ -206,6 +288,17 @@ describe("apps plugin schema projection", () => { · 10 unmodified lines
206 inboxes: { slug: "gmail", mode: "many" },288 inboxes: { slug: "gmail", mode: "many" },
207 },289 },
208 }),290 }),
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 }),
209 };302 };
210 const result = yield* projectAppsToolSchema(303 const result = yield* projectAppsToolSchema(
211 {304 {
@@ -219,6 +312,7 @@ describe("apps plugin schema projection", () => { · 7 unmodified lines
219 ),312 ),
220 },313 },
221 } as never,314 } as never,
315 "crm",
222 "sync",316 "sync",
223 {317 {
224 type: "object",318 type: "object",
@@ -233,15 +327,25 @@ describe("apps plugin schema projection", () => { · 8 unmodified lines
233 );327 );
234 expect(result.inputSchema).toMatchObject({328 expect(result.inputSchema).toMatchObject({
235 properties: {329 properties: {
236 crm: { enum: ["tools.dealcloud.org.main"] },330 crm: {
331 enum: ["tools.dealcloud.org.main"],
332 default: "tools.dealcloud.org.main",
333 },
237 inboxes: {334 inboxes: {
335 default: ["tools.gmail.org.work", "tools.gmail.user.personal"],
238 items: {336 items: {
239 enum: ["tools.gmail.org.work", "tools.gmail.user.personal"],337 enum: ["tools.gmail.org.work", "tools.gmail.user.personal"],
240 },338 },
241 },339 },
242 },340 },
243 required: ["crm", "inboxes"],
244 });341 });
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 );
245 }),349 }),
246 );350 );
247});351});
@@ -303,6 +407,17 @@ describe("apps source sync", () => { · 55 unmodified lines
303 }),407 }),
304 );408 );
305 409
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
306 it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () =>421 it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () =>
307 Effect.gen(function* () {422 Effect.gen(function* () {
308 const fixture = yield* Effect.promise(() => fixtureFetch());423 const fixture = yield* Effect.promise(() => fixtureFetch());
@@ -314,7 +429,8 @@ describe("apps source sync", () => { · 5 unmodified lines
314 executor: makeInProcessAppToolExecutor(),429 executor: makeInProcessAppToolExecutor(),
315 allowPrivateGitHosts: true,430 allowPrivateGitHosts: true,
316 });431 });
317 let connectionExists = false;432 const integrations = new Set<string>();
433 const connections = new Set<string>();
318 const extension = plugin.extension!({434 const extension = plugin.extension!({
319 storage: store,435 storage: store,
320 providers: {436 providers: {
@@ -324,16 +440,31 @@ describe("apps source sync", () => { · 3 unmodified lines
324 },440 },
325 core: {441 core: {
326 integrations: {442 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 }),
328 },458 },
329 },459 },
330 connections: {460 connections: {
331 list: () => Effect.succeed([]),461 list: () => Effect.succeed([]),
332 resolveValue: () => Effect.succeed(null),462 resolveValue: () => Effect.succeed(null),
333 get: () => Effect.succeed(connectionExists ? ({} as never) : null),463 get: ({ integration }: { readonly integration: unknown }) =>
334 create: () =>464 Effect.succeed(connections.has(String(integration)) ? ({} as never) : null),
465 create: ({ integration }: { readonly integration: unknown }) =>
335 Effect.sync(() => {466 Effect.sync(() => {
336 connectionExists = true;467 connections.add(String(integration));
337 }),468 }),
338 refresh: () => Effect.succeed([]),469 refresh: () => Effect.succeed([]),
339 },470 },
@@ -359,12 +490,215 @@ describe("apps source sync", () => { · 19 unmodified lines
359 expect(changed.status).toBe("published");490 expect(changed.status).toBe("published");
360 expect(changed.sourceRef).not.toBe(first.sourceRef);491 expect(changed.sourceRef).not.toBe(first.sourceRef);
361 expect(fixture.packRequests()).toBe(2);492 expect(fixture.packRequests()).toBe(2);
362 expect(store.descriptor()?.sourceRef).toBe(changed.sourceRef);493 expect(store.descriptor("fixture")?.sourceRef).toBe(changed.sourceRef);
363 } finally {494 } finally {
364 globalThis.fetch = originalFetch;495 globalThis.fetch = originalFetch;
365 }496 }
366 }),497 }),
367 );498 );
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 );
368});702});
369 703
370describe("apps plugin invocation", () => {704describe("apps plugin invocation", () => {
@@ -392,7 +726,7 @@ describe("apps plugin invocation", () => { · 21 unmodified lines
392 ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }),726 ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }),
393 ),727 ),
394 }),728 }),
395 toolRow: { name: "sync" },729 toolRow: { integration: "crm", name: "sync" },
396 args: { crm: "tools.dealcloud.org.main" },730 args: { crm: "tools.dealcloud.org.main" },
397 } as never);731 } as never);
398 expect(result).toMatchObject({732 expect(result).toMatchObject({
@@ -439,7 +773,7 @@ describe("apps plugin invocation", () => { · 40 unmodified lines
439 ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }),773 ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }),
440 ),774 ),
441 }),775 }),
442 toolRow: { name: "sync" },776 toolRow: { integration: "crm", name: "sync" },
443 args: { crm: "tools.dealcloud.org.main" },777 args: { crm: "tools.dealcloud.org.main" },
444 } as never);778 } as never);
445 expect(result).toEqual({ fallback: true });779 expect(result).toEqual({ fallback: true });
packages/plugins/apps/src/plugin/apps-plugin.ts+215−70
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 ToolName,9 ToolName,
10 ToolResult,10 ToolResult,
11 definePlugin,11 definePlugin,
12 IntegrationAlreadyExistsError,
12 type PluginCtx,13 type PluginCtx,
13 type ToolDef,14 type ToolDef,
14} from "@executor-js/sdk";15} from "@executor-js/sdk";
@@ -33,11 +34,13 @@ import { · 18 unmodified lines
33 type SyncDiagnostic,34 type SyncDiagnostic,
34} from "../source/app-source";35} from "../source/app-source";
35import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source";36import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source";
36import { fetchLocalDirectoryAppSource } from "../source/local-directory-source";37import {
38 fetchLocalDirectoryAppSource,
39 listLocalDirectoryDirs,
40} from "../source/local-directory-source";
37import { AppSourceError, type AppSourceSnapshot } from "../source/app-source";41import { AppSourceError, type AppSourceSnapshot } from "../source/app-source";
38import type { PublishError } from "../pipeline/publish";42import type { PublishError } from "../pipeline/publish";
39 43
40const APPS_INTEGRATION = IntegrationSlug.make("apps");
41const APPS_CONNECTION = ConnectionName.make("published");44const APPS_CONNECTION = ConnectionName.make("published");
42const APPS_NO_AUTH = AuthTemplateSlug.make("none");45const APPS_NO_AUTH = AuthTemplateSlug.make("none");
43 46
@@ -45,6 +48,10 @@ class AppPluginError extends Data.TaggedError("AppPluginError")<{ · 1 unmodified line
45 readonly message: string;48 readonly message: string;
46}> {}49}> {}
47 50
51class AppSlugConflictError extends Data.TaggedError("AppSlugConflictError")<{
52 readonly app: string;
53}> {}
54
48interface ProjectedToolSchema {55interface ProjectedToolSchema {
49 readonly inputSchema?: unknown;56 readonly inputSchema?: unknown;
50 readonly outputSchema?: unknown;57 readonly outputSchema?: unknown;
@@ -138,53 +145,109 @@ const sourceConfig = (input: CreateAppSourceInput): AppSourceConfig => { · 87 unmodified lines
138 return { kind: "local-directory", path: input.path };145 return { kind: "local-directory", path: input.path };
139};146};
140 147
141const ensureAppsCatalogConnection = (ctx: PluginCtx<AppsStore>): Effect.Effect<void, unknown> =>148const 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
155const ensureAppIntegration = (
156 ctx: PluginCtx<AppsStore>,
157 record: AppSourceRecord,
158 description?: string,
159): Effect.Effect<void, unknown> =>
142 Effect.gen(function* () {160 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;
143 yield* ctx.core.integrations.register({164 yield* ctx.core.integrations.register({
144 slug: APPS_INTEGRATION,165 slug: appSlug,
145 name: "Apps",166 name: record.app,
146 description: "Published app tools",167 description: appDescription(record, description),
147 config: {},168 config: {},
148 });169 });
170 });
171
172const 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);
149 const existing = yield* ctx.connections.get({180 const existing = yield* ctx.connections.get({
150 owner: "org",181 owner: "org",
151 integration: APPS_INTEGRATION,182 integration: appSlug,
152 name: APPS_CONNECTION,183 name: APPS_CONNECTION,
153 });184 });
154 if (existing) {185 if (existing) {
155 yield* ctx.connections.refresh({186 yield* ctx.connections.refresh({
156 owner: "org",187 owner: "org",
157 integration: APPS_INTEGRATION,188 integration: appSlug,
158 name: APPS_CONNECTION,189 name: APPS_CONNECTION,
159 });190 });
160 return;191 return;
161 }192 }
162 yield* ctx.connections.create({193 yield* ctx.connections.create({
163 owner: "org",194 owner: "org",
164 integration: APPS_INTEGRATION,195 integration: appSlug,
165 name: APPS_CONNECTION,196 name: APPS_CONNECTION,
166 template: APPS_NO_AUTH,197 template: APPS_NO_AUTH,
167 values: {},198 values: {},
168 });199 });
169 });200 });
170 201
171const tokenItemId = (slug: string): ProviderItemId =>202const guardAppIntegration = (
172 ProviderItemId.make(`apps/source-tokens/${slug}`);203 ctx: PluginCtx<AppsStore>,
173 204 record: AppSourceRecord,
174const gitHubConnectionToken = (ctx: PluginCtx<AppsStore>): Effect.Effect<string | null, unknown> =>205): Effect.Effect<void, unknown> =>
175 Effect.gen(function* () {206 Effect.gen(function* () {
176 const connections = yield* ctx.connections.list({207 const appSlug = IntegrationSlug.make(record.app);
177 integration: IntegrationSlug.make("github"),208 const existing = yield* ctx.core.integrations.get(appSlug);
178 });209 if (existing && existing.kind !== "apps") {
179 const connection = connections.find((item) => item.owner === "user") ?? connections[0];210 return yield* new IntegrationAlreadyExistsError({ slug: appSlug });
180 if (!connection) return null;211 }
181 return yield* ctx.connections.resolveValue({212 const sources = yield* ctx.storage.listSources();
182 owner: connection.owner,213 const duplicate = sources.find(
183 integration: connection.integration,214 (source) => source.slug !== record.slug && source.app === record.app,
184 name: connection.name,215 );
185 });216 if (duplicate) {
217 return yield* new AppSlugConflictError({ app: record.app });
218 }
186 });219 });
187 220
221const 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
245const 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.
188const gitTokenFor = (251const gitTokenFor = (
189 ctx: PluginCtx<AppsStore>,252 ctx: PluginCtx<AppsStore>,
190 config: Extract<AppSourceConfig, { readonly kind: "git" }>,253 config: Extract<AppSourceConfig, { readonly kind: "git" }>,
@@ -196,9 +259,7 @@ const gitTokenFor = ( · 5 unmodified lines
196 ProviderItemId.make(config.tokenItemId),259 ProviderItemId.make(config.tokenItemId),
197 );260 );
198 }261 }
199 const url = yield* parseGitSourceUrl(config.url).pipe(Effect.result);262 return null;
200 if (Result.isFailure(url) || url.success.hostname.toLowerCase() !== "github.com") return null;
201 return yield* gitHubConnectionToken(ctx);
202 });263 });
203 264
204const activeToolNamesFor = (265const activeToolNamesFor = (
@@ -207,13 +268,7 @@ const activeToolNamesFor = ( · 2 unmodified lines
207): Effect.Effect<readonly string[], unknown> =>268): Effect.Effect<readonly string[], unknown> =>
208 ctx.storage269 ctx.storage
209 .listActiveTools()270 .listActiveTools()
210 .pipe(271 .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name)));
211 Effect.map((tools) =>
212 tools
213 .filter((tool) => tool.name.startsWith(`${app}__`) || tool.name === app)
214 .map((tool) => tool.name),
215 ),
216 );
217 272
218const makeAppsExtension = (273const makeAppsExtension = (
219 ctx: PluginCtx<AppsStore>,274 ctx: PluginCtx<AppsStore>,
@@ -232,6 +287,15 @@ const makeAppsExtension = ( · 12 unmodified lines
232 publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input),287 publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input),
233 listSources: () => ctx.storage.listSources(),288 listSources: () => ctx.storage.listSources(),
234 getSource: (slug: string) => ctx.storage.getSource(slug),289 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 }),
235 createSource: (input: CreateAppSourceInput) =>299 createSource: (input: CreateAppSourceInput) =>
236 Effect.gen(function* () {300 Effect.gen(function* () {
237 const config = sourceConfig(input);301 const config = sourceConfig(input);
@@ -276,11 +340,18 @@ const makeAppsExtension = ( · 38 unmodified lines
276 updatedAt: now(),340 updatedAt: now(),
277 };341 };
278 yield* ctx.storage.putSource(record, "org");342 yield* ctx.storage.putSource(record, "org");
343 yield* ensureAppIntegration(ctx, record);
279 return record;344 return record;
280 }),345 }),
281 deleteSource: (slug: string) =>346 deleteSource: (slug: string) =>
282 Effect.gen(function* () {347 Effect.gen(function* () {
283 const record = yield* ctx.storage.getSource(slug);348 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 }
284 if (355 if (
285 record?.config.kind === "git" &&356 record?.config.kind === "git" &&
286 record.config.tokenProvider &&357 record.config.tokenProvider &&
@@ -323,6 +394,17 @@ const makeAppsExtension = ( · 36 unmodified lines
323 return { status: "failed", tools: [], errors: [diagnostic] };394 return { status: "failed", tools: [], errors: [diagnostic] };
324 }395 }
325 if (record.sourceRef === checked.success.sourceRef) {396 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 }
326 const tools = yield* activeToolNamesFor(ctx, record.app);408 const tools = yield* activeToolNamesFor(ctx, record.app);
327 const updated: AppSourceRecord = {409 const updated: AppSourceRecord = {
328 ...record,410 ...record,
@@ -330,7 +412,7 @@ const makeAppsExtension = ( · 1 unmodified line
330 updatedAt: now(),412 updatedAt: now(),
331 };413 };
332 yield* ctx.storage.putSource(updated, "org");414 yield* ctx.storage.putSource(updated, "org");
333 yield* ensureAppsCatalogConnection(ctx);415 yield* ensureAppConnection(ctx, updated);
334 return { status: "up-to-date", sourceRef: checked.success.sourceRef, tools };416 return { status: "up-to-date", sourceRef: checked.success.sourceRef, tools };
335 }417 }
336 }418 }
@@ -361,16 +443,49 @@ const makeAppsExtension = ( · 24 unmodified lines
361 }443 }
362 const snapshot: AppSourceSnapshot = fetched.success;444 const snapshot: AppSourceSnapshot = fetched.success;
363 if (record.sourceRef === snapshot.sourceRef) {445 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 }
364 const tools = yield* activeToolNamesFor(ctx, record.app);458 const tools = yield* activeToolNamesFor(ctx, record.app);
365 const updated: AppSourceRecord = {459 const updated: AppSourceRecord = {
366 ...record,460 ...record,
461 description: snapshot.description,
367 status: { type: "up-to-date", at: now(), tools },462 status: { type: "up-to-date", at: now(), tools },
368 updatedAt: now(),463 updatedAt: now(),
369 };464 };
370 yield* ctx.storage.putSource(updated, "org");465 yield* ctx.storage.putSource(updated, "org");
371 yield* ensureAppsCatalogConnection(ctx);466 yield* ensureAppConnection(ctx, updated, snapshot.description);
372 return { status: "up-to-date", sourceRef: snapshot.sourceRef, tools };467 return { status: "up-to-date", sourceRef: snapshot.sourceRef, tools };
373 }468 }
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 }
374 const published = yield* publish(489 const published = yield* publish(
375 { store: ctx.storage, executor: activeExecutor, bundler: activeBundler },490 { store: ctx.storage, executor: activeExecutor, bundler: activeBundler },
376 {491 {
@@ -412,7 +527,7 @@ const makeAppsExtension = ( · 35 unmodified lines
412 },527 },
413 "org",528 "org",
414 );529 );
415 yield* ensureAppsCatalogConnection(ctx);530 yield* ensureAppConnection(ctx, record, snapshot.description);
416 return {531 return {
417 status: published.success.noop ? "up-to-date" : "published",532 status: published.success.noop ? "up-to-date" : "published",
418 sourceRef: snapshot.sourceRef,533 sourceRef: snapshot.sourceRef,
@@ -435,6 +550,7 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 16 unmodified lines
435 definePlugin(() => ({550 definePlugin(() => ({
436 id: "apps",551 id: "apps",
437 packageName: "@executor-js/plugin-apps",552 packageName: "@executor-js/plugin-apps",
553 clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] },
438 pluginStorage: {554 pluginStorage: {
439 [descriptorCollection.name]: descriptorCollection,555 [descriptorCollection.name]: descriptorCollection,
440 [toolCollection.name]: toolCollection,556 [toolCollection.name]: toolCollection,
@@ -442,49 +558,55 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 1 unmodified line
442 },558 },
443 storage: ({ blobs, pluginStorage }) => makeAppsStore({ blobs, pluginStorage }),559 storage: ({ blobs, pluginStorage }) => makeAppsStore({ blobs, pluginStorage }),
444 extension: (ctx: PluginCtx<AppsStore>) => makeAppsExtension(ctx, options),560 extension: (ctx: PluginCtx<AppsStore>) => makeAppsExtension(ctx, options),
445 staticSources: () => [561 resolveTools: ({ storage, connection }) =>
446 {
447 id: "apps",
448 kind: "apps",
449 name: "Apps",
450 canRemove: false,
451 canRefresh: false,
452 tools: [],
453 },
454 ],
455 resolveTools: ({ storage }) =>
456 storage.listActiveTools().pipe(562 storage.listActiveTools().pipe(
457 Effect.map((tools) => ({563 Effect.map((tools) => ({
458 tools: tools.map(564 tools: tools
459 (tool): ToolDef => ({565 .filter((tool) => tool.app === String(connection.integration))
460 name: ToolName.make(tool.name),566 .map(
461 description: tool.description,567 (tool): ToolDef => ({
462 inputSchema: tool.inputSchema,568 name: ToolName.make(tool.name),
463 outputSchema: tool.outputSchema,569 description: tool.description,
464 annotations: {570 inputSchema: tool.inputSchema,
465 ...(tool.annotations?.requiresApproval !== undefined571 outputSchema: tool.outputSchema,
466 ? { requiresApproval: tool.annotations.requiresApproval }572 annotations: {
467 : {}),573 ...(tool.annotations?.requiresApproval !== undefined
468 ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}),574 ? { requiresApproval: tool.annotations.requiresApproval }
469 },575 : {}),
470 }),576 ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}),
471 ),577 },
578 }),
579 ),
472 })),580 })),
473 ),581 ),
474 projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) =>582 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 ),
476 validateToolArgs: ({ ctx, toolRow, args }) =>590 validateToolArgs: ({ ctx, toolRow, args }) =>
477 Effect.gen(function* () {591 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 );
479 if (!tool) return;596 if (!tool) return;
480 const resolver = makePluginCtxAppsResolver({ ctx });597 const resolver = makePluginCtxAppsResolver({ ctx });
481 yield* resolveIntegrationBindings(tool.integrations, args, resolver);598 yield* resolveIntegrationBindings(tool.integrations, args, resolver);
482 }),599 }),
483 invokeTool: ({ ctx, toolRow, args, invokeOptions }) =>600 invokeTool: ({ ctx, toolRow, args, invokeOptions }) =>
484 Effect.gen(function* () {601 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 );
486 if (!tool) {606 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 });
488 }610 }
489 const bundle = yield* ctx.storage.getBlob(tool.bundleKey);611 const bundle = yield* ctx.storage.getBlob(tool.bundleKey);
490 if (!bundle) {612 if (!bundle) {
@@ -525,12 +647,13 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 34 unmodified lines
525 647
526export const projectAppsToolSchema = (648export const projectAppsToolSchema = (
527 ctx: PluginCtx<AppsStore>,649 ctx: PluginCtx<AppsStore>,
650 app: string,
528 toolName: string,651 toolName: string,
529 inputSchema: unknown,652 inputSchema: unknown,
530 outputSchema: unknown,653 outputSchema: unknown,
531): Effect.Effect<ProjectedToolSchema, unknown> =>654): Effect.Effect<ProjectedToolSchema, unknown> =>
532 Effect.gen(function* () {655 Effect.gen(function* () {
533 const tool = yield* ctx.storage.getTool(toolName);656 const tool = yield* ctx.storage.getToolForApp(app, toolName);
534 if (!tool || !inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {657 if (!tool || !inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
535 return { inputSchema, outputSchema };658 return { inputSchema, outputSchema };
536 }659 }
@@ -546,12 +669,34 @@ export const projectAppsToolSchema = ( · 9 unmodified lines
546 const enumValues = connections.map((connection) => String(connection.address));669 const enumValues = connections.map((connection) => String(connection.address));
547 properties[field] =670 properties[field] =
548 decl.mode === "many"671 decl.mode === "many"
549 ? { type: "array", items: { type: "string", enum: enumValues } }672 ? {
550 : { type: "string", enum: enumValues };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 };
551 }682 }
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 };
553 });698 });
554 699
555export const appsPlugin = makeAppsPlugin();700export const appsPlugin = makeAppsPlugin();
556 701
557export { APPS_INTEGRATION, APPS_CONNECTION };702export { APPS_CONNECTION };
packages/plugins/apps/src/plugin/handlers.ts+64−1
@@ -5,12 +5,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines
5import { addGroup, capture } from "@executor-js/api";5import { addGroup, capture } from "@executor-js/api";
6import { InternalError } from "@executor-js/sdk/shared";6import { InternalError } from "@executor-js/sdk/shared";
7import type { AppsExtension } from "./apps-plugin";7import type { AppsExtension } from "./apps-plugin";
8import { AppsGroup } from "./routes";8import { AppsBaseGroup, AppsGroup } from "./routes";
9 9
10export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()(10export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()(
11 "AppsExtensionService",11 "AppsExtensionService",
12) {}12) {}
13 13
14const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup);
14const ExecutorApiWithApps = addGroup(AppsGroup);15const ExecutorApiWithApps = addGroup(AppsGroup);
15 16
16const captureUnexpected = <A, E, R>(17const captureUnexpected = <A, E, R>(
@@ -18,6 +19,53 @@ const captureUnexpected = <A, E, R>( · 1 unmodified line
18): Effect.Effect<A, InternalError, R> =>19): Effect.Effect<A, InternalError, R> =>
19 capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause))));20 capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause))));
20 21
22export 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
21export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) =>69export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) =>
22 handlers70 handlers
23 .handle("listSources", () =>71 .handle("listSources", () =>
@@ -62,5 +110,20 @@ export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (h · 38 unmodified lines
62 return yield* ext.syncSource(params.slug);110 return yield* ext.syncSource(params.slug);
63 }),111 }),
64 ),112 ),
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 ),
65 ),124 ),
66);125);
126
127export 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
67 errors: Schema.optional(Schema.Array(SyncDiagnostic)),67 errors: Schema.optional(Schema.Array(SyncDiagnostic)),
68});68});
69 69
70const ListDirsQuery = Schema.Struct({
71 path: Schema.optional(Schema.String),
72 includeHidden: Schema.optional(Schema.String),
73});
74const 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
70const DomainErrors = [InternalError] as const;92const DomainErrors = [InternalError] as const;
71 93
72export const AppsGroup = HttpApiGroup.make("apps")94export const AppsBaseGroup = HttpApiGroup.make("apps")
73 .add(95 .add(
74 HttpApiEndpoint.get("listSources", "/apps/sources", {96 HttpApiEndpoint.get("listSources", "/apps/sources", {
75 success: SourcesResponse,97 success: SourcesResponse,
@@ -104,3 +126,18 @@ export const AppsGroup = HttpApiGroup.make("apps") · 28 unmodified lines
104 error: DomainErrors,126 error: DomainErrors,
105 }),127 }),
106 );128 );
129
130export 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
141export 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
177 yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1");177 yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1");
178 const active = yield* store.listActiveTools();178 const active = yield* store.listActiveTools();
179 expect(active.map((item) => item.name)).toEqual(["first"]);179 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();
180 }),201 }),
181 );202 );
182 203
packages/plugins/apps/src/plugin/store.ts+53−1
@@ -102,8 +102,9 @@ export interface AppsStore { · 101 unmodified lines
102 owner: Owner,102 owner: Owner,
103 expectedSourceRef: string | null,103 expectedSourceRef: string | null,
104 ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>;104 ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>;
105 readonly removePublished: (app: string, owner: Owner) => Effect.Effect<void, StorageFailure>;
105 readonly listActiveTools: () => Effect.Effect<106 readonly listActiveTools: () => Effect.Effect<
106 readonly AppDescriptor["tools"][number][],107 readonly (AppDescriptor["tools"][number] & { readonly app: string })[],
107 StorageFailure108 StorageFailure
108 >;109 >;
109 readonly getTool: (name: string) => Effect.Effect<110 readonly getTool: (name: string) => Effect.Effect<
@@ -119,6 +120,22 @@ export interface AppsStore { · 9 unmodified lines
119 } | null,120 } | null,
120 StorageFailure121 StorageFailure
121 >;122 >;
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 >;
122 readonly putSource: (139 readonly putSource: (
123 record: AppSourceRecord,140 record: AppSourceRecord,
124 owner: Owner,141 owner: Owner,
@@ -215,10 +232,27 @@ export const makeAppsStore = (input: { · 90 unmodified lines
215 },232 },
216 });233 });
217 }),234 }),
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 }),
218 listActiveTools: () =>251 listActiveTools: () =>
219 tools.query({ where: { tombstoned: false } }).pipe(252 tools.query({ where: { tombstoned: false } }).pipe(
220 Effect.map((entries) =>253 Effect.map((entries) =>
221 entries.map((entry) => ({254 entries.map((entry) => ({
255 app: entry.data.app,
222 name: entry.data.name,256 name: entry.data.name,
223 sourcePath: "",257 sourcePath: "",
224 source: { path: "", sourceHash: "" },258 source: { path: "", sourceHash: "" },
@@ -249,6 +283,24 @@ export const makeAppsStore = (input: { · 24 unmodified lines
249 : null;283 : null;
250 }),284 }),
251 ),285 ),
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 ),
252 putSource: (record, owner) =>304 putSource: (record, owner) =>
253 sources.put({ owner, key: record.slug, data: record }).pipe(Effect.asVoid),305 sources.put({ owner, key: record.slug, data: record }).pipe(Effect.asVoid),
254 listSources: () =>306 listSources: () =>
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+459−0
@@ -0,0 +1,459 @@
1import { useEffect, useMemo, useState } from "react";
2import { Effect, Exit } from "effect";
3
4import { Button } from "@executor-js/react/components/button";
5import {
6 CardStack,
7 CardStackContent,
8 CardStackEntryField,
9} from "@executor-js/react/components/card-stack";
10import { FloatActions } from "@executor-js/react/components/float-actions";
11import { Input } from "@executor-js/react/components/input";
12import { Checkbox } from "@executor-js/react/components/checkbox";
13import { Label } from "@executor-js/react/components/label";
14import {
15 Dialog,
16 DialogContent,
17 DialogDescription,
18 DialogFooter,
19 DialogHeader,
20 DialogTitle,
21} from "@executor-js/react/components/dialog";
22import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
23
24import {
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";
37import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model";
38
39export 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
331function 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
437function 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 @@
1import { useCallback, useEffect, useState } from "react";
2import { Effect, Exit } from "effect";
3
4import {
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";
15import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
16import { Button } from "@executor-js/react/components/button";
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
18
19import {
20 listCustomToolSourcesEffect,
21 removeCustomToolSourceEffect,
22 syncCustomToolSourceEffect,
23 type AppSourceRecord,
24} from "./custom-tools-client";
25import {
26 sourceFailureLines,
27 sourcePanelModel,
28 syncNoticeFromResult,
29 toolsCountLabel,
30 type SyncNoticeModel,
31} from "./source-panel-model";
32
33const integrationsHref = (): string => {
34 const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/);
35 return `${match?.[1] ?? ""}/integrations`;
36};
37
38type LoadState =
39 | { readonly status: "loading" }
40 | { readonly status: "error"; readonly message: string }
41 | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] };
42
43export 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
173function 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
281function 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 @@
1import { Data, Effect, Schema } from "effect";
2import {
3 EXECUTOR_ORG_HEADER,
4 getActiveOrgSlug,
5 getExecutorApiBaseUrl,
6 getExecutorServerAuthorizationHeader,
7} from "@executor-js/react/api/server-connection";
8
9export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
10export const CUSTOM_TOOLS_LABEL = "Custom tools";
11
12export type AppSourceKind = "git" | "local-directory";
13
14export 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
20export 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
29export 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
57export interface SourcesListResponse {
58 readonly sources: readonly AppSourceRecord[];
59}
60
61export interface SourceDetailResponse {
62 readonly source: AppSourceRecord | null;
63}
64
65export 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
81export interface CreateSourceResponse {
82 readonly source: AppSourceRecord;
83}
84
85export 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
99export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
100
101const 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
118export interface CustomToolsDirectoryEntry {
119 readonly name: string;
120 readonly path: string;
121 readonly isSymlink: boolean;
122 readonly hasTools: boolean;
123}
124
125export interface CustomToolsDirectorySourceShape {
126 readonly toolFiles: readonly string[];
127 readonly skipped: readonly string[];
128 readonly hasPackageJson: boolean;
129}
130
131export interface CustomToolsDirectoryListing {
132 readonly path: string;
133 readonly parent: string | null;
134 readonly dirs: readonly CustomToolsDirectoryEntry[];
135 readonly source: CustomToolsDirectorySourceShape;
136}
137
138export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
139 readonly message: string;
140}> {}
141
142export 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
150export 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
158export 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
180export 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
187export const validateGitSourceUrl = (url: string): string | null => {
188 const parsed = parseGitSourceUrl(url);
189 return parsed.ok ? null : parsed.message;
190};
191
192const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
193const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
194
195const isRecord = (value: unknown): value is Record<string, unknown> =>
196 value !== null && typeof value === "object" && !Array.isArray(value);
197
198const 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
205const 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
228export 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
243export const listCustomToolSources = (
244 fetchImpl: CustomToolsFetch = customToolsFetch,
245): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));
246
247export 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
266export 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
288export 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
310export 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
327export 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
347export 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
353export 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
362export const formatSyncErrors = (result: SyncSourceResult): readonly string[] =>
363 result.status === "failed" ? formatDiagnostics(result.errors) : [];
364
365export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import 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";
20import {
21 directorySourceVerdict,
22 sourcePanelModel,
23 syncNoticeFromResult,
24} from "./source-panel-model";
25import { directoryBrowserRows } from "./source-panel-model";
26
27const 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
34const 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
55describe("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 @@
1export { default } from "./plugin-client";
2export * from "./custom-tools-client";
packages/plugins/apps/src/react/plugin-client.tsx+16−0
@@ -0,0 +1,16 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { makeAppsIntegrationPlugin } from "./source-plugin";
4import type { AppSourceKind } from "./custom-tools-client";
5
6export { makeAppsIntegrationPlugin } from "./source-plugin";
7export * from "./custom-tools-client";
8
9export 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 @@
1import type {
2 AppSourceRecord,
3 CustomToolsDirectoryListing,
4 SyncSourceResult,
5} from "./custom-tools-client";
6import {
7 formatDiagnostics,
8 formatSyncErrors,
9 syncStatusLabel,
10 toolDiff,
11} from "./custom-tools-client";
12
13export 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
21export 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
30export 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
40export 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
50const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
51
52export 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
64export const toolsCountLabel = (count: number): string =>
65 `${count} ${count === 1 ? "tool" : "tools"}`;
66
67export 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
95export const sourceFailureLines = (source: AppSourceRecord): readonly string[] =>
96 source.status.type === "failed" ? formatDiagnostics(source.status.errors) : [];
97
98export 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
114export 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
129const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, "");
130
131export 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 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3
4import {
5 CUSTOM_TOOLS_LABEL,
6 CUSTOM_TOOLS_PLUGIN_KEY,
7 type AppSourceKind,
8} from "./custom-tools-client";
9
10const importAccounts = () => import("./CustomToolsAccountsPanel");
11
12export 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 @@
1/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */1/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */
2import { createServer, type IncomingMessage, type ServerResponse } from "node:http";2import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";3import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
4import { tmpdir } from "node:os";4import { homedir, tmpdir } from "node:os";
5import { join } from "node:path";5import { join } from "node:path";
6import { deflateSync } from "node:zlib";6import { deflateSync } from "node:zlib";
7 7
@@ -14,7 +14,7 @@ import { parsePack, walkTree } from "../git-client/packfile"; · 6 unmodified lines
14import { authForHost, checkRefs, uploadPack } from "../git-client/transport";14import { authForHost, checkRefs, uploadPack } from "../git-client/transport";
15import { PUBLISH_LIMITS } from "../pipeline/publish";15import { PUBLISH_LIMITS } from "../pipeline/publish";
16import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source";16import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source";
17import { fetchLocalDirectoryAppSource } from "./local-directory-source";17import { fetchLocalDirectoryAppSource, listLocalDirectoryDirs } from "./local-directory-source";
18 18
19const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);19const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
20const textDecoder = new TextDecoder();20const textDecoder = new TextDecoder();
@@ -227,6 +227,7 @@ describe("git app sources", () => { · 206 unmodified lines
227 it.effect("rejects private git hosts under cloud posture", () =>227 it.effect("rejects private git hosts under cloud posture", () =>
228 Effect.gen(function* () {228 Effect.gen(function* () {
229 for (const url of [229 for (const url of [
230 "http://localhost/repo.git",
230 "https://localhost/repo.git",231 "https://localhost/repo.git",
231 "https://127.0.0.1/repo.git",232 "https://127.0.0.1/repo.git",
232 "https://[::ffff:127.0.0.1]/repo.git",233 "https://[::ffff:127.0.0.1]/repo.git",
@@ -239,6 +240,11 @@ describe("git app sources", () => { · 6 unmodified lines
239 const exit = yield* Effect.exit(parseGitSourceUrl(url));240 const exit = yield* Effect.exit(parseGitSourceUrl(url));
240 expect(Exit.isFailure(exit)).toBe(true);241 expect(Exit.isFailure(exit)).toBe(true);
241 }242 }
243 expect(
244 yield* parseGitSourceUrl("http://127.0.0.1:9999/repo.git", {
245 allowPrivateHosts: true,
246 }),
247 ).toBeInstanceOf(URL);
242 expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL);248 expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL);
243 }),249 }),
244 );250 );
@@ -461,6 +467,98 @@ describe("local-directory app sources", () => { · 216 unmodified lines
461 expect(Exit.isFailure(parent)).toBe(true);467 expect(Exit.isFailure(parent)).toBe(true);
462 });468 });
463 469
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
464 it("does not read symlinks that escape the source root", async () => {562 it("does not read symlinks that escape the source root", async () => {
465 const root = await mkdtemp();563 const root = await mkdtemp();
466 const outside = await mkdtemp();564 const outside = await mkdtemp();
packages/plugins/apps/src/source/local-directory-source.ts+162−4
@@ -1,11 +1,13 @@
1/* 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 */1/* 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 */
2import { createHash } from "node:crypto";2import { createHash } from "node:crypto";
3import { constants } from "node:fs";3import { constants } from "node:fs";
4import { lstat, open, readdir, realpath } from "node:fs/promises";4import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
5import { isAbsolute, resolve, sep } from "node:path";5import { homedir } from "node:os";
6import { dirname, isAbsolute, resolve, sep } from "node:path";
6 7
7import { Effect, Schema } from "effect";8import { Effect, Schema } from "effect";
8 9
10import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover";
9import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";11import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";
10import { AppSourceError, type AppSourceSnapshot } from "./app-source";12import { AppSourceError, type AppSourceSnapshot } from "./app-source";
11import {13import {
@@ -23,10 +25,35 @@ export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { · 11 unmodified lines
23 readonly skipped: readonly SourceSkippedFile[];25 readonly skipped: readonly SourceSkippedFile[];
24}26}
25 27
28export interface LocalDirectoryDirsInput {
29 readonly path?: string;
30 readonly includeHidden?: boolean;
31}
32
33export interface LocalDirectoryDirEntry {
34 readonly name: string;
35 readonly path: string;
36 readonly isSymlink: boolean;
37 readonly hasTools: boolean;
38}
39
40export interface LocalDirectorySourceShape {
41 readonly toolFiles: readonly string[];
42 readonly skipped: readonly string[];
43 readonly hasPackageJson: boolean;
44}
45
46export interface LocalDirectoryDirsResult {
47 readonly path: string;
48 readonly parent: string | null;
49 readonly dirs: readonly LocalDirectoryDirEntry[];
50 readonly source: LocalDirectorySourceShape;
51}
52
26const sha256 = (bytes: Uint8Array | string): string =>53const sha256 = (bytes: Uint8Array | string): string =>
27 createHash("sha256").update(bytes).digest("hex");54 createHash("sha256").update(bytes).digest("hex");
28 55
29const validateRoot = (path: string): Effect.Effect<string, AppSourceError> =>56export const validateLocalDirectoryPath = (path: string): Effect.Effect<string, AppSourceError> =>
30 Effect.try({57 Effect.try({
31 try: () => {58 try: () => {
32 if (!isAbsolute(path)) {59 if (!isAbsolute(path)) {
@@ -175,11 +202,65 @@ const descriptionFor = ( · 142 unmodified lines
175 return typeof description === "string" ? description : undefined;202 return typeof description === "string" ? description : undefined;
176 });203 });
177 204
205const 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
212const 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
218const 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
224const 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
178export const fetchLocalDirectoryAppSource = (259export const fetchLocalDirectoryAppSource = (
179 input: LocalDirectoryAppSourceInput,260 input: LocalDirectoryAppSourceInput,
180): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>261): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>
181 Effect.gen(function* () {262 Effect.gen(function* () {
182 const root = yield* validateRoot(input.path);263 const root = yield* validateLocalDirectoryPath(input.path);
183 const rootReal = yield* Effect.tryPromise({264 const rootReal = yield* Effect.tryPromise({
184 try: () => realpath(root),265 try: () => realpath(root),
185 catch: (cause) =>266 catch: (cause) =>
@@ -199,6 +280,83 @@ export const fetchLocalDirectoryAppSource = ( · 13 unmodified lines
199 };280 };
200 });281 });
201 282
283export 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
202export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({360export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({
203 fetch: () => fetchLocalDirectoryAppSource(input),361 fetch: () => fetchLocalDirectoryAppSource(input),
204});362});
packages/plugins/apps/src/testing/conformance.ts+97−0
@@ -41,6 +41,103 @@ export const appToolExecutorConformance = ( · 40 unmodified lines
41 }),41 }),
42 );42 );
43 43
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
44 it.effect("invokes handlers with split input and integrations", () =>141 it.effect("invokes handlers with split input and integrations", () =>
45 Effect.gen(function* () {142 Effect.gen(function* () {
46 const bundled = yield* bundle(`143 const bundled = yield* bundle(`
packages/plugins/apps/tsconfig.json+2−1
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 "types": ["bun-types", "node"],9 "types": ["bun-types", "node"],
10 "noUnusedLocals": true,10 "noUnusedLocals": true,
11 "noImplicitOverride": true,11 "noImplicitOverride": true,
12 "jsx": "react-jsx",
12 "plugins": [13 "plugins": [
13 {14 {
14 "name": "@effect/language-service",15 "name": "@effect/language-service",
@@ -18,5 +19,5 @@ · 3 unmodified lines
18 }19 }
19 ]20 ]
20 },21 },
21 "include": ["src/**/*.ts"]22 "include": ["src/**/*.ts", "src/**/*.tsx"]
22}23}
packages/react/src/components/json-schema-form.tsx+31−8
@@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines
95 return undefined;95 return undefined;
96};96};
97 97
98export 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
98const DEFAULT_BY_TYPE: Record<string, () => unknown> = {103const DEFAULT_BY_TYPE: Record<string, () => unknown> = {
99 string: () => "",104 string: () => "",
100 number: () => 0,105 number: () => 0,
@@ -105,9 +110,10 @@ const DEFAULT_BY_TYPE: Record<string, () => unknown> = { · 4 unmodified lines
105};110};
106 111
107/** Seed used when the user clicks "Add" for an optional property or array item.112/** 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. */
109export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => {114export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => {
110 const s = deepResolve(schema, root);115 const s = deepResolve(schema, root);
116 if (s.default !== undefined) return s.default;
111 if (s.const !== undefined) return s.const;117 if (s.const !== undefined) return s.const;
112 if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];118 if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
113 const t = primaryType(s);119 const t = primaryType(s);
@@ -223,8 +229,8 @@ export function JsonSchemaForm(props: { · 109 unmodified lines
223}229}
224 230
225// ---------------------------------------------------------------------------231// ---------------------------------------------------------------------------
226// ObjectFields — renders an object's properties (required first, then present232// ObjectFields — renders an object's properties (required/default-backed first,
227// optionals, then "Add" rows for absent optionals)233// then present optionals, then "Add" rows for absent optionals)
228// ---------------------------------------------------------------------------234// ---------------------------------------------------------------------------
229 235
230function ObjectFields(props: {236function ObjectFields(props: {
@@ -242,11 +248,15 @@ function ObjectFields(props: { · 11 unmodified lines
242 const requiredSet = new Set(schema.required ?? []);248 const requiredSet = new Set(schema.required ?? []);
243 const entries = Object.entries(properties);249 const entries = Object.entries(properties);
244 250
245 const present = entries.filter(251 const present = entries.filter(([key, propSchema]: [string, JsonSchema]) => {
246 ([key]: [string, JsonSchema]) => key in value || requiredSet.has(key),252 const resolved = deepResolve(propSchema, root);
247 );253 return key in value || requiredSet.has(key) || resolved.default !== undefined;
254 });
248 const addable = entries.filter(255 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,
250 );260 );
251 261
252 // No properties → render nothing (no "takes no arguments" noise); a zero-arg262 // No properties → render nothing (no "takes no arguments" noise); a zero-arg
@@ -316,6 +326,7 @@ function JsonSchemaField(props: { · 63 unmodified lines
316 const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props;326 const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props;
317 const schema = deepResolve(props.schema, root);327 const schema = deepResolve(props.schema, root);
318 const description = schemaDescription(schema);328 const description = schemaDescription(schema);
329 const fieldValue = value === undefined && schema.default !== undefined ? schema.default : value;
319 330
320 const header = (331 const header = (
321 <div className="flex items-center justify-between gap-2">332 <div className="flex items-center justify-between gap-2">
@@ -353,7 +364,7 @@ function JsonSchemaField(props: { · 31 unmodified lines
353 schema={schema}364 schema={schema}
354 root={root}365 root={root}
355 fieldId={fieldId}366 fieldId={fieldId}
356 value={value}367 value={fieldValue}
357 onChange={onChange}368 onChange={onChange}
358 depth={depth}369 depth={depth}
359 disabled={disabled}370 disabled={disabled}
@@ -407,6 +418,18 @@ function FieldControl(props: { · 47 unmodified lines
407 );418 );
408 }419 }
409 420
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
410 // enum — native select. Optional/unset gets a leading empty option.433 // enum — native select. Optional/unset gets a leading empty option.
411 if (Array.isArray(schema.enum) && schema.enum.length > 0) {434 if (Array.isArray(schema.enum) && schema.enum.length > 0) {
412 const options = schema.enum;435 const options = schema.enum;
packages/react/src/components/tool-run-panel.test.ts+18−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
9 type Owner,9 type Owner,
10} from "@executor-js/sdk/shared";10} from "@executor-js/sdk/shared";
11 11
12import { toolProxyAddress } from "./tool-run-panel";12import { shouldRenderConnectionPicker, toolProxyAddress } from "./tool-run-panel";
13 13
14const connection = (input: { readonly owner: Owner; readonly name: string }): Connection => ({14const connection = (input: { readonly owner: Owner; readonly name: string }): Connection => ({
15 owner: input.owner,15 owner: input.owner,
@@ -22,6 +22,23 @@ const connection = (input: { readonly owner: Owner; readonly name: string }): Co · 6 unmodified lines
22 expiresAt: null,22 expiresAt: null,
23});23});
24 24
25describe("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
25describe("toolProxyAddress", () => {42describe("toolProxyAddress", () => {
26 it("builds the address under the SELECTED connection's own owner — user", () => {43 it("builds the address under the SELECTED connection's own owner — user", () => {
27 expect(44 expect(
packages/react/src/components/tool-run-panel.tsx+27−22
@@ -54,6 +54,9 @@ export const toolProxyAddress = (input: { · 53 unmodified lines
54}): string =>54}): string =>
55 `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`;55 `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`;
56 56
57export const shouldRenderConnectionPicker = (connections: readonly Connection[]): boolean =>
58 connections.length > 1;
59
57type RunResult =60type RunResult =
58 | {61 | {
59 readonly kind: "completed";62 readonly kind: "completed";
@@ -261,33 +264,35 @@ export function ToolRunPanel(props: { · 201 unmodified lines
261 if (connections.length === 0) {264 if (connections.length === 0) {
262 return <p className="text-sm text-muted-foreground">Add a connection to test tools.</p>;265 return <p className="text-sm text-muted-foreground">Add a connection to test tools.</p>;
263 }266 }
267 const renderConnectionPicker = shouldRenderConnectionPicker(connections);
264 268
265 return (269 return (
266 <div className="space-y-4">270 <div className="space-y-4">
267 <CardStack>271 <CardStack>
268 <CardStackContent className="space-y-4 px-4 py-4">272 <CardStackContent className="space-y-4 px-4 py-4">
269 {/* Connection picker — which account to run against. */}273 {renderConnectionPicker && (
270 <div className="space-y-1.5">274 <div className="space-y-1.5">
271 <Label htmlFor="tool-run-connection">Connection</Label>275 <Label htmlFor="tool-run-connection">Connection</Label>
272 <NativeSelect276 <NativeSelect
273 id="tool-run-connection"277 id="tool-run-connection"
274 className="w-full"278 className="w-full"
275 value={selectedConnection ?? ""}279 value={selectedConnection ?? ""}
276 onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>280 onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
277 setSelectedConnection(e.target.value as ConnectionName)281 setSelectedConnection(e.target.value as ConnectionName)
278 }282 }
279 disabled={running}283 disabled={running}
280 >284 >
281 {connections.map((connection: Connection) => (285 {connections.map((connection: Connection) => (
282 <NativeSelectOption key={connection.name} value={connection.name}>286 <NativeSelectOption key={connection.name} value={connection.name}>
283 {connection.identityLabel || connection.name}287 {connection.identityLabel || connection.name}
284 </NativeSelectOption>288 </NativeSelectOption>
285 ))}289 ))}
286 </NativeSelect>290 </NativeSelect>
287 <p className="text-xs text-muted-foreground">291 <p className="text-xs text-muted-foreground">
288 Pick the account whose credentials you want to verify.292 Pick the account whose credentials you want to verify.
289 </p>293 </p>
290 </div>294 </div>
295 )}
291 296
292 {/* Arguments editor — dynamic form built from the input JSON Schema,297 {/* Arguments editor — dynamic form built from the input JSON Schema,
293 with a Raw JSON escape hatch. Both views share the `argsJson`298 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 @@
1export type IntegrationDetailSearchTab = "accounts" | "source" | "tools";
2export type IntegrationDetailInternalTab = "accounts" | "tools";
3
4export const integrationDetailTabForAddCompletion = (
5 pluginKey: string,
6): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined);
7
8export const integrationDetailInternalTabFromSearch = (
9 tab: IntegrationDetailSearchTab | undefined,
10): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts");
11
12export 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 @@
1import { Suspense } from "react";1import { Suspense } from "react";
2import { useAtomRefresh } from "@effect/atom-react";
2import { Link, useNavigate } from "@tanstack/react-router";3import { Link, useNavigate } from "@tanstack/react-router";
3import { useIntegrationPlugins } from "@executor-js/sdk/client";4import { useIntegrationPlugins } from "@executor-js/sdk/client";
5import { integrationsOptimisticAtom } from "../api/atoms";
4import { trackEvent } from "../api/analytics";6import { trackEvent } from "../api/analytics";
5import { useExecutorDocumentTitle } from "../lib/document-title";7import { useExecutorDocumentTitle } from "../lib/document-title";
8import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs";
6 9
7// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
8// Page11// Page
@@ -18,6 +21,7 @@ export function AddIntegrationPage(props: { · 9 unmodified lines
18 const { pluginKey, url, preset, namespace } = props;21 const { pluginKey, url, preset, namespace } = props;
19 const navigate = useNavigate();22 const navigate = useNavigate();
20 const integrationPlugins = useIntegrationPlugins();23 const integrationPlugins = useIntegrationPlugins();
24 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
21 25
22 const plugin = integrationPlugins.find((p) => p.key === pluginKey);26 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
23 27
@@ -59,9 +63,15 @@ export function AddIntegrationPage(props: { · 35 unmodified lines
59 plugin_key: pluginKey,63 plugin_key: pluginKey,
60 ...(slug ? { integration_slug: slug } : {}),64 ...(slug ? { integration_slug: slug } : {}),
61 });65 });
66 refreshIntegrations();
67 const tab = integrationDetailTabForAddCompletion(pluginKey);
62 void navigate(68 void navigate(
63 slug69 slug
64 ? { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: slug } }70 ? {
71 to: "/{-$orgSlug}/integrations/$namespace",
72 params: { namespace: slug },
73 search: tab ? { tab } : {},
74 }
65 : { to: "/{-$orgSlug}" },75 : { to: "/{-$orgSlug}" },
66 );76 );
67 }}77 }}
packages/react/src/pages/integration-detail.tsx+41−5
@@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines
36import { useExecutorDocumentTitle } from "../lib/document-title";36import { useExecutorDocumentTitle } from "../lib/document-title";
37import { ErrorState } from "../components/error-state";37import { ErrorState } from "../components/error-state";
38import { isAsyncResultLoading } from "../lib/async-result";38import { isAsyncResultLoading } from "../lib/async-result";
39import {
40 integrationDetailInternalTabFromSearch,
41 integrationDetailSearchTabForInternal,
42 type IntegrationDetailInternalTab,
43 type IntegrationDetailSearchTab,
44} from "../lib/integration-detail-tabs";
39 45
40// v2: the route's `namespace` param is the integration slug. Tools belong to46// v2: the route's `namespace` param is the integration slug. Tools belong to
41// the integration's per-owner connections; a tool's policy id is47// the integration's per-owner connections; a tool's policy id is
@@ -52,7 +58,10 @@ type ToolRow = { · 10 unmodified lines
52 readonly static?: boolean;58 readonly static?: boolean;
53};59};
54 60
55export function IntegrationDetailPage(props: { namespace: string }) {61export function IntegrationDetailPage(props: {
62 namespace: string;
63 tab?: IntegrationDetailSearchTab;
64}) {
56 const { namespace } = props;65 const { namespace } = props;
57 const slug = IntegrationSlug.make(namespace);66 const slug = IntegrationSlug.make(namespace);
58 const integrationPlugins = useIntegrationPlugins();67 const integrationPlugins = useIntegrationPlugins();
@@ -89,7 +98,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 30 unmodified lines
89 const [deleting, setDeleting] = useState(false);98 const [deleting, setDeleting] = useState(false);
90 const [refreshing, setRefreshing] = useState(false);99 const [refreshing, setRefreshing] = useState(false);
91 const [editSheetOpen, setEditSheetOpen] = useState(false);100 const [editSheetOpen, setEditSheetOpen] = useState(false);
92 const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");101 const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() =>
102 integrationDetailInternalTabFromSearch(props.tab),
103 );
93 const [manualAccountHandoff, setManualAccountHandoff] =104 const [manualAccountHandoff, setManualAccountHandoff] =
94 useState<IntegrationAccountHandoff | null>(null);105 useState<IntegrationAccountHandoff | null>(null);
95 const [locationSearch] = useState(() =>106 const [locationSearch] = useState(() =>
@@ -101,9 +112,14 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines
101 setEditSheetOpen(false);112 setEditSheetOpen(false);
102 }, [namespace]);113 }, [namespace]);
103 114
115 useEffect(() => {
116 setActiveTab(integrationDetailInternalTabFromSearch(props.tab));
117 }, [namespace, props.tab]);
118
104 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;119 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
105 useExecutorDocumentTitle(integrationData?.name || namespace);120 useExecutorDocumentTitle(integrationData?.name || namespace);
106 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";121 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
122 const isAppsIntegration = integrationData?.kind === "apps";
107 const currentTab = isBuiltInIntegration ? "tools" : activeTab;123 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
108 const canRefresh = integrationData?.canRefresh ?? false;124 const canRefresh = integrationData?.canRefresh ?? false;
109 const canRemove = integrationData?.canRemove ?? false;125 const canRemove = integrationData?.canRemove ?? false;
@@ -153,6 +169,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines
153 // Find the plugin edit component based on integration kind169 // Find the plugin edit component based on integration kind
154 const editPlugin = useMemo(() => {170 const editPlugin = useMemo(() => {
155 if (!integrationData) return null;171 if (!integrationData) return null;
172 if (integrationData.kind === "apps") {
173 return integrationPlugins.find((p) => p.key === "apps") ?? null;
174 }
156 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;175 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
157 }, [integrationData, integrationPlugins]);176 }, [integrationData, integrationPlugins]);
158 177
@@ -347,6 +366,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 188 unmodified lines
347 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });366 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });
348 };367 };
349 368
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
350 return (381 return (
351 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">382 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
352 {/* Header bar */}383 {/* Header bar */}
@@ -363,7 +394,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 10 unmodified lines
363 </div>394 </div>
364 395
365 <div className="flex shrink-0 items-center gap-2">396 <div className="flex shrink-0 items-center gap-2">
366 {!confirmDelete && !isBuiltInIntegration && integrationData && (397 {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && (
367 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>398 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
368 Edit399 Edit
369 </Button>400 </Button>
@@ -381,6 +412,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines
381 )}412 )}
382 413
383 {canRemove &&414 {canRemove &&
415 !isAppsIntegration &&
384 (confirmDelete ? (416 (confirmDelete ? (
385 <div className="flex items-center gap-2">417 <div className="flex items-center gap-2">
386 <Button418 <Button
@@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines
415 447
416 <Tabs448 <Tabs
417 value={currentTab}449 value={currentTab}
418 onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}450 onValueChange={handleTabChange}
419 className="min-h-0 flex-1 gap-0 overflow-hidden"451 className="min-h-0 flex-1 gap-0 overflow-hidden"
420 >452 >
421 <div className="shrink-0 border-b border-border/60 px-4 py-2">453 <div className="shrink-0 border-b border-border/60 px-4 py-2">
422 <TabsList variant="line">454 <TabsList variant="line">
423 {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}455 {!isBuiltInIntegration && (
456 <TabsTrigger value="accounts">
457 {isAppsIntegration ? "Source" : "Accounts"}
458 </TabsTrigger>
459 )}
424 <TabsTrigger value="tools">Tools</TabsTrigger>460 <TabsTrigger value="tools">Tools</TabsTrigger>
425 </TabsList>461 </TabsList>
426 </div>462 </div>
packages/react/src/pages/integrations.tsx+52−0
@@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines
49import { isAsyncResultLoading } from "../lib/async-result";49import { isAsyncResultLoading } from "../lib/async-result";
50 50
51const KIND_TO_PLUGIN_KEY: Record<string, string> = {51const KIND_TO_PLUGIN_KEY: Record<string, string> = {
52 apps: "apps",
52 openapi: "openapi",53 openapi: "openapi",
53 mcp: "mcp",54 mcp: "mcp",
54 graphql: "graphql",55 graphql: "graphql",
@@ -66,6 +67,41 @@ const bestDetection = ( · 11 unmodified lines
66): IntegrationDetectionResult | undefined =>67): IntegrationDetectionResult | undefined =>
67 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];68 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
68 69
70// Hosts where a two-segment path (owner/repo) is unambiguously a git remote.
71const 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.
77const 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
69// ---------------------------------------------------------------------------105// ---------------------------------------------------------------------------
70// Page106// Page
71// ---------------------------------------------------------------------------107// ---------------------------------------------------------------------------
@@ -176,6 +212,22 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 104 unmodified lines
176 if (!trimmed) return;212 if (!trimmed) return;
177 setDetecting(true);213 setDetecting(true);
178 setError(null);214 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 }
179 // Detection is read-only — it inspects a URL and returns candidates without231 // Detection is read-only — it inspects a URL and returns candidates without
180 // mutating the catalog, so it invalidates nothing.232 // mutating the catalog, so it invalidates nothing.
181 const exit = await doDetect({233 const exit = await doDetect({
packages/react/src/routes/integrations.$namespace.tsx+16−1
@@ -1,10 +1,25 @@
1import { Schema } from "effect";
1import { createFileRoute } from "@tanstack/react-router";2import { createFileRoute } from "@tanstack/react-router";
2 3
3import { IntegrationDetailPage } from "../pages/integration-detail";4import { IntegrationDetailPage } from "../pages/integration-detail";
5import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
6
7const SearchParams = Schema.toStandardSchemaV1(
8 Schema.Struct({
9 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
10 }),
11);
4 12
5export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({13export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
14 validateSearch: SearchParams,
6 component: () => {15 component: () => {
7 const { namespace } = Route.useParams();16 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 );
9 },24 },
10});25});