History

Merge pull request #720 from RhysSullivan/codex/runtime-ui-boundaries

Tighten runtime and UI boundaries

Rhys Sullivan committed 6 months ago

Commit bdc6c792 parents 320821a 5293f8162 files · +834 −287Browse files

Unified · Split

62 files
  1. apps/cloud/src/api.test.ts +21 −2
  2. apps/cloud/src/api/error-response.ts +2 −8
  3. apps/cloud/src/api/slack.ts +10 −3
  4. apps/cloud/src/mcp.ts +25 −3
  5. apps/cloud/src/sentry-tunnel.ts +82 −0
  6. apps/cloud/src/start.test.ts +21 −0
  7. apps/cloud/src/start.ts +2 −53
  8. apps/cloud/wrangler.jsonc +1 −1
  9. apps/desktop/src/main.ts +29 −11
  10. packages/core/api/src/handlers/tools.ts +4 −5
  11. packages/core/execution/src/description.test.ts +5 −4
  12. packages/core/execution/src/description.ts +5 −3
  13. packages/core/sdk/src/client.ts +9 −3
  14. packages/core/sdk/src/oxlint-plugin-executor.test.ts +73 −0
  15. packages/core/sdk/src/secrets.ts +3 −2
  16. packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +11 −0
  17. packages/kernel/runtime-deno-subprocess/src/index.test.ts +20 −0
  18. packages/kernel/runtime-deno-subprocess/src/index.ts +16 −5
  19. packages/kernel/runtime-dynamic-worker/src/executor.ts +19 −9
  20. packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +80 −0
  21. packages/kernel/runtime-dynamic-worker/src/module-template.ts +11 −10
  22. packages/kernel/runtime-quickjs/README.md +1 −1
  23. packages/kernel/runtime-quickjs/src/index.test.ts +16 −0
  24. packages/kernel/runtime-quickjs/src/index.ts +6 −7
  25. packages/plugins/google-discovery/package.json +6 −0
  26. packages/plugins/google-discovery/src/react/client.ts +1 −1
  27. packages/plugins/google-discovery/tsup.config.ts +1 −0
  28. packages/plugins/graphql/package.json +6 −0
  29. packages/plugins/graphql/src/react/client.ts +1 −1
  30. packages/plugins/graphql/tsup.config.ts +1 −0
  31. packages/plugins/keychain/src/index.ts +5 −3
  32. packages/plugins/mcp/package.json +7 −0
  33. packages/plugins/mcp/src/react/client.ts +1 −1
  34. packages/plugins/mcp/tsup.config.ts +1 −0
  35. packages/plugins/onepassword/package.json +6 −0
  36. packages/plugins/onepassword/src/react/client.ts +1 −1
  37. packages/plugins/onepassword/src/sdk/plugin.ts +20 −4
  38. packages/plugins/onepassword/src/sdk/service.ts +23 −15
  39. packages/plugins/onepassword/tsup.config.ts +1 −0
  40. packages/plugins/openapi/package.json +6 −0
  41. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +3 −24
  42. packages/plugins/openapi/src/react/client.ts +1 −1
  43. packages/plugins/openapi/src/sdk/openapi-utils.ts +29 −1
  44. packages/plugins/openapi/tsup.config.ts +1 −0
  45. packages/react/src/api/client.tsx +4 −2
  46. packages/react/src/components/chart.test.ts +18 −0
  47. packages/react/src/components/chart.tsx +16 −1
  48. packages/react/src/components/markdown.test.ts +18 −0
  49. packages/react/src/components/markdown.tsx +37 −2
  50. packages/react/src/components/mcp-install-card.test.ts +32 −0
  51. packages/react/src/components/mcp-install-card.tsx +29 −10
  52. packages/react/src/components/schema-explorer.test.ts +16 −0
  53. packages/react/src/components/schema-explorer.tsx +14 −3
  54. packages/react/src/components/source-favicon.test.tsx +13 −0
  55. packages/react/src/components/source-favicon.tsx +6 −34
  56. packages/react/src/plugins/secret-credential-scope.ts +9 −0
  57. packages/react/src/plugins/secret-form.tsx +5 −4
  58. packages/react/src/plugins/secret-header-auth.test.ts +10 −1
  59. packages/react/src/plugins/secret-header-auth.tsx +2 −5
  60. packages/react/src/plugins/secret-input.ts +4 −0
  61. scripts/install.sh +4 −2
  62. scripts/oxlint-plugin-executor/rules/no-conditional-tests.js +4 −41
apps/cloud/src/api.test.ts+21−2
@@ -13,7 +13,8 @@ import { · 12 unmodified lines
1313 HttpServerResponse,
1414 } from "effect/unstable/http";
1515 import { expect, layer } from "@effect/vitest";
16−import { Effect, Layer, Schema } from "effect";
16+import { Cause, Effect, Layer, Schema } from "effect";
17+import { toErrorServerResponse } from "./api/error-response";
1718
1819 const SourceResponse = Schema.Struct({ source: Schema.String });
1920
@@ -72,7 +73,7 @@ const ProtectedHandlers = HttpApiBuilder.group(ProtectedTestApi, "protected", (h · 52 unmodified lines
7273 // the full WorkOS / executor stack.
7374 // ---------------------------------------------------------------------------
7475
75−type ProtectedMode = "ok" | "none" | "error" | "bad-status";
76+type ProtectedMode = "ok" | "none" | "error" | "bad-status" | "defect";
7677 const testState: { mode: ProtectedMode } = { mode: "ok" };
7778 const resetState = () => {
7879 testState.mode = "ok";
@@ -100,6 +101,9 @@ const TestProtectedGate = HttpRouter.middleware()((httpEffect) => · 21 unmodified lines
100101 HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }),
101102 );
102103 }
104+ if (testState.mode === "defect") {
105+ return Effect.succeed(toErrorServerResponse(Cause.die("secret-bearing failure detail")));
106+ }
103107 return httpEffect;
104108 }),
105109 ).layer;
@@ -249,4 +253,19 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 143 unmodified lines
249253 expect(body).toEqual({ error: "boom" });
250254 }),
251255 );
256+
257+ it.effect("maps protected defects to the generic API error body", () =>
258+ Effect.gen(function* () {
259+ resetState();
260+ testState.mode = "defect";
261+
262+ const response = yield* HttpClient.get(`${TEST_BASE_URL}/scope`);
263+ expect(response.status).toBe(500);
264+ const body = yield* response.json;
265+ expect(body).toEqual({
266+ error: "Internal server error",
267+ code: "internal_server_error",
268+ });
269+ }),
270+ );
252271 });
apps/cloud/src/api/error-response.ts+2−8
@@ -53,19 +53,13 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 52 unmodified lines
5353
5454 export const toErrorResponse = (error: unknown): Response => {
5555 const mapped = toHttpResponseError(error);
56− if (mapped.status >= 500) captureCause(error);
56+ if (mapped.status >= 500) captureCause(mapped);
5757 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });
5858 };
5959
6060 export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
6161 const mapped = toHttpResponseError(error);
62− if (mapped.status >= 500) {
63− console.error(
64− "[api] toErrorServerResponse error:",
65− Cause.isCause(error) ? Cause.pretty(error) : error,
66− );
67− captureCause(error);
68− }
62+ if (mapped.status >= 500) captureCause(mapped);
6963 return HttpServerResponse.jsonUnsafe(
7064 { error: mapped.message, code: mapped.code },
7165 { status: mapped.status },
apps/cloud/src/api/slack.ts+10−3
@@ -7,8 +7,17 @@ import { HttpResponseError, isServerError, toErrorServerResponse } from "./error · 6 unmodified lines
77
88 const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
99
10+const rateLimitKey = (request: Request, email: string): string => {
11+ const ip =
12+ request.headers.get("cf-connecting-ip") ??
13+ request.headers.get("x-forwarded-for")?.split(",", 1)[0]?.trim() ??
14+ "unknown";
15+ return `${ip}:${email.toLowerCase()}`;
16+};
17+
1018 const handler = Effect.gen(function* () {
1119 const request = yield* HttpServerRequest.HttpServerRequest;
20+ const webRequest = yield* HttpServerRequest.toWeb(request);
1221
1322 if (request.method !== "POST") {
1423 return yield* new HttpResponseError({
@@ -45,10 +54,8 @@ const handler = Effect.gen(function* () { · 30 unmodified lines
4554 });
4655 }
4756
48− // Global rate limit — single shared bucket keyed at "global". Per-IP gating
49− // belongs at the edge (Cloudflare Rules).
5057 const limit = yield* Effect.tryPromise({
51− try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }),
58+ try: () => env.SLACK_INVITE_LIMITER.limit({ key: rateLimitKey(webRequest, email) }),
5259 catch: (cause) => ({ success: false as const, fetchError: cause }),
5360 });
5461 if (!limit.success) {
apps/cloud/src/mcp.ts+25−3
@@ -72,6 +72,7 @@ const MCP_PATH = "/mcp"; · 71 unmodified lines
7272 const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
7373 const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
7474 const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
75+export const MAX_MCP_JSON_RPC_BODY_BYTES = 1_000_000;
7576
7677 type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
7778
@@ -107,6 +108,18 @@ const corsPreflight = HttpServerResponse.empty({ · 29 unmodified lines
107108 headers: CORS_PREFLIGHT_HEADERS,
108109 });
109110
111+const contentLength = (request: Request): number | null => {
112+ const value = request.headers.get("content-length");
113+ if (!value) return null;
114+ const parsed = Number(value);
115+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
116+};
117+
118+const isBodyTooLarge = (request: Request, maxBytes: number): boolean => {
119+ const length = contentLength(request);
120+ return length !== null && length > maxBytes;
121+};
122+
110123 // ---------------------------------------------------------------------------
111124 // Auth
112125 // ---------------------------------------------------------------------------
@@ -302,7 +315,11 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 189 unmodified lines
302315 try: () => request.clone().text(),
303316 catch: () => undefined,
304317 }).pipe(
305− Effect.map((text) => (text ? decodeJsonRpcEnvelopeString(text) : Option.none())),
318+ Effect.map((text) =>
319+ text && text.length <= MAX_MCP_JSON_RPC_BODY_BYTES
320+ ? decodeJsonRpcEnvelopeString(text)
321+ : Option.none(),
322+ ),
306323 Effect.catchCause(() => Effect.succeed(Option.none())),
307324 Effect.withSpan("mcp.request.read_json_rpc"),
308325 );
@@ -669,23 +686,28 @@ export const mcpApp: Effect.Effect< · 360 unmodified lines
669686 if (Result.isFailure(authResult)) {
670687 yield* annotateMcpRequest(request, {
671688 token: null,
672− parseBody: request.method === "POST",
689+ parseBody: false,
673690 });
674691 return yield* authTemporarilyUnavailable(authResult.failure);
675692 }
676693 const authValue = authResult.success;
694+ const bodyTooLarge =
695+ request.method === "POST" && isBodyTooLarge(request, MAX_MCP_JSON_RPC_BODY_BYTES);
677696
678697 // Annotate before dispatch so even 401s show up with what we know. Only
679698 // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE
680699 // don't carry one.
681700 yield* annotateMcpRequest(request, {
682701 token: isMcpAuthorized(authValue) ? authValue.token : null,
683− parseBody: request.method === "POST",
702+ parseBody: request.method === "POST" && isMcpAuthorized(authValue) && !bodyTooLarge,
684703 });
685704
686705 if (isMcpUnauthorized(authValue)) {
687706 return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);
688707 }
708+ if (bodyTooLarge) {
709+ return jsonRpcError(413, -32000, "Request body too large");
710+ }
689711 const token = authValue.token;
690712 switch (request.method) {
691713 case "POST":
apps/cloud/src/sentry-tunnel.ts+82−0
@@ -0,0 +1,82 @@
1+import { Data, Effect, Schema } from "effect";
2+
3+export const MAX_SENTRY_ENVELOPE_BODY_BYTES = 200_000;
4+
5+class SentryTunnelError extends Data.TaggedError("SentryTunnelError")<{
6+ readonly cause?: unknown;
7+}> {}
8+
9+const SentryEnvelopeHeader = Schema.Struct({
10+ dsn: Schema.optional(Schema.String),
11+});
12+
13+const decodeSentryEnvelopeHeader = Schema.decodeUnknownEffect(
14+ Schema.fromJsonString(SentryEnvelopeHeader),
15+);
16+
17+const badSentryEnvelopeResponse = () => new Response("bad envelope", { status: 400 });
18+
19+const requestContentLength = (request: Request): number | null => {
20+ const value = request.headers.get("content-length");
21+ if (!value) return null;
22+ const parsed = Number(value);
23+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
24+};
25+
26+const readLimitedRequestText = (request: Request, maxBytes: number) =>
27+ Effect.gen(function* () {
28+ const length = requestContentLength(request);
29+ if (length !== null && length > maxBytes) {
30+ return yield* new SentryTunnelError({ cause: "payload_too_large" });
31+ }
32+ const text = yield* Effect.tryPromise({
33+ try: () => request.text(),
34+ catch: (cause) => new SentryTunnelError({ cause }),
35+ });
36+ if (text.length > maxBytes) {
37+ return yield* new SentryTunnelError({ cause: "payload_too_large" });
38+ }
39+ return text;
40+ });
41+
42+export const handleSentryTunnelRequest = (request: Request, configuredDsn: string) =>
43+ Effect.gen(function* () {
44+ const envelope = yield* readLimitedRequestText(request, MAX_SENTRY_ENVELOPE_BODY_BYTES).pipe(
45+ Effect.catchTag("SentryTunnelError", (error) =>
46+ error.cause === "payload_too_large"
47+ ? Effect.succeed(new Response("payload too large", { status: 413 }))
48+ : Effect.fail(error),
49+ ),
50+ );
51+ if (envelope instanceof Response) return envelope;
52+ const firstLine = envelope.slice(0, envelope.indexOf("\n"));
53+ const header = yield* decodeSentryEnvelopeHeader(firstLine).pipe(
54+ Effect.mapError((cause) => new SentryTunnelError({ cause })),
55+ );
56+ const dsn = header.dsn;
57+ if (!dsn) return new Response("missing dsn", { status: 400 });
58+
59+ const envelopeDsn = yield* Effect.try({
60+ try: () => new URL(dsn),
61+ catch: (cause) => new SentryTunnelError({ cause }),
62+ });
63+ const ourDsn = yield* Effect.try({
64+ try: () => new URL(configuredDsn),
65+ catch: (cause) => new SentryTunnelError({ cause }),
66+ });
67+ if (envelopeDsn.host !== ourDsn.host || envelopeDsn.pathname !== ourDsn.pathname) {
68+ return new Response("dsn mismatch", { status: 400 });
69+ }
70+
71+ const projectId = envelopeDsn.pathname.replace(/^\//, "");
72+ const ingestUrl = `https://${envelopeDsn.host}/api/${projectId}/envelope/`;
73+ return yield* Effect.tryPromise({
74+ try: () =>
75+ fetch(ingestUrl, {
76+ method: "POST",
77+ body: envelope,
78+ headers: { "Content-Type": "application/x-sentry-envelope" },
79+ }),
80+ catch: (cause) => new SentryTunnelError({ cause }),
81+ });
82+ }).pipe(Effect.catch(() => Effect.succeed(badSentryEnvelopeResponse())));
apps/cloud/src/start.test.ts+21−0
@@ -0,0 +1,21 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import { handleSentryTunnelRequest, MAX_SENTRY_ENVELOPE_BODY_BYTES } from "./sentry-tunnel";
5+
6+describe("handleSentryTunnelRequest", () => {
7+ it.effect("rejects oversized Sentry tunnel envelopes before forwarding", () =>
8+ Effect.gen(function* () {
9+ const response = yield* handleSentryTunnelRequest(
10+ new Request("https://executor.sh/api/sentry-tunnel", {
11+ method: "POST",
12+ body: "x".repeat(MAX_SENTRY_ENVELOPE_BODY_BYTES + 1),
13+ }),
14+ "https://public@example.sentry.io/123",
15+ );
16+
17+ expect(response.status).toBe(413);
18+ expect(yield* Effect.promise(() => response.text())).toBe("payload too large");
19+ }),
20+ );
21+});
apps/cloud/src/start.ts+2−53
@@ -1,8 +1,9 @@
11 import { env } from "cloudflare:workers";
22 import { createMiddleware, createStart } from "@tanstack/react-start";
3−import { Data, Effect, Schema } from "effect";
3+import { Effect } from "effect";
44 import { handleApiRequest } from "./api";
55 import { mcpFetch } from "./mcp";
6+import { handleSentryTunnelRequest } from "./sentry-tunnel";
67
78 // ---------------------------------------------------------------------------
89 // Marketing routes — proxied to the marketing worker via service binding
@@ -85,58 +86,6 @@ const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( · 76 unmodified lines
8586 },
8687 );
8788
88−class SentryTunnelError extends Data.TaggedError("SentryTunnelError")<{
89− readonly cause?: unknown;
90−}> {}
91−
92−const SentryEnvelopeHeader = Schema.Struct({
93− dsn: Schema.optional(Schema.String),
94−});
95−
96−const decodeSentryEnvelopeHeader = Schema.decodeUnknownEffect(
97− Schema.fromJsonString(SentryEnvelopeHeader),
98−);
99−
100−const badSentryEnvelopeResponse = () => new Response("bad envelope", { status: 400 });
101−
102−const handleSentryTunnelRequest = (request: Request, configuredDsn: string) =>
103− Effect.gen(function* () {
104− const envelope = yield* Effect.tryPromise({
105− try: () => request.text(),
106− catch: (cause) => new SentryTunnelError({ cause }),
107− });
108− const firstLine = envelope.slice(0, envelope.indexOf("\n"));
109− const header = yield* decodeSentryEnvelopeHeader(firstLine).pipe(
110− Effect.mapError((cause) => new SentryTunnelError({ cause })),
111− );
112− const dsn = header.dsn;
113− if (!dsn) return new Response("missing dsn", { status: 400 });
114−
115− const envelopeDsn = yield* Effect.try({
116− try: () => new URL(dsn),
117− catch: (cause) => new SentryTunnelError({ cause }),
118− });
119− const ourDsn = yield* Effect.try({
120− try: () => new URL(configuredDsn),
121− catch: (cause) => new SentryTunnelError({ cause }),
122− });
123− if (envelopeDsn.host !== ourDsn.host || envelopeDsn.pathname !== ourDsn.pathname) {
124− return new Response("dsn mismatch", { status: 400 });
125− }
126−
127− const projectId = envelopeDsn.pathname.replace(/^\//, "");
128− const ingestUrl = `https://${envelopeDsn.host}/api/${projectId}/envelope/`;
129− return yield* Effect.tryPromise({
130− try: () =>
131− fetch(ingestUrl, {
132− method: "POST",
133− body: envelope,
134− headers: { "Content-Type": "application/x-sentry-envelope" },
135− }),
136− catch: (cause) => new SentryTunnelError({ cause }),
137− });
138− }).pipe(Effect.catch(() => Effect.succeed(badSentryEnvelopeResponse())));
139−
14089 // ---------------------------------------------------------------------------
14190 // PostHog reverse proxy — the browser SDK targets a build-randomized
14291 // first-party path and we forward to PostHog's ingest + asset hosts. Keeps
apps/cloud/wrangler.jsonc+1−1
@@ -11,7 +11,7 @@ · 10 unmodified lines
1111 },
1212 ],
1313 "limits": {
14− "cpu_ms": 300000,
14+ "cpu_ms": 30000,
1515 },
1616 "observability": {
1717 "enabled": true,
apps/desktop/src/main.ts+29−11
@@ -34,15 +34,23 @@ const SETTINGS_PATH = join(SETTINGS_DIR, "desktop-settings.json"); · 33 unmodified lines
3434 const CLI_BIN_DIR = join(SETTINGS_DIR, "bin");
3535 const CLI_BIN_PATH = join(CLI_BIN_DIR, process.platform === "win32" ? "executor.exe" : "executor");
3636
37+const htmlEscape = (value: string): string =>
38+ value
39+ .replace(/&/g, "&amp;")
40+ .replace(/</g, "&lt;")
41+ .replace(/>/g, "&gt;")
42+ .replace(/"/g, "&quot;")
43+ .replace(/'/g, "&#39;");
44+
3745 // ---------------------------------------------------------------------------
3846 // CLI install — copy sidecar to ~/.executor/bin and patch shell PATH
3947 // ---------------------------------------------------------------------------
4048
41−const getInstalledCliVersion = (): string | null => {
42− if (!existsSync(CLI_BIN_PATH)) return null;
49+const getCliVersion = (path: string): string | null => {
50+ if (!existsSync(path)) return null;
4351 try {
4452 const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
45− return execFileSync(CLI_BIN_PATH, ["--version"], {
53+ return execFileSync(path, ["--version"], {
4654 timeout: 5000,
4755 encoding: "utf-8",
4856 }).trim();
@@ -51,6 +59,8 @@ const getInstalledCliVersion = (): string | null => { · 2 unmodified lines
5159 }
5260 };
5361
62+const getInstalledCliVersion = (): string | null => getCliVersion(CLI_BIN_PATH);
63+
5464 const installCli = (): void => {
5565 if (isDev) return;
5666
@@ -58,7 +68,7 @@ const installCli = (): void => { · 1 unmodified line
5868 if (!existsSync(sidecar)) return;
5969
6070 // Check if installed version is already same or newer
61− const appVersion = app.getVersion();
71+ const bundledVersion = getCliVersion(sidecar) ?? app.getVersion();
6272 const installedVersion = getInstalledCliVersion();
6373 if (installedVersion) {
6474 const parse = (v: string) =>
@@ -70,7 +80,7 @@ const installCli = (): void => { · 5 unmodified lines
7080 return isNaN(n) ? 0 : n;
7181 });
7282 const installed = parse(installedVersion);
73− const bundled = parse(appVersion);
83+ const bundled = parse(bundledVersion);
7484 const len = Math.max(installed.length, bundled.length);
7585 let cmp = 0;
7686 for (let i = 0; i < len && cmp === 0; i++) {
@@ -86,7 +96,7 @@ const installCli = (): void => { · 9 unmodified lines
8696 chmodSync(CLI_BIN_PATH, 0o755);
8797 } catch {}
8898 console.log(
89− `Installed executor CLI ${appVersion} to ${CLI_BIN_PATH}` +
99+ `Installed executor CLI ${bundledVersion} to ${CLI_BIN_PATH}` +
90100 (installedVersion ? ` (was ${installedVersion})` : ""),
91101 );
92102
@@ -543,7 +553,7 @@ const loadingHTML = (scopePath: string): string => { · 450 unmodified lines
543553 const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
544554 const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)";
545555 const barTrack = isDark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";
546− const folder = basename(scopePath.replace(/[/\\]+$/, "")) || scopePath;
556+ const folder = htmlEscape(basename(scopePath.replace(/[/\\]+$/, "")) || scopePath);
547557
548558 return `<!DOCTYPE html>
549559 <html>
@@ -683,7 +693,7 @@ const errorHTML = (message: string): string => `<!DOCTYPE html> · 133 unmodified lines
683693 <body>
684694 <div class="container">
685695 <h2>Failed to start server</h2>
686− <pre>${message.replace(/</g, "&lt;")}</pre>
696+ <pre>${htmlEscape(message)}</pre>
687697 <button onclick="window.electronAPI.selectScope()">Choose Different Folder</button>
688698 </div>
689699 </body>
@@ -704,9 +714,9 @@ const welcomeHTML = (): string => { · 14 unmodified lines
704714 const recentItems = settings.recentScopes
705715 .map(
706716 (s) =>
707− `<button class="scope-btn" onclick="window.electronAPI.switchScope('${s.replace(/'/g, "\\'")}')">
708− <span class="name">${basename(s)}</span>
709− <span class="path">${s}</span>
717+ `<button class="scope-btn" data-scope="${htmlEscape(s)}">
718+ <span class="name">${htmlEscape(basename(s))}</span>
719+ <span class="path">${htmlEscape(s)}</span>
710720 </button>`,
711721 )
712722 .join("");
@@ -765,6 +775,14 @@ const welcomeHTML = (): string => { · 52 unmodified lines
765775 : ""
766776 }
767777 </div>
778+ <script>
779+ for (const button of document.querySelectorAll(".scope-btn")) {
780+ button.addEventListener("click", () => {
781+ const scope = button.getAttribute("data-scope");
782+ if (scope) window.electronAPI.switchScope(scope);
783+ });
784+ }
785+ </script>
768786 </body>
769787 </html>`;
770788 };
packages/core/api/src/handlers/tools.ts+4−5
@@ -12,12 +12,11 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 11 unmodified lines
1212 capture(
1313 Effect.gen(function* () {
1414 const executor = yield* ExecutorService;
15− // Tools page is a management view — include policy-blocked tools
16− // (so users can unblock them) and load annotations so the row can
17− // show the plugin's default approval state when no user rule
18− // matches. Mirrors the per-source `sources.tools` handler.
15+ // Keep the all-tools view bounded to metadata already available
16+ // from discovery. Per-source detail loads annotations for the
17+ // smaller source-local management view.
1918 const tools = yield* executor.tools.list({
20− includeAnnotations: true,
19+ includeAnnotations: false,
2120 includeBlocked: true,
2221 });
2322 return tools.map((t) => ({
packages/core/execution/src/description.test.ts+5−4
@@ -70,10 +70,11 @@ describe("buildExecuteDescription", () => { · 69 unmodified lines
7070 expect(description).toContain("Execute TypeScript in a sandboxed runtime");
7171 // The namespaces section header.
7272 expect(description).toContain("## Available namespaces");
73− // Each source renders with its ACTUAL id (not pluginId / name / UUID).
74− expect(description).toContain("`github` — GitHub");
75− expect(description).toContain("`slack` — Slack Workspace");
76− // And the plugin ids must NOT leak into the namespace list.
73+ // Each source renders with its ACTUAL id, without display labels or plugin ids.
74+ expect(description).toContain("- `github`");
75+ expect(description).toContain("- `slack`");
76+ expect(description).not.toContain("GitHub");
77+ expect(description).not.toContain("Slack Workspace");
7778 expect(description).not.toContain("`github-plugin`");
7879 expect(description).not.toContain("`slack-plugin`");
7980
packages/core/execution/src/description.ts+5−3
@@ -63,10 +63,12 @@ const formatDescription = (sources: readonly Source[]): string => { · 62 unmodified lines
6363 lines.push("");
6464 lines.push("## Available namespaces");
6565 lines.push("");
66− const sorted = [...sources].sort((a, b) => a.id.localeCompare(b.id));
66+ const sorted = [...sources].sort((a, b) => a.id.localeCompare(b.id)).slice(0, 50);
6767 for (const source of sorted) {
68− const label = source.name;
69− lines.push(`- \`${source.id}\`${label !== source.id ? ` — ${label}` : ""}`);
68+ lines.push(`- \`${source.id}\``);
69+ }
70+ if (sources.length > sorted.length) {
71+ lines.push(`- ... ${sources.length - sorted.length} more`);
7072 }
7173 }
7274
packages/core/sdk/src/client.ts+9−3
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
2020 } from "react";
2121 import { HttpApi } from "effect/unstable/httpapi";
2222 import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
23−import { FetchHttpClient } from "effect/unstable/http";
23+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
2424 import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2525
2626 // ---------------------------------------------------------------------------
@@ -191,7 +191,7 @@ export interface CreatePluginAtomClientOptions { · 164 unmodified lines
191191 /** Override the base URL. Defaults to `/api` (host strips this prefix
192192 * when forwarding to the Effect handler) — same convention as the
193193 * core `ExecutorApiClient`. */
194− readonly baseUrl?: string;
194+ readonly baseUrl?: string | (() => string);
195195 }
196196
197197 /**
@@ -217,7 +217,13 @@ export const createPluginAtomClient = < · 19 unmodified lines
217217 return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, {
218218 api: bundle,
219219 httpClient: FetchHttpClient.layer,
220− baseUrl,
220+ ...(typeof baseUrl === "function"
221+ ? {
222+ transformClient: HttpClient.mapRequest((request) =>
223+ HttpClientRequest.prependUrl(request, baseUrl()),
224+ ),
225+ }
226+ : { baseUrl }),
221227 });
222228 };
223229
packages/core/sdk/src/oxlint-plugin-executor.test.ts+73−0
@@ -0,0 +1,73 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { spawnSync } from "node:child_process";
3+import { mkdir, rm, writeFile } from "node:fs/promises";
4+import { join, resolve } from "node:path";
5+
6+const repoRoot = resolve(import.meta.dirname, "../../../..");
7+
8+const runOxlintOn = async (name: string, source: string) => {
9+ const dir = join(repoRoot, ".local", "oxlint-plugin-executor-tests");
10+ await mkdir(dir, { recursive: true });
11+ const file = join(dir, name);
12+ await writeFile(file, source);
13+
14+ const result = spawnSync(
15+ join(repoRoot, "node_modules", ".bin", "oxlint"),
16+ ["-c", join(repoRoot, ".oxlintrc.jsonc"), file, "--deny-warnings"],
17+ {
18+ cwd: repoRoot,
19+ encoding: "utf8",
20+ },
21+ );
22+
23+ await rm(file, { force: true });
24+ return result;
25+};
26+
27+describe("executor oxlint plugin", () => {
28+ it("rejects expect calls in conditional test branches", async () => {
29+ const result = await runOxlintOn(
30+ "conditional-expect.test.ts",
31+ `
32+ import { describe, expect, it } from "@effect/vitest";
33+
34+ const helper = (value: string | undefined) => {
35+ if (value) {
36+ expect(value).toBe("ok");
37+ }
38+ };
39+
40+ describe("example", () => {
41+ it("uses a helper", () => {
42+ helper("ok");
43+ });
44+ });
45+ `,
46+ );
47+
48+ expect(result.status).toBe(1);
49+ expect(result.stdout).toContain("executor(no-conditional-tests)");
50+ });
51+
52+ it("allows unconditional expects over conditional values", async () => {
53+ const result = await runOxlintOn(
54+ "unconditional-expect.test.ts",
55+ `
56+ import { describe, expect, it } from "@effect/vitest";
57+
58+ const pick = (flag: boolean) => flag ? "ok" : "no";
59+
60+ describe("example", () => {
61+ it("compares the selected value", () => {
62+ const value = pick(true);
63+ expect(value).toBe("ok");
64+ });
65+ });
66+ `,
67+ );
68+
69+ expect(result.status).toBe(0);
70+ expect(result.stdout).toContain("Found 0 warnings and 0 errors.");
71+ expect(result.stderr).toBe("");
72+ });
73+});
packages/core/sdk/src/secrets.ts+3−2
@@ -48,8 +48,9 @@ export interface SecretProvider { · 47 unmodified lines
4848 readonly { readonly id: string; readonly name: string }[],
4949 StorageFailure
5050 >;
51− /** If false, `get` only runs for routed secret rows owned by this
52− * provider and is skipped by provider-enumeration fallback lookup. */
51+ /** Whether the provider may be asked during id-only fallback resolution.
52+ * Providers whose own auth depends on `ctx.secrets.get` should opt out to
53+ * avoid recursive fallback through themselves. */
5354 readonly allowFallback?: boolean;
5455 }
5556
packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+11−0
@@ -7,6 +7,7 @@ const IPC_PREFIX = "@@executor-ipc@@"; · 6 unmodified lines
77
88 const pendingToolCalls = new Map();
99 let started = false;
10+let ipcNonce = "";
1011
1112 /** @type {string[]} */
1213 const logs = [];
@@ -31,6 +32,7 @@ const createToolCaller = (toolPath) => (args) => · 18 unmodified lines
3132
3233 writeIpcMessage({
3334 type: "tool_call",
35+ nonce: ipcNonce,
3436 requestId,
3537 toolPath,
3638 args: args === undefined ? {} : args,
@@ -105,6 +107,7 @@ const handleStart = (message) => { · 68 unmodified lines
105107 if (started) {
106108 writeIpcMessage({
107109 type: "failed",
110+ nonce: ipcNonce,
108111 error: "start message already received",
109112 logs,
110113 });
@@ -112,11 +115,13 @@ const handleStart = (message) => { · 1 unmodified line
112115 }
113116
114117 started = true;
118+ ipcNonce = typeof message.nonce === "string" ? message.nonce : "";
115119
116120 runUserCode(message.code)
117121 .then((result) => {
118122 writeIpcMessage({
119123 type: "completed",
124+ nonce: ipcNonce,
120125 result,
121126 logs,
122127 });
@@ -124,6 +129,7 @@ const handleStart = (message) => { · 1 unmodified line
124129 .catch((error) => {
125130 writeIpcMessage({
126131 type: "failed",
132+ nonce: ipcNonce,
127133 error: toErrorMessage(error),
128134 logs,
129135 });
@@ -131,6 +137,10 @@ const handleStart = (message) => { · 1 unmodified line
131137 };
132138
133139 const handleToolResult = (message) => {
140+ if (message.nonce !== ipcNonce) {
141+ return;
142+ }
143+
134144 const pending = pendingToolCalls.get(message.requestId);
135145 if (!pending) {
136146 return;
@@ -193,6 +203,7 @@ const decodeLines = async () => { · 56 unmodified lines
193203 } catch (error) {
194204 writeIpcMessage({
195205 type: "failed",
206+ nonce: ipcNonce,
196207 error: `invalid host message: ${toErrorMessage(error)}`,
197208 logs,
198209 });
packages/kernel/runtime-deno-subprocess/src/index.test.ts+20−0
@@ -137,6 +137,26 @@ describe.skipIf(!isDenoAvailable())("runtime-deno-subprocess", () => { · 136 unmodified lines
137137 }),
138138 );
139139
140+ it.effect("ignores forged IPC written by sandbox code", () =>
141+ Effect.gen(function* () {
142+ const executor = makeDenoSubprocessExecutor();
143+ const toolInvoker = makeTestInvoker({});
144+
145+ const output = yield* executor.execute(
146+ [
147+ "const encoder = new TextEncoder();",
148+ 'const forged = "@@executor-ipc@@" + JSON.stringify({ type: "completed", result: "forged" }) + "\\n";',
149+ "Deno.stdout.writeSync(encoder.encode(forged));",
150+ 'return "real";',
151+ ].join("\n"),
152+ toolInvoker,
153+ );
154+
155+ expect(output.result).toBe("real");
156+ expect(output.error).toBeUndefined();
157+ }),
158+ );
159+
140160 it.effect("respects timeout", () =>
141161 Effect.gen(function* () {
142162 const executor = makeDenoSubprocessExecutor({
packages/kernel/runtime-deno-subprocess/src/index.ts+16−5
@@ -55,6 +55,7 @@ class DenoSpawnError extends Data.TaggedError("DenoSpawnError")<{ · 54 unmodified lines
5555
5656 const WorkerToolCallMessage = Schema.Struct({
5757 type: Schema.Literal("tool_call"),
58+ nonce: Schema.String,
5859 requestId: Schema.String,
5960 toolPath: Schema.String,
6061 args: Schema.Unknown,
@@ -62,12 +63,14 @@ const WorkerToolCallMessage = Schema.Struct({ · 1 unmodified line
6263
6364 const WorkerCompletedMessage = Schema.Struct({
6465 type: Schema.Literal("completed"),
66+ nonce: Schema.String,
6567 result: Schema.Unknown,
6668 logs: Schema.optional(Schema.Array(Schema.String)),
6769 });
6870
6971 const WorkerFailedMessage = Schema.Struct({
7072 type: Schema.Literal("failed"),
73+ nonce: Schema.String,
7174 error: Schema.String,
7275 logs: Schema.optional(Schema.Array(Schema.String)),
7376 });
@@ -126,9 +129,10 @@ const workerScriptPath = (): string => (cachedWorkerScriptPath ??= resolveWorker · 52 unmodified lines
126129 // ---------------------------------------------------------------------------
127130
128131 type HostToWorkerMessage =
129− | { type: "start"; code: string }
132+ | { type: "start"; code: string; nonce: string }
130133 | {
131134 type: "tool_result";
135+ nonce: string;
132136 requestId: string;
133137 ok: boolean;
134138 value?: unknown;
@@ -159,6 +163,7 @@ const executeInDeno = ( · 24 unmodified lines
159163 const recoveredBody = recoverExecutionBody(code);
160164 const denoExecutable = options.denoExecutable ?? defaultDenoExecutable();
161165 const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
166+ const nonce = crypto.randomUUID();
162167
163168 return Effect.gen(function* () {
164169 const runSync = Effect.runSync;
@@ -189,9 +194,13 @@ const executeInDeno = ( · 24 unmodified lines
189194 const line = rawLine.trim();
190195 if (!line.startsWith(IPC_PREFIX)) return;
191196
192− const decoded = decodeWorkerMessage(JSON.parse(line.slice(IPC_PREFIX.length)));
193− if (Option.isSome(decoded)) {
194− runSync(Queue.offer(messages, decoded.value as WorkerToHostMessage));
197+ try {
198+ const decoded = decodeWorkerMessage(JSON.parse(line.slice(IPC_PREFIX.length)));
199+ if (Option.isSome(decoded) && decoded.value.nonce === nonce) {
200+ runSync(Queue.offer(messages, decoded.value as WorkerToHostMessage));
201+ }
202+ } catch {
203+ // Ignore malformed sandbox output. It is not trusted IPC.
195204 }
196205 },
197206 onStderr: () => {},
@@ -220,7 +229,7 @@ const executeInDeno = ( · 22 unmodified lines
220229 });
221230
222231 // Send code to the subprocess
223− writeMessage(worker.stdin, { type: "start", code: recoveredBody });
232+ writeMessage(worker.stdin, { type: "start", code: recoveredBody, nonce });
224233
225234 // Set up timeout — kills process and completes the deferred
226235 const timer = setTimeout(() => {
@@ -250,6 +259,7 @@ const executeInDeno = ( · 23 unmodified lines
250259 Effect.map(
251260 (value): HostToWorkerMessage => ({
252261 type: "tool_result",
262+ nonce,
253263 requestId: msg.requestId,
254264 ok: true,
255265 value,
@@ -258,6 +268,7 @@ const executeInDeno = ( · 2 unmodified lines
258268 Effect.catchCause((cause) =>
259269 Effect.succeed<HostToWorkerMessage>({
260270 type: "tool_result",
271+ nonce,
261272 requestId: msg.requestId,
262273 ok: false,
263274 error: causeMessage(cause),
packages/kernel/runtime-dynamic-worker/src/executor.ts+19−9
@@ -90,7 +90,6 @@ const normalizeErrorObject = (error: Error) => ({ · 89 unmodified lines
9090 __type: "Error" as const,
9191 name: error.name,
9292 message: error.message,
93− ...(typeof error.stack === "string" && error.stack.length > 0 ? { stack: error.stack } : {}),
9493 });
9594
9695 const isNormalizedErrorObject = (
@@ -260,9 +259,13 @@ const isPlainObject = (value: object): boolean => { · 163 unmodified lines
260259 return proto === Object.prototype || proto === null;
261260 };
262261
263−const rehydrateBinary = (value: unknown): unknown => {
262+const rehydrateBinary = (value: unknown, seen = new WeakSet<object>()): unknown => {
264263 if (value === null || typeof value !== "object") return value;
265264 if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;
265+ if (seen.has(value)) {
266+ throw new Error("Tool RPC payload contains a circular reference");
267+ }
268+ seen.add(value);
266269 if (isBinaryEnvelope(value)) {
267270 if (value.kind === "file" && typeof value.name === "string") {
268271 return new File([value.buffer], value.name, {
@@ -272,20 +275,24 @@ const rehydrateBinary = (value: unknown): unknown => { · 3 unmodified lines
272275 }
273276 return new Blob([value.buffer], { type: value.type });
274277 }
275− if (Array.isArray(value)) return value.map(rehydrateBinary);
278+ if (Array.isArray(value)) return value.map((item) => rehydrateBinary(item, seen));
276279 if (!isPlainObject(value)) return value;
277280 const out: Record<string, unknown> = {};
278281 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
279− out[k] = rehydrateBinary(v);
282+ out[k] = rehydrateBinary(v, seen);
280283 }
281284 return out;
282285 };
283286
284287 // Async because `Blob.arrayBuffer()` is async. Used on tool results before
285288 // the dispatcher hands them back to the sandbox.
286−const encodeBinary = async (value: unknown): Promise<unknown> => {
289+const encodeBinary = async (value: unknown, seen = new WeakSet<object>()): Promise<unknown> => {
287290 if (value === null || typeof value !== "object") return value;
288291 if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;
292+ if (seen.has(value)) {
293+ throw new Error("Tool RPC payload contains a circular reference");
294+ }
295+ seen.add(value);
289296 if (typeof File !== "undefined" && value instanceof File) {
290297 return {
291298 __executorBinary: 1 as const,
@@ -304,11 +311,11 @@ const encodeBinary = async (value: unknown): Promise<unknown> => { · 12 unmodified lines
304311 buffer: await value.arrayBuffer(),
305312 };
306313 }
307− if (Array.isArray(value)) return Promise.all(value.map(encodeBinary));
314+ if (Array.isArray(value)) return Promise.all(value.map((item) => encodeBinary(item, seen)));
308315 if (!isPlainObject(value)) return value;
309316 const out: Record<string, unknown> = {};
310317 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
311− out[k] = await encodeBinary(v);
318+ out[k] = await encodeBinary(v, seen);
312319 }
313320 return out;
314321 };
@@ -347,9 +354,12 @@ export class ToolDispatcher extends RpcTarget { · 32 unmodified lines
347354 }
348355
349356 async call(path: string, args: unknown): Promise<WorkerRpcResponse> {
350− const decodedArgs = rehydrateBinary(args);
351357 return this.#runPromise(
352− this.#invoker.invoke({ path, args: decodedArgs }).pipe(
358+ Effect.try({
359+ try: () => rehydrateBinary(args),
360+ catch: (cause) => cause,
361+ }).pipe(
362+ Effect.flatMap((decodedArgs) => this.#invoker.invoke({ path, args: decodedArgs })),
353363 Effect.flatMap((value) =>
354364 Effect.tryPromise({
355365 try: (): Promise<WorkerRpcResponse> =>
packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+80−0
@@ -97,6 +97,22 @@ describe("ToolDispatcher", () => { · 96 unmodified lines
9797 expect(result).toEqual({ ok: true, result: undefined });
9898 });
9999
100+ it("returns a failure envelope for circular RPC args", async () => {
101+ const invoker = makeInvoker(({ args }) => args);
102+ const dispatcher = new ToolDispatcher(invoker, Effect.runPromise);
103+ const cyclic: Record<string, unknown> = {};
104+ cyclic.self = cyclic;
105+
106+ const result = await dispatcher.call("test.tool", cyclic);
107+
108+ expect(result).toMatchObject({
109+ ok: false,
110+ error: {
111+ message: "Tool RPC payload contains a circular reference",
112+ },
113+ });
114+ });
115+
100116 it("passes the tool path correctly", async () => {
101117 let capturedPath = "";
102118 const invoker = makeInvoker(({ path }) => {
@@ -241,6 +257,37 @@ describe("makeDynamicWorkerExecutor", () => { · 138 unmodified lines
241257 expect(result.error).toBe("not authorized");
242258 });
243259
260+ it("does not expose host error stack details to sandbox error handlers", async () => {
261+ const executor = makeDynamicWorkerExecutor({ loader });
262+ const invoker = {
263+ invoke: () => {
264+ const error = new Error("not authorized");
265+ error.stack = "secret host stack";
266+ return Effect.fail(error);
267+ },
268+ } satisfies SandboxToolInvoker;
269+
270+ const result = await Effect.runPromise(
271+ executor.execute(
272+ `async () => {
273+ try {
274+ await tools.secret.read({});
275+ } catch (error) {
276+ return {
277+ message: error && error.message,
278+ stack: error && error.stack,
279+ };
280+ }
281+ }`,
282+ invoker,
283+ ),
284+ );
285+
286+ expect(result.error).toBeUndefined();
287+ expect(result.result).toMatchObject({ message: "not authorized" });
288+ expect((result.result as { stack?: string }).stack).not.toContain("secret host stack");
289+ });
290+
244291 it("surfaces object-shaped tool errors in execution result", async () => {
245292 const executor = makeDynamicWorkerExecutor({ loader });
246293 const invoker = {
@@ -300,6 +347,39 @@ describe("makeDynamicWorkerExecutor", () => { · 53 unmodified lines
300347 expect(result.result).toBe(30);
301348 });
302349
350+ it("returns an execution error for circular tool args", async () => {
351+ const executor = makeDynamicWorkerExecutor({ loader });
352+ const invoker = makeInvoker(() => null);
353+
354+ const result = await Effect.runPromise(
355+ executor.execute(
356+ `async () => {
357+ const payload = {};
358+ payload.self = payload;
359+ return await tools.cycles.send(payload);
360+ }`,
361+ invoker,
362+ ),
363+ );
364+
365+ expect(result.result).toBeNull();
366+ expect(result.error).toBe("Tool RPC payload contains a circular reference");
367+ });
368+
369+ it("returns an execution error for circular tool results", async () => {
370+ const executor = makeDynamicWorkerExecutor({ loader });
371+ const cyclic: Record<string, unknown> = {};
372+ cyclic.self = cyclic;
373+ const invoker = makeInvoker(() => cyclic);
374+
375+ const result = await Effect.runPromise(
376+ executor.execute("async () => await tools.cycles.read({})", invoker),
377+ );
378+
379+ expect(result.result).toBeNull();
380+ expect(result.error).toBe("Tool RPC payload contains a circular reference");
381+ });
382+
303383 it("respects timeout", async () => {
304384 const executor = makeDynamicWorkerExecutor({ loader, timeoutMs: 500 });
305385 const invoker = makeInvoker(() => null);
packages/kernel/runtime-dynamic-worker/src/module-template.ts+11−10
@@ -31,7 +31,6 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 30 unmodified lines
3131 " __type: 'Error',",
3232 " name: value.name,",
3333 " message: value.message,",
34− " ...(typeof value.stack === 'string' && value.stack.length > 0 ? { stack: value.stack } : {}),",
3534 " };",
3635 " }",
3736 " if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {",
@@ -84,9 +83,11 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 46 unmodified lines
8483 " const __isBinaryEnvelope = (v) =>",
8584 " v && typeof v === 'object' && v.__executorBinary === 1 &&",
8685 " v.buffer instanceof ArrayBuffer && typeof v.type === 'string';",
87− " const __encodeBinary = async (value) => {",
86+ " const __encodeBinary = async (value, seen = new WeakSet()) => {",
8887 " if (value === null || typeof value !== 'object') return value;",
8988 " if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;",
89+ " if (seen.has(value)) throw new Error('Tool RPC payload contains a circular reference');",
90+ " seen.add(value);",
9091 " if (typeof File !== 'undefined' && value instanceof File) {",
9192 " return {",
9293 " __executorBinary: 1,",
@@ -100,15 +101,17 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 7 unmodified lines
100101 " if (value instanceof Blob) {",
101102 " return { __executorBinary: 1, kind: 'blob', type: value.type, buffer: await value.arrayBuffer() };",
102103 " }",
103− " if (Array.isArray(value)) return Promise.all(value.map(__encodeBinary));",
104+ " if (Array.isArray(value)) return Promise.all(value.map((item) => __encodeBinary(item, seen)));",
104105 " if (!__isPlainObject(value)) return value;",
105106 " const out = {};",
106− " for (const [k, v] of Object.entries(value)) out[k] = await __encodeBinary(v);",
107+ " for (const [k, v] of Object.entries(value)) out[k] = await __encodeBinary(v, seen);",
107108 " return out;",
108109 " };",
109− " const __decodeBinary = (value) => {",
110+ " const __decodeBinary = (value, seen = new WeakSet()) => {",
110111 " if (value === null || typeof value !== 'object') return value;",
111112 " if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;",
113+ " if (seen.has(value)) throw new Error('Tool RPC payload contains a circular reference');",
114+ " seen.add(value);",
112115 " if (__isBinaryEnvelope(value)) {",
113116 " if (value.kind === 'file' && typeof value.name === 'string') {",
114117 " return new File([value.buffer], value.name, {",
@@ -118,10 +121,10 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 3 unmodified lines
118121 " }",
119122 " return new Blob([value.buffer], { type: value.type });",
120123 " }",
121− " if (Array.isArray(value)) return value.map(__decodeBinary);",
124+ " if (Array.isArray(value)) return value.map((item) => __decodeBinary(item, seen));",
122125 " if (!__isPlainObject(value)) return value;",
123126 " const out = {};",
124− " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v);",
127+ " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v, seen);",
125128 " return out;",
126129 " };",
127130 " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
@@ -135,9 +138,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 7 unmodified lines
135138 " return (async () => {",
136139 " const encoded = await __encodeBinary(args[0]);",
137140 " const data = await __dispatcher.call(toolPath, encoded);",
138− " if (!data.ok) {",
139− " throw (data.error && data.error.primary !== null ? data.error.primary : data.error.message);",
140− " }",
141+ " if (!data.ok) throw new Error(data.error && data.error.message ? data.error.message : 'Tool execution failed');",
141142 " return __decodeBinary(data.result);",
142143 " })();",
143144 " },",
packages/kernel/runtime-quickjs/README.md+1−1
@@ -35,7 +35,7 @@ const engine = createExecutionEngine({ · 34 unmodified lines
3535
3636 | Option | Default | Description |
3737 | ------------------- | ---------- | --------------------------------- |
38−| `timeoutMs` | `5_000` | Max wall-clock time per execution |
38+| `timeoutMs` | `300_000` | Max wall-clock time per execution |
3939 | `memoryLimitBytes` | `64 * 1MB` | Max memory the VM can allocate |
4040 | `maxStackSizeBytes` | `1 * 1MB` | Max call-stack depth |
4141
packages/kernel/runtime-quickjs/src/index.test.ts+16−0
@@ -160,6 +160,22 @@ describe("quickjs executor", () => { · 159 unmodified lines
160160 }),
161161 );
162162
163+ it.effect("applies a memory limit by default", () =>
164+ Effect.gen(function* () {
165+ const defaultExecutor = makeQuickJsExecutor({ timeoutMs: 5_000 });
166+
167+ const result = yield* defaultExecutor.execute(
168+ `
169+ return new ArrayBuffer(128 * 1024 * 1024).byteLength;
170+ `,
171+ makeTestInvoker({}),
172+ );
173+
174+ expect(result.result).toBeNull();
175+ expect(result.error).toBeDefined();
176+ }),
177+ );
178+
163179 it.effect("passes tool result into next tool call", () =>
164180 Effect.gen(function* () {
165181 const invoker = makeTestInvoker({
packages/kernel/runtime-quickjs/src/index.ts+6−7
@@ -37,7 +37,11 @@ class QuickJsExecutionError extends Data.TaggedError("QuickJsExecutionError")<{ · 36 unmodified lines
3737 readonly message: string;
3838 }> {}
3939
40+// Large OpenAPI specs can take longer to parse inside QuickJS, so keep the
41+// default execution budget at five minutes unless a caller opts into less.
4042 const DEFAULT_TIMEOUT_MS = 5 * 60_000;
43+const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
44+const DEFAULT_MAX_STACK_SIZE_BYTES = 1 * 1024 * 1024;
4145 const EXECUTION_FILENAME = "executor-quickjs-runtime.js";
4246
4347 const toError = (cause: unknown): Error =>
@@ -297,13 +301,8 @@ const evaluateInQuickJs = async ( · 253 unmodified lines
297301 const runtime = QuickJS.newRuntime();
298302
299303 try {
300− if (options.memoryLimitBytes !== undefined) {
301− runtime.setMemoryLimit(options.memoryLimitBytes);
302− }
303−
304− if (options.maxStackSizeBytes !== undefined) {
305− runtime.setMaxStackSize(options.maxStackSizeBytes);
306− }
304+ runtime.setMemoryLimit(options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES);
305+ runtime.setMaxStackSize(options.maxStackSizeBytes ?? DEFAULT_MAX_STACK_SIZE_BYTES);
307306
308307 runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));
309308
packages/plugins/google-discovery/package.json+6−0
@@ -37,6 +37,12 @@ · 36 unmodified lines
3737 "types": "./dist/sdk/index.d.ts",
3838 "default": "./dist/core.js"
3939 }
40+ },
41+ "./client": {
42+ "import": {
43+ "types": "./dist/react/plugin-client.d.ts",
44+ "default": "./dist/client.js"
45+ }
4046 }
4147 }
4248 },
packages/plugins/google-discovery/src/react/client.ts+1−1
@@ -3,5 +3,5 @@ import { getBaseUrl } from "@executor-js/react/api/base-url"; · 2 unmodified lines
33 import { GoogleDiscoveryGroup } from "../api/group";
44
55 export const GoogleDiscoveryClient = createPluginAtomClient(GoogleDiscoveryGroup, {
6− baseUrl: getBaseUrl(),
6+ baseUrl: getBaseUrl,
77 });
packages/plugins/google-discovery/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ client: "src/react/plugin-client.tsx",
78 },
89 format: ["esm"],
910 dts: false,
packages/plugins/graphql/package.json+6−0
@@ -39,6 +39,12 @@ · 38 unmodified lines
3939 "default": "./dist/core.js"
4040 }
4141 },
42+ "./client": {
43+ "import": {
44+ "types": "./dist/react/plugin-client.d.ts",
45+ "default": "./dist/client.js"
46+ }
47+ },
4248 "./testing": {
4349 "import": {
4450 "types": "./dist/testing/index.d.ts",
packages/plugins/graphql/src/react/client.ts+1−1
@@ -3,5 +3,5 @@ import { getBaseUrl } from "@executor-js/react/api/base-url"; · 2 unmodified lines
33 import { GraphqlGroup } from "../api/group";
44
55 export const GraphqlClient = createPluginAtomClient(GraphqlGroup, {
6− baseUrl: getBaseUrl(),
6+ baseUrl: getBaseUrl,
77 });
packages/plugins/graphql/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ client: "src/react/plugin-client.tsx",
78 testing: "src/testing/index.ts",
89 },
910 format: ["esm"],
packages/plugins/keychain/src/index.ts+5−3
@@ -18,8 +18,9 @@ import { makeKeychainProvider, scopedKeychainServiceName } from "./provider"; · 17 unmodified lines
1818 // error, but `setPassword` fails because the secret-service backend
1919 // isn't actually reachable. Writing is the capability the executor
2020 // cares about, so test it directly.
21−const PROBE_ACCOUNT = "__executor_keychain_probe__";
2221 const PROBE_VALUE = "probe";
22+const probeAccount = (): string =>
23+ `__executor_keychain_probe__:${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
2324
2425 // ---------------------------------------------------------------------------
2526 // Re-exports
@@ -88,9 +89,10 @@ export const keychainPlugin = definePlugin((options?: KeychainPluginConfig) => ( · 62 unmodified lines
8889 Effect.gen(function* () {
8990 const baseServiceName = resolveServiceName(options?.serviceName);
9091 const probeServiceName = scopedKeychainServiceName(baseServiceName, ctx.scopes[0]!.id);
91− const reachable = yield* setPassword(probeServiceName, PROBE_ACCOUNT, PROBE_VALUE).pipe(
92+ const account = probeAccount();
93+ const reachable = yield* setPassword(probeServiceName, account, PROBE_VALUE).pipe(
9294 Effect.andThen(
93− deletePassword(probeServiceName, PROBE_ACCOUNT).pipe(Effect.catch(() => Effect.void)),
95+ deletePassword(probeServiceName, account).pipe(Effect.catch(() => Effect.void)),
9496 ),
9597 Effect.as(true),
9698 Effect.catch(() =>
packages/plugins/mcp/package.json+7−0
@@ -39,6 +39,12 @@ · 38 unmodified lines
3939 "default": "./dist/core.js"
4040 }
4141 },
42+ "./client": {
43+ "import": {
44+ "types": "./dist/react/plugin-client.d.ts",
45+ "default": "./dist/client.js"
46+ }
47+ },
4248 "./testing": {
4349 "import": {
4450 "types": "./dist/testing/index.d.ts",
@@ -55,6 +61,7 @@ · 10 unmodified lines
5561 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
5662 },
5763 "dependencies": {
64+ "@cfworker/json-schema": "^4.1.1",
5865 "@effect/platform-node": "catalog:",
5966 "@executor-js/config": "workspace:*",
6067 "@executor-js/sdk": "workspace:*",
packages/plugins/mcp/src/react/client.ts+1−1
@@ -3,5 +3,5 @@ import { getBaseUrl } from "@executor-js/react/api/base-url"; · 2 unmodified lines
33 import { McpGroup } from "../api/group";
44
55 export const McpClient = createPluginAtomClient(McpGroup, {
6− baseUrl: getBaseUrl(),
6+ baseUrl: getBaseUrl,
77 });
packages/plugins/mcp/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ client: "src/react/plugin-client.tsx",
78 testing: "src/testing/index.ts",
89 },
910 format: ["esm"],
packages/plugins/onepassword/package.json+6−0
@@ -36,6 +36,12 @@ · 35 unmodified lines
3636 "types": "./dist/sdk/index.d.ts",
3737 "default": "./dist/core.js"
3838 }
39+ },
40+ "./client": {
41+ "import": {
42+ "types": "./dist/react/plugin-client.d.ts",
43+ "default": "./dist/client.js"
44+ }
3945 }
4046 }
4147 },
packages/plugins/onepassword/src/react/client.ts+1−1
@@ -3,5 +3,5 @@ import { getBaseUrl } from "@executor-js/react/api/base-url"; · 2 unmodified lines
33 import { OnePasswordGroup } from "../api/group";
44
55 export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, {
6− baseUrl: getBaseUrl(),
6+ baseUrl: getBaseUrl,
77 });
packages/plugins/onepassword/src/sdk/plugin.ts+20−4
@@ -159,6 +159,15 @@ const getServiceFromConfig = ( · 158 unmodified lines
159159 Effect.flatMap((resolved) => makeOnePasswordService(resolved, { timeoutMs, preferSdk })),
160160 );
161161
162+const configuredVaultUri = (config: OnePasswordConfig, secretId: string): string | null => {
163+ if (!secretId.startsWith("op://")) {
164+ return `op://${config.vaultId}/${secretId}/${CREDENTIAL_FIELD}`;
165+ }
166+ const match = secretId.match(/^op:\/\/([^/]+)\/.+/);
167+ if (!match || match[1] !== config.vaultId) return null;
168+ return secretId;
169+};
170+
162171 // ---------------------------------------------------------------------------
163172 // SecretProvider — read-only, resolves op:// URIs or vaultId-based lookups
164173 // ---------------------------------------------------------------------------
@@ -170,6 +179,7 @@ const makeProvider = ( · 5 unmodified lines
170179 ): SecretProvider => ({
171180 key: "onepassword",
172181 writable: false,
182+ allowFallback: false,
173183
174184 // 1Password vaults are named in the stored config; the executor-scope
175185 // arg isn't used for routing here. A future refactor could let the
@@ -179,9 +189,8 @@ const makeProvider = ( · 3 unmodified lines
179189 Effect.flatMap((config) => {
180190 if (!config) return Effect.succeed(null as string | null);
181191
182− const uri = secretId.startsWith("op://")
183− ? secretId
184− : `op://${config.vaultId}/${secretId}/${CREDENTIAL_FIELD}`;
192+ const uri = configuredVaultUri(config, secretId);
193+ if (uri === null) return Effect.succeed(null as string | null);
185194
186195 return getServiceFromConfig(config, ctx, timeoutMs, preferSdk).pipe(
187196 Effect.flatMap((svc) => svc.resolveSecret(uri)),
@@ -261,8 +270,15 @@ const makeOnePasswordExtension = ( · 73 unmodified lines
261270 message: "1Password is not configured",
262271 });
263272 }
273+ const scopedUri = configuredVaultUri(config, uri);
274+ if (scopedUri === null) {
275+ return yield* new OnePasswordError({
276+ operation: "resolve",
277+ message: "1Password secret URI is outside the configured vault",
278+ });
279+ }
264280 const svc = yield* getServiceFromConfig(config, ctx, timeoutMs, preferSdk);
265− return yield* svc.resolveSecret(uri);
281+ return yield* svc.resolveSecret(scopedUri);
266282 }),
267283 };
268284 };
packages/plugins/onepassword/src/sdk/service.ts+23−15
@@ -1,4 +1,4 @@
1−import { Context, Duration, Effect } from "effect";
1+import { Context, Duration, Effect, Semaphore } from "effect";
22 import * as op from "@1password/op-js";
33
44 import { OnePasswordError } from "./errors";
@@ -138,26 +138,34 @@ export const makeNativeSdkService = ( · 133 unmodified lines
138138 // CLI backend — uses @1password/op-js (shells out to `op` CLI)
139139 // ---------------------------------------------------------------------------
140140
141+const cliAuthLock = Semaphore.makeUnsafe(1);
142+
141143 export const makeCliService = (
142144 auth: ResolvedAuth,
143145 ): Effect.Effect<OnePasswordService, OnePasswordError> =>
144146 Effect.sync(() => {
145− // Configure auth
146− if (auth.kind === "service-account") {
147− op.setServiceAccount(auth.token);
148− } else {
149− op.setGlobalFlags({ account: auth.accountName });
150− }
151−
152147 const wrapSync = <A>(fn: () => A, operation: string): Effect.Effect<A, OnePasswordError> =>
153− Effect.try({
154− try: fn,
155− catch: () =>
156− new OnePasswordError({
157− operation,
158− message: `1Password CLI ${operation} failed`,
148+ cliAuthLock
149+ .withPermits(1)(
150+ Effect.try({
151+ try: () => {
152+ if (auth.kind === "service-account") {
153+ op.setGlobalFlags({});
154+ op.setServiceAccount(auth.token);
155+ } else {
156+ op.setServiceAccount("");
157+ op.setGlobalFlags({ account: auth.accountName });
158+ }
159+ return fn();
160+ },
161+ catch: () =>
162+ new OnePasswordError({
163+ operation,
164+ message: `1Password CLI ${operation} failed`,
165+ }),
159166 }),
160− }).pipe(Effect.withSpan(`onepassword.cli.${operation}`));
167+ )
168+ .pipe(Effect.withSpan(`onepassword.cli.${operation}`));
161169
162170 return OnePasswordServiceTag.of({
163171 resolveSecret: (uri) => wrapSync(() => op.read.parse(uri), "secret resolution"),
packages/plugins/onepassword/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ client: "src/react/plugin-client.tsx",
78 },
89 format: ["esm"],
910 dts: false,
packages/plugins/openapi/package.json+6−0
@@ -39,6 +39,12 @@ · 38 unmodified lines
3939 "default": "./dist/core.js"
4040 }
4141 },
42+ "./client": {
43+ "import": {
44+ "types": "./dist/react/plugin-client.d.ts",
45+ "default": "./dist/client.js"
46+ }
47+ },
4248 "./testing": {
4349 "import": {
4450 "types": "./dist/testing/index.d.ts",
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+3−24
@@ -76,12 +76,8 @@ import { · 75 unmodified lines
7676 oauth2ConnectionSlot,
7777 queryParamBindingSlot,
7878 } from "../sdk/store";
79−import {
80− ConfiguredHeaderBinding,
81− OAuth2SourceConfig,
82− type ServerInfo,
83− type ServerVariable,
84−} from "../sdk/types";
79+import { ConfiguredHeaderBinding, OAuth2SourceConfig, type ServerInfo } from "../sdk/types";
80+import { expandServerUrlOptions } from "../sdk/openapi-utils";
8581
8682 export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
8783 export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
@@ -336,24 +332,7 @@ export default function AddOpenApiSource(props: { · 248 unmodified lines
336332 // ---- Derived state ----
337333
338334 const expandServerOptions = (server: ServerInfo) => {
339− const vars: Record<string, ServerVariable> = Option.getOrElse(
340− server.variables,
341− () => ({}) as Record<string, ServerVariable>,
342− );
343− const variableEntries = Object.entries(vars);
344− const expanded = variableEntries.reduce(
345− (urls, [name, variable]) => {
346− const enumValues: readonly string[] = Option.getOrElse(
347− variable.enum,
348− () => [] as readonly string[],
349− );
350− const values = enumValues.length > 0 ? enumValues : [variable.default];
351− return urls.flatMap((url) => values.map((value) => url.replaceAll(`{${name}}`, value)));
352− },
353− [server.url],
354− );
355−
356− return expanded.map((value) => ({
335+ return expandServerUrlOptions(server).map((value) => ({
357336 value,
358337 label: value,
359338 }));
packages/plugins/openapi/src/react/client.ts+1−1
@@ -3,5 +3,5 @@ import { getBaseUrl } from "@executor-js/react/api/base-url"; · 2 unmodified lines
33 import { OpenApiGroup } from "../api/group";
44
55 export const OpenApiClient = createPluginAtomClient(OpenApiGroup, {
6− baseUrl: getBaseUrl(),
6+ baseUrl: getBaseUrl,
77 });
packages/plugins/openapi/src/sdk/openapi-utils.ts+29−1
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 import { Option } from "effect";
88 import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
99 import type { ParsedDocument } from "./parse";
10+import type { ServerVariable } from "./types";
1011
1112 // ---------------------------------------------------------------------------
1213 // Type aliases — collapse V3 / V3_1 unions into single names
@@ -63,9 +64,36 @@ export const substituteUrlVariables = (url: string, values: Record<string, strin · 50 unmodified lines
6364 return out;
6465 };
6566
67+export const OPENAPI_MAX_SERVER_VARIABLE_OPTIONS = 64;
68+
6669 type ServerLike = {
6770 url: string;
68− variables: import("effect/Option").Option<Record<string, { default: string } | string>>;
71+ variables: import("effect/Option").Option<Record<string, ServerVariable | string>>;
72+};
73+
74+export const expandServerUrlOptions = (
75+ server: ServerLike,
76+ limit = OPENAPI_MAX_SERVER_VARIABLE_OPTIONS,
77+): readonly string[] => {
78+ if (!Option.isSome(server.variables)) return [server.url];
79+ let urls: readonly string[] = [server.url];
80+ for (const [name, variable] of Object.entries(server.variables.value)) {
81+ const enumValues =
82+ typeof variable === "string" ? [] : Option.getOrElse(variable.enum, () => []);
83+ const values =
84+ enumValues.length > 0
85+ ? enumValues
86+ : [typeof variable === "string" ? variable : variable.default];
87+ const next: string[] = [];
88+ for (const url of urls) {
89+ for (const value of values) {
90+ next.push(url.replaceAll(`{${name}}`, value));
91+ if (next.length >= limit) return next;
92+ }
93+ }
94+ urls = next;
95+ }
96+ return urls;
6997 };
7098
7199 export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {
packages/plugins/openapi/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ client: "src/react/plugin-client.tsx",
78 testing: "src/testing/index.ts",
89 },
910 format: ["esm"],
packages/react/src/api/client.tsx+4−2
@@ -1,5 +1,5 @@
11 import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2−import { FetchHttpClient } from "effect/unstable/http";
2+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
33 import { ExecutorApi } from "@executor-js/api";
44
55 import { getBaseUrl } from "./base-url";
@@ -11,7 +11,9 @@ import { getBaseUrl } from "./base-url"; · 5 unmodified lines
1111 const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorApiClient", {
1212 api: ExecutorApi,
1313 httpClient: FetchHttpClient.layer,
14− baseUrl: getBaseUrl(),
14+ transformClient: HttpClient.mapRequest((request) =>
15+ HttpClientRequest.prependUrl(request, getBaseUrl()),
16+ ),
1517 });
1618
1719 export { ExecutorApiClient };
packages/react/src/components/chart.test.ts+18−0
@@ -0,0 +1,18 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { chartCssColorValue, chartCssVariableName } from "./chart";
4+
5+describe("chart style helpers", () => {
6+ it("accepts CSS-safe chart keys and colors", () => {
7+ expect(chartCssVariableName("requests_2xx")).toBe("--color-requests_2xx");
8+ expect(chartCssColorValue("#0ea5e9")).toBe("#0ea5e9");
9+ expect(chartCssColorValue("rgb(14 165 233 / 50%)")).toBe("rgb(14 165 233 / 50%)");
10+ expect(chartCssColorValue("var(--chart-1)")).toBe("var(--chart-1)");
11+ });
12+
13+ it("rejects style-breaking keys and color values", () => {
14+ expect(chartCssVariableName("x];body{color:red")).toBeNull();
15+ expect(chartCssColorValue("red; background: url(https://example.test/x)")).toBeNull();
16+ expect(chartCssColorValue("</style><script>alert(1)</script>")).toBeNull();
17+ });
18+});
packages/react/src/components/chart.tsx+16−1
@@ -25,6 +25,18 @@ type ChartContextProps = { · 24 unmodified lines
2525 config: ChartConfig;
2626 };
2727
28+const cssIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_-]*$/;
29+const cssColorPattern =
30+ /^(?:#[0-9A-Fa-f]{3,8}|(?:rgb|hsl)a?\(\s*[-+0-9.%]+\s*(?:,\s*[-+0-9.%]+\s*){2}(?:,\s*[-+0-9.]+\s*)?\)|(?:rgb|hsl)a?\(\s*[-+0-9.%]+\s+[-+0-9.%]+\s+[-+0-9.%]+(?:\s*\/\s*[-+0-9.%]+)?\s*\)|[A-Za-z]+|var\(--[A-Za-z0-9_-]+\))$/;
31+
32+export const chartCssVariableName = (key: string): string | null =>
33+ cssIdentifierPattern.test(key) ? `--color-${key}` : null;
34+
35+export const chartCssColorValue = (color: string): string | null => {
36+ const trimmed = color.trim();
37+ return cssColorPattern.test(trimmed) ? trimmed : null;
38+};
39+
2840 const ChartContext = React.createContext<ChartContextProps | null>(null);
2941
3042 function useChart() {
@@ -92,8 +104,11 @@ ${prefix} [data-chart=${id}] { · 61 unmodified lines
92104 ${colorConfig
93105 .map(([key, itemConfig]) => {
94106 const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
95− return color ? ` --color-${key}: ${color};` : null;
107+ const variableName = chartCssVariableName(key);
108+ const colorValue = color ? chartCssColorValue(color) : null;
109+ return variableName && colorValue ? ` ${variableName}: ${colorValue};` : null;
96110 })
111+ .filter((line): line is string => line !== null)
97112 .join("\n")}
98113 }
99114 `,
packages/react/src/components/markdown.test.ts+18−0
@@ -0,0 +1,18 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { sanitizeMarkdownUrl } from "./markdown";
4+
5+describe("sanitizeMarkdownUrl", () => {
6+ it("allows regular web links", () => {
7+ expect(sanitizeMarkdownUrl("https://example.test/path?q=1")).toBe(
8+ "https://example.test/path?q=1",
9+ );
10+ });
11+
12+ it("removes unsupported link targets", () => {
13+ expect(sanitizeMarkdownUrl("javascript:alert(1)")).toBeNull();
14+ expect(sanitizeMarkdownUrl("data:text/html,hello")).toBeNull();
15+ expect(sanitizeMarkdownUrl("//example.test/path")).toBeNull();
16+ expect(sanitizeMarkdownUrl("/local/path")).toBeNull();
17+ });
18+});
packages/react/src/components/markdown.tsx+37−2
@@ -2,6 +2,20 @@ import { isValidElement, Children, type ComponentPropsWithoutRef, type ReactNode · 1 unmodified line
22 import { Streamdown, type Components } from "streamdown";
33 import { CodeBlock } from "./code-block";
44
5+const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
6+
7+export function sanitizeMarkdownUrl(url: string | undefined): string | null {
8+ const trimmed = url?.trim();
9+ if (!trimmed || trimmed.startsWith("//")) return null;
10+
11+ try {
12+ const parsed = new URL(trimmed);
13+ return SAFE_LINK_PROTOCOLS.has(parsed.protocol) ? parsed.href : null;
14+ } catch {
15+ return null;
16+ }
17+}
18+
519 function extractText(node: ReactNode): string {
620 if (typeof node === "string") return node;
721 if (typeof node === "number") return String(node);
@@ -28,6 +42,22 @@ function PreBlock( · 20 unmodified lines
2842 return <pre>{props.children}</pre>;
2943 }
3044
45+function Link(props: ComponentPropsWithoutRef<"a"> & { children?: ReactNode; node?: unknown }) {
46+ const { children, href, node: _node, ...rest } = props;
47+ const safeHref = sanitizeMarkdownUrl(href);
48+ if (!safeHref) return <span>{children}</span>;
49+ return (
50+ <a {...rest} href={safeHref} rel="noopener noreferrer" target="_blank">
51+ {children}
52+ </a>
53+ );
54+}
55+
56+function Image(props: ComponentPropsWithoutRef<"img"> & { node?: unknown }) {
57+ const { alt } = props;
58+ return alt ? <span>{alt}</span> : null;
59+}
60+
3161 const PROSE_CLASSES = [
3262 "text-sm leading-relaxed text-muted-foreground",
3363 // paragraphs
@@ -62,10 +92,15 @@ const PROSE_CLASSES = [ · 28 unmodified lines
6292 ].join(" ");
6393
6494 export function Markdown(props: { children: string; className?: string }) {
65− const components = { pre: PreBlock } satisfies Components;
95+ const components = { a: Link, img: Image, pre: PreBlock } satisfies Components;
6696 return (
6797 <div className={props.className ? `${PROSE_CLASSES} ${props.className}` : PROSE_CLASSES}>
68− <Streamdown linkSafety={{ enabled: false }} components={components}>
98+ <Streamdown
99+ components={components}
100+ linkSafety={{ enabled: true }}
101+ skipHtml
102+ urlTransform={(url) => sanitizeMarkdownUrl(url)}
103+ >
69104 {props.children}
70105 </Streamdown>
71106 </div>
packages/react/src/components/mcp-install-card.test.ts+32−0
@@ -0,0 +1,32 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { buildMcpInstallCommand, shellQuoteWord } from "./mcp-install-card";
4+
5+describe("MCP install command rendering", () => {
6+ it("quotes shell words without giving scope paths command syntax", () => {
7+ expect(shellQuoteWord("plain/path")).toBe("plain/path");
8+ expect(shellQuoteWord("owner's scope")).toBe(`'owner'"'"'s scope'`);
9+
10+ const command = buildMcpInstallCommand({
11+ mode: "stdio",
12+ isDev: false,
13+ origin: null,
14+ scopeDir: `/tmp/scope"; touch /tmp/unsafe; echo "`,
15+ });
16+
17+ expect(command).toBe(
18+ `npx add-mcp 'executor mcp --scope '"'"'/tmp/scope"; touch /tmp/unsafe; echo "'"'"'' --name executor`,
19+ );
20+ expect(command).not.toContain(`--scope "/tmp/scope"; touch`);
21+ });
22+
23+ it("quotes HTTP endpoints as add-mcp arguments", () => {
24+ expect(
25+ buildMcpInstallCommand({
26+ mode: "http",
27+ isDev: false,
28+ origin: "http://localhost:4788",
29+ }),
30+ ).toBe("npx add-mcp http://localhost:4788/mcp --transport http --name executor");
31+ });
32+});
packages/react/src/components/mcp-install-card.tsx+29−10
@@ -23,6 +23,29 @@ const isLocal = · 22 unmodified lines
2323 window.location.hostname === "127.0.0.1" ||
2424 window.location.hostname.endsWith(".localhost"));
2525
26+export const shellQuoteWord = (value: string): string => {
27+ if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value;
28+ return `'${value.replace(/'/g, `'"'"'`)}'`;
29+};
30+
31+export const buildMcpInstallCommand = (input: {
32+ readonly mode: TransportMode;
33+ readonly isDev: boolean;
34+ readonly origin: string | null;
35+ readonly scopeDir?: string;
36+}): string => {
37+ if (input.mode === "http") {
38+ const endpoint = input.origin ? `${input.origin}/mcp` : "<this-server>/mcp";
39+ return `npx add-mcp ${shellQuoteWord(endpoint)} --transport http --name executor`;
40+ }
41+
42+ const innerArgs = input.isDev ? ["bun", "run", "dev:cli", "mcp"] : ["executor", "mcp"];
43+ if (input.scopeDir) {
44+ innerArgs.push("--scope", input.scopeDir);
45+ }
46+ return `npx add-mcp ${shellQuoteWord(innerArgs.map(shellQuoteWord).join(" "))} --name executor`;
47+};
48+
2649 export function McpInstallCard(props: { className?: string }) {
2750 const showStdio = isLocal;
2851 const [mode, setMode] = useState<TransportMode>(showStdio ? "stdio" : "http");
@@ -33,16 +56,12 @@ export function McpInstallCard(props: { className?: string }) { · 4 unmodified lines
3356 setOrigin(window.location.origin);
3457 }, []);
3558
36− const scopeFlag = scopeInfo.dir ? ` --scope ${JSON.stringify(scopeInfo.dir)}` : "";
37−
38− const command =
39− mode === "stdio"
40− ? isDev
41− ? `npx add-mcp "bun run dev:cli mcp${scopeFlag}" --name "executor"`
42− : `npx add-mcp "executor mcp${scopeFlag}" --name "executor"`
43− : origin
44− ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`
45− : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';
59+ const command = buildMcpInstallCommand({
60+ mode,
61+ isDev,
62+ origin,
63+ scopeDir: scopeInfo.dir,
64+ });
4665
4766 const subtitle =
4867 mode === "stdio"
packages/react/src/components/schema-explorer.test.ts+16−0
@@ -0,0 +1,16 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { safeSchemaValueLabel } from "./schema-explorer";
4+
5+describe("safeSchemaValueLabel", () => {
6+ it("formats values that JSON.stringify cannot serialize", () => {
7+ expect(safeSchemaValueLabel(10n)).toBe("10n");
8+ });
9+
10+ it("uses neutral text for circular values", () => {
11+ const value: { self?: unknown } = {};
12+ value.self = value;
13+
14+ expect(safeSchemaValueLabel(value)).toBe("[unavailable]");
15+ });
16+});
packages/react/src/components/schema-explorer.tsx+14−3
@@ -26,6 +26,17 @@ type JsonSchema = { · 25 unmodified lines
2626 format?: string;
2727 };
2828
29+export const safeSchemaValueLabel = (value: unknown): string => {
30+ if (typeof value === "bigint") return `${value.toString()}n`;
31+ try {
32+ const label = JSON.stringify(value);
33+ if (label === undefined) return String(value);
34+ return label.length > 120 ? `${label.slice(0, 117)}...` : label;
35+ } catch {
36+ return "[unavailable]";
37+ }
38+};
39+
2940 // ---------------------------------------------------------------------------
3041 // Ref resolution — lazy, only on expand
3142 // ---------------------------------------------------------------------------
@@ -64,11 +75,11 @@ const getTypeLabel = (schema: JsonSchema, root: JsonSchema): string => { · 32 unmodified lines
6475 return getRefName(schema.$ref) ?? "ref";
6576 }
6677
67− if (schema.const !== undefined) return JSON.stringify(schema.const);
78+ if (schema.const !== undefined) return safeSchemaValueLabel(schema.const);
6879
6980 if (schema.enum) {
7081 if (schema.enum.length <= 3) {
71− return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
82+ return schema.enum.map((v) => safeSchemaValueLabel(v)).join(" | ");
7283 }
7384 return `enum (${schema.enum.length})`;
7485 }
@@ -263,7 +274,7 @@ function PropertyRow(props: { · 188 unmodified lines
263274 ))}
264275 {schema.default !== undefined && (
265276 <p className="text-xs leading-5 text-muted-foreground">
266− = {JSON.stringify(schema.default)}
277+ = {safeSchemaValueLabel(schema.default)}
267278 </p>
268279 )}
269280 </div>
packages/react/src/components/source-favicon.test.tsx+13−0
@@ -0,0 +1,13 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { SourceFavicon } from "./source-favicon";
4+
5+describe("SourceFavicon", () => {
6+ it("renders without requesting an external favicon service", () => {
7+ const element = SourceFavicon({ url: "https://internal.example.test/private", size: 20 });
8+
9+ expect(element.type).not.toBe("img");
10+ expect(element.props).not.toHaveProperty("src");
11+ expect(element.props).not.toHaveProperty("href");
12+ });
13+});
packages/react/src/components/source-favicon.tsx+6−34
@@ -1,43 +1,15 @@
11 import { BoxIcon } from "lucide-react";
2−import { useState } from "react";
3−import { getDomain } from "tldts";
42
53 // ---------------------------------------------------------------------------
6−// SourceFavicon — renders a small favicon derived from a source URL.
7−// Falls back to a neutral dot if the URL is missing or the image fails to load.
4+// SourceFavicon — renders a neutral local source icon.
5+// Do not fetch third-party favicon services here; source URLs may be private.
86 // ---------------------------------------------------------------------------
97
10−function domainOf(url: string): string | null {
11− try {
12− return getDomain(url) ?? getDomain(new URL(url).hostname) ?? null;
13− } catch {
14− return null;
15− }
16−}
17−
18−export function SourceFavicon({ url, size = 16 }: { url?: string; size?: number }) {
19− const [failed, setFailed] = useState(false);
20− const domain = url ? domainOf(url) : null;
21−
22− if (!domain || failed) {
23− return (
24− <BoxIcon
25− aria-hidden
26− className="shrink-0 text-muted-foreground"
27− style={{ width: size, height: size }}
28− />
29− );
30− }
31−
8+export function SourceFavicon({ size = 16 }: { url?: string; size?: number }) {
329 return (
33− <img
34− src={`https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`}
35− alt=""
36− width={size}
37− height={size}
38− loading="lazy"
39− onError={() => setFailed(true)}
40− className="shrink-0 rounded-sm"
10+ <BoxIcon
11+ aria-hidden
12+ className="shrink-0 text-muted-foreground"
4113 style={{ width: size, height: size }}
4214 />
4315 );
packages/react/src/plugins/secret-credential-scope.ts+9−0
@@ -0,0 +1,9 @@
1+import type { ScopeId } from "@executor-js/sdk";
2+
3+import type { SecretPickerSecret } from "./secret-picker";
4+
5+export const secretsForCredentialTarget = (
6+ secrets: readonly SecretPickerSecret[],
7+ targetScope: ScopeId,
8+): readonly SecretPickerSecret[] =>
9+ secrets.filter((secret) => secret.scopeId === String(targetScope));
packages/react/src/plugins/secret-form.tsx+5−4
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 } from "../components/select";
2626 import type { VariantProps } from "class-variance-authority";
2727
28+import { secretValueInputType } from "./secret-input";
2829 import { getUniqueSecretId, isSecretIdTaken } from "./secret-id";
2930
3031 // ---------------------------------------------------------------------------
@@ -219,6 +220,7 @@ function ValueField(props: { revealable?: boolean; placeholder?: string; autoFoc · 188 unmodified lines
219220 const { state, actions } = useSecretForm();
220221 const inputId = useId();
221222 const revealable = props.revealable ?? false;
223+ const revealed = revealable && state.revealed;
222224 const errored = state.status.kind === "error";
223225
224226 return (
@@ -227,16 +229,15 @@ function ValueField(props: { revealable?: boolean; placeholder?: string; autoFoc · 2 unmodified lines
227229 <div className="relative">
228230 <Input
229231 id={inputId}
230− type={revealable ? "text" : "password"}
232+ type={secretValueInputType({ revealable, revealed })}
231233 value={state.value}
232234 onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}
233235 placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}
234236 autoFocus={props.autoFocus}
237+ autoComplete="new-password"
235238 className={revealable ? "pr-9 font-mono" : "font-mono"}
236239 style={
237− revealable && !state.revealed
238− ? ({ WebkitTextSecurity: "disc" } as CSSProperties)
239− : undefined
240+ revealable && !revealed ? ({ WebkitTextSecurity: "disc" } as CSSProperties) : undefined
240241 }
241242 />
242243 {revealable && (
packages/react/src/plugins/secret-header-auth.test.ts+10−1
@@ -1,7 +1,8 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { ScopeId } from "@executor-js/sdk";
33
4−import { secretsForCredentialTarget } from "./secret-header-auth";
4+import { secretsForCredentialTarget } from "./secret-credential-scope";
5+import { secretValueInputType } from "./secret-input";
56
67 describe("secretsForCredentialTarget", () => {
78 it("only exposes secrets owned by the target credential scope", () => {
@@ -16,3 +17,11 @@ describe("secretsForCredentialTarget", () => { · 8 unmodified lines
1617 ).toEqual(["shared-token"]);
1718 });
1819 });
20+
21+describe("secretValueInputType", () => {
22+ it("uses password inputs until a value is revealed", () => {
23+ expect(secretValueInputType({ revealable: true, revealed: false })).toBe("password");
24+ expect(secretValueInputType({ revealable: false, revealed: false })).toBe("password");
25+ expect(secretValueInputType({ revealable: true, revealed: true })).toBe("text");
26+ });
27+});
packages/react/src/plugins/secret-header-auth.tsx+2−5
@@ -25,12 +25,9 @@ import { · 24 unmodified lines
2525 CredentialTargetScopeSelector,
2626 type CredentialTargetScopeOption,
2727 } from "./credential-target-scope";
28+import { secretsForCredentialTarget } from "./secret-credential-scope";
2829
29−export const secretsForCredentialTarget = (
30− secrets: readonly SecretPickerSecret[],
31− targetScope: ScopeId,
32−): readonly SecretPickerSecret[] =>
33− secrets.filter((secret) => secret.scopeId === String(targetScope));
30+export { secretsForCredentialTarget };
3431
3532 export interface HeaderAuthPreset {
3633 readonly key: string;
packages/react/src/plugins/secret-input.ts+4−0
@@ -0,0 +1,4 @@
1+export const secretValueInputType = (input: {
2+ readonly revealable: boolean;
3+ readonly revealed: boolean;
4+}): "password" | "text" => (input.revealable && input.revealed ? "text" : "password");
scripts/install.sh+4−2
@@ -186,8 +186,9 @@ check_existing_version() { · 185 unmodified lines
186186
187187 download_and_install() {
188188 print_message info "\n${MUTED}Installing ${NC}${APP} ${MUTED}version: ${NC}${specific_version}"
189− local tmp_dir="${TMPDIR:-/tmp}/${APP}_install_$$"
190− mkdir -p "$tmp_dir"
189+ local tmp_dir
190+ tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/${APP}_install_XXXXXXXXXX")
191+ trap 'rm -rf "$tmp_dir"' RETURN
191192
192193 curl -# -L -o "${tmp_dir}/${filename}" "$url"
193194
@@ -205,6 +206,7 @@ download_and_install() { · 11 unmodified lines
205206
206207 chmod 755 "${INSTALL_DIR}/${APP}"
207208 rm -rf "$tmp_dir"
209+ trap - RETURN
208210 }
209211
210212 install_from_binary() {
scripts/oxlint-plugin-executor/rules/no-conditional-tests.js+4−41
@@ -1,22 +1,4 @@
1−import { getCallName } from "../utils.js";
2−import { isTestLike } from "../utils.js";
3−import { unwrapExpression } from "../utils.js";
4−
5−const testRegistrars = new Set(["it", "test"]);
6−
7−const isFunction = (node) =>
8− node?.type === "ArrowFunctionExpression" ||
9− node?.type === "FunctionExpression" ||
10− node?.type === "FunctionDeclaration";
11−
12−const isTestRegistrationCall = (node) => {
13− const callee = unwrapExpression(node.callee);
14− if (callee?.type === "Identifier") return testRegistrars.has(callee.name);
15− if (callee?.type !== "MemberExpression") return false;
16−
17− const object = unwrapExpression(callee.object);
18− return object?.type === "Identifier" && testRegistrars.has(object.name);
19−};
1+import { getCallName, isTestLike } from "../utils.js";
202
213 export default {
224 meta: {
@@ -28,31 +10,18 @@ export default { · 5 unmodified lines
2810 create(context) {
2911 if (!isTestLike(context.filename)) return {};
3012
31− const testCallbacks = new WeakSet();
32− let testDepth = 0;
3313 let conditionalDepth = 0;
3414
35− const enterFunction = (node) => {
36− if (testCallbacks.has(node)) testDepth++;
37− };
38− const exitFunction = (node) => {
39− if (testCallbacks.has(node)) testDepth--;
40− };
4115 const enterConditional = () => {
42− if (testDepth > 0) conditionalDepth++;
16+ conditionalDepth++;
4317 };
4418 const exitConditional = () => {
45− if (testDepth > 0) conditionalDepth--;
19+ conditionalDepth--;
4620 };
4721
4822 return {
4923 CallExpression(node) {
50− if (isTestRegistrationCall(node)) {
51− const callback = node.arguments.find(isFunction);
52− if (callback) testCallbacks.add(callback);
53− }
54−
55− if (testDepth === 0 || conditionalDepth === 0) return;
24+ if (conditionalDepth === 0) return;
5625 const name = getCallName(node.callee);
5726 if (name !== "expect") return;
5827 context.report({
@@ -61,12 +30,6 @@ export default { · 2 unmodified lines
6130 "Avoid conditional expect calls; split the test or assert both branches explicitly. Skill: wrdn-effect-vitest-tests.",
6231 });
6332 },
64− FunctionDeclaration: enterFunction,
65− "FunctionDeclaration:exit": exitFunction,
66− FunctionExpression: enterFunction,
67− "FunctionExpression:exit": exitFunction,
68− ArrowFunctionExpression: enterFunction,
69− "ArrowFunctionExpression:exit": exitFunction,
7033 IfStatement: enterConditional,
7134 "IfStatement:exit": exitConditional,
7235 ConditionalExpression: enterConditional,