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
13 HttpServerResponse,13 HttpServerResponse,
14} from "effect/unstable/http";14} from "effect/unstable/http";
15import { expect, layer } from "@effect/vitest";15import { expect, layer } from "@effect/vitest";
16import { Effect, Layer, Schema } from "effect";16import { Cause, Effect, Layer, Schema } from "effect";
17import { toErrorServerResponse } from "./api/error-response";
17 18
18const SourceResponse = Schema.Struct({ source: Schema.String });19const SourceResponse = Schema.Struct({ source: Schema.String });
19 20
@@ -72,7 +73,7 @@ const ProtectedHandlers = HttpApiBuilder.group(ProtectedTestApi, "protected", (h · 52 unmodified lines
72// the full WorkOS / executor stack.73// the full WorkOS / executor stack.
73// ---------------------------------------------------------------------------74// ---------------------------------------------------------------------------
74 75
75type ProtectedMode = "ok" | "none" | "error" | "bad-status";76type ProtectedMode = "ok" | "none" | "error" | "bad-status" | "defect";
76const testState: { mode: ProtectedMode } = { mode: "ok" };77const testState: { mode: ProtectedMode } = { mode: "ok" };
77const resetState = () => {78const resetState = () => {
78 testState.mode = "ok";79 testState.mode = "ok";
@@ -100,6 +101,9 @@ const TestProtectedGate = HttpRouter.middleware()((httpEffect) => · 21 unmodified lines
100 HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }),101 HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }),
101 );102 );
102 }103 }
104 if (testState.mode === "defect") {
105 return Effect.succeed(toErrorServerResponse(Cause.die("secret-bearing failure detail")));
106 }
103 return httpEffect;107 return httpEffect;
104 }),108 }),
105).layer;109).layer;
@@ -249,4 +253,19 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 143 unmodified lines
249 expect(body).toEqual({ error: "boom" });253 expect(body).toEqual({ error: "boom" });
250 }),254 }),
251 );255 );
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 );
252});271});
apps/cloud/src/api/error-response.ts+2−8
@@ -53,19 +53,13 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 52 unmodified lines
53 53
54export const toErrorResponse = (error: unknown): Response => {54export const toErrorResponse = (error: unknown): Response => {
55 const mapped = toHttpResponseError(error);55 const mapped = toHttpResponseError(error);
56 if (mapped.status >= 500) captureCause(error);56 if (mapped.status >= 500) captureCause(mapped);
57 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });57 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });
58};58};
59 59
60export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {60export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
61 const mapped = toHttpResponseError(error);61 const mapped = toHttpResponseError(error);
62 if (mapped.status >= 500) {62 if (mapped.status >= 500) captureCause(mapped);
63 console.error(
64 "[api] toErrorServerResponse error:",
65 Cause.isCause(error) ? Cause.pretty(error) : error,
66 );
67 captureCause(error);
68 }
69 return HttpServerResponse.jsonUnsafe(63 return HttpServerResponse.jsonUnsafe(
70 { error: mapped.message, code: mapped.code },64 { error: mapped.message, code: mapped.code },
71 { status: mapped.status },65 { status: mapped.status },
apps/cloud/src/api/slack.ts+10−3
@@ -7,8 +7,17 @@ import { HttpResponseError, isServerError, toErrorServerResponse } from "./error · 6 unmodified lines
7 7
8const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);8const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
9 9
10const 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
10const handler = Effect.gen(function* () {18const handler = Effect.gen(function* () {
11 const request = yield* HttpServerRequest.HttpServerRequest;19 const request = yield* HttpServerRequest.HttpServerRequest;
20 const webRequest = yield* HttpServerRequest.toWeb(request);
12 21
13 if (request.method !== "POST") {22 if (request.method !== "POST") {
14 return yield* new HttpResponseError({23 return yield* new HttpResponseError({
@@ -45,10 +54,8 @@ const handler = Effect.gen(function* () { · 30 unmodified lines
45 });54 });
46 }55 }
47 56
48 // Global rate limit — single shared bucket keyed at "global". Per-IP gating
49 // belongs at the edge (Cloudflare Rules).
50 const limit = yield* Effect.tryPromise({57 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) }),
52 catch: (cause) => ({ success: false as const, fetchError: cause }),59 catch: (cause) => ({ success: false as const, fetchError: cause }),
53 });60 });
54 if (!limit.success) {61 if (!limit.success) {
apps/cloud/src/mcp.ts+25−3
@@ -72,6 +72,7 @@ const MCP_PATH = "/mcp"; · 71 unmodified lines
72const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";72const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
73const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;73const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
74const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;74const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
75export const MAX_MCP_JSON_RPC_BODY_BYTES = 1_000_000;
75 76
76type McpUnauthorizedReason = "missing_bearer" | "invalid_token";77type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
77 78
@@ -107,6 +108,18 @@ const corsPreflight = HttpServerResponse.empty({ · 29 unmodified lines
107 headers: CORS_PREFLIGHT_HEADERS,108 headers: CORS_PREFLIGHT_HEADERS,
108});109});
109 110
111const 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
118const isBodyTooLarge = (request: Request, maxBytes: number): boolean => {
119 const length = contentLength(request);
120 return length !== null && length > maxBytes;
121};
122
110// ---------------------------------------------------------------------------123// ---------------------------------------------------------------------------
111// Auth124// Auth
112// ---------------------------------------------------------------------------125// ---------------------------------------------------------------------------
@@ -302,7 +315,11 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 189 unmodified lines
302 try: () => request.clone().text(),315 try: () => request.clone().text(),
303 catch: () => undefined,316 catch: () => undefined,
304 }).pipe(317 }).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 ),
306 Effect.catchCause(() => Effect.succeed(Option.none())),323 Effect.catchCause(() => Effect.succeed(Option.none())),
307 Effect.withSpan("mcp.request.read_json_rpc"),324 Effect.withSpan("mcp.request.read_json_rpc"),
308 );325 );
@@ -669,23 +686,28 @@ export const mcpApp: Effect.Effect< · 360 unmodified lines
669 if (Result.isFailure(authResult)) {686 if (Result.isFailure(authResult)) {
670 yield* annotateMcpRequest(request, {687 yield* annotateMcpRequest(request, {
671 token: null,688 token: null,
672 parseBody: request.method === "POST",689 parseBody: false,
673 });690 });
674 return yield* authTemporarilyUnavailable(authResult.failure);691 return yield* authTemporarilyUnavailable(authResult.failure);
675 }692 }
676 const authValue = authResult.success;693 const authValue = authResult.success;
694 const bodyTooLarge =
695 request.method === "POST" && isBodyTooLarge(request, MAX_MCP_JSON_RPC_BODY_BYTES);
677 696
678 // Annotate before dispatch so even 401s show up with what we know. Only697 // Annotate before dispatch so even 401s show up with what we know. Only
679 // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE698 // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE
680 // don't carry one.699 // don't carry one.
681 yield* annotateMcpRequest(request, {700 yield* annotateMcpRequest(request, {
682 token: isMcpAuthorized(authValue) ? authValue.token : null,701 token: isMcpAuthorized(authValue) ? authValue.token : null,
683 parseBody: request.method === "POST",702 parseBody: request.method === "POST" && isMcpAuthorized(authValue) && !bodyTooLarge,
684 });703 });
685 704
686 if (isMcpUnauthorized(authValue)) {705 if (isMcpUnauthorized(authValue)) {
687 return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);706 return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL);
688 }707 }
708 if (bodyTooLarge) {
709 return jsonRpcError(413, -32000, "Request body too large");
710 }
689 const token = authValue.token;711 const token = authValue.token;
690 switch (request.method) {712 switch (request.method) {
691 case "POST":713 case "POST":
apps/cloud/src/sentry-tunnel.ts+82−0
@@ -0,0 +1,82 @@
1import { Data, Effect, Schema } from "effect";
2
3export const MAX_SENTRY_ENVELOPE_BODY_BYTES = 200_000;
4
5class SentryTunnelError extends Data.TaggedError("SentryTunnelError")<{
6 readonly cause?: unknown;
7}> {}
8
9const SentryEnvelopeHeader = Schema.Struct({
10 dsn: Schema.optional(Schema.String),
11});
12
13const decodeSentryEnvelopeHeader = Schema.decodeUnknownEffect(
14 Schema.fromJsonString(SentryEnvelopeHeader),
15);
16
17const badSentryEnvelopeResponse = () => new Response("bad envelope", { status: 400 });
18
19const 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
26const 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
42export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { handleSentryTunnelRequest, MAX_SENTRY_ENVELOPE_BODY_BYTES } from "./sentry-tunnel";
5
6describe("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 @@
1import { env } from "cloudflare:workers";1import { env } from "cloudflare:workers";
2import { createMiddleware, createStart } from "@tanstack/react-start";2import { createMiddleware, createStart } from "@tanstack/react-start";
3import { Data, Effect, Schema } from "effect";3import { Effect } from "effect";
4import { handleApiRequest } from "./api";4import { handleApiRequest } from "./api";
5import { mcpFetch } from "./mcp";5import { mcpFetch } from "./mcp";
6import { handleSentryTunnelRequest } from "./sentry-tunnel";
6 7
7// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
8// Marketing routes — proxied to the marketing worker via service binding9// Marketing routes — proxied to the marketing worker via service binding
@@ -85,58 +86,6 @@ const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( · 76 unmodified lines
85 },86 },
86);87);
87 88
88class SentryTunnelError extends Data.TaggedError("SentryTunnelError")<{
89 readonly cause?: unknown;
90}> {}
91
92const SentryEnvelopeHeader = Schema.Struct({
93 dsn: Schema.optional(Schema.String),
94});
95
96const decodeSentryEnvelopeHeader = Schema.decodeUnknownEffect(
97 Schema.fromJsonString(SentryEnvelopeHeader),
98);
99
100const badSentryEnvelopeResponse = () => new Response("bad envelope", { status: 400 });
101
102const 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
140// ---------------------------------------------------------------------------89// ---------------------------------------------------------------------------
141// PostHog reverse proxy — the browser SDK targets a build-randomized90// PostHog reverse proxy — the browser SDK targets a build-randomized
142// first-party path and we forward to PostHog's ingest + asset hosts. Keeps91// 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
11 },11 },
12 ],12 ],
13 "limits": {13 "limits": {
14 "cpu_ms": 300000,14 "cpu_ms": 30000,
15 },15 },
16 "observability": {16 "observability": {
17 "enabled": true,17 "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
34const CLI_BIN_DIR = join(SETTINGS_DIR, "bin");34const CLI_BIN_DIR = join(SETTINGS_DIR, "bin");
35const CLI_BIN_PATH = join(CLI_BIN_DIR, process.platform === "win32" ? "executor.exe" : "executor");35const CLI_BIN_PATH = join(CLI_BIN_DIR, process.platform === "win32" ? "executor.exe" : "executor");
36 36
37const 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
37// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
38// CLI install — copy sidecar to ~/.executor/bin and patch shell PATH46// CLI install — copy sidecar to ~/.executor/bin and patch shell PATH
39// ---------------------------------------------------------------------------47// ---------------------------------------------------------------------------
40 48
41const getInstalledCliVersion = (): string | null => {49const getCliVersion = (path: string): string | null => {
42 if (!existsSync(CLI_BIN_PATH)) return null;50 if (!existsSync(path)) return null;
43 try {51 try {
44 const { execFileSync } = require("node:child_process") as typeof import("node:child_process");52 const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
45 return execFileSync(CLI_BIN_PATH, ["--version"], {53 return execFileSync(path, ["--version"], {
46 timeout: 5000,54 timeout: 5000,
47 encoding: "utf-8",55 encoding: "utf-8",
48 }).trim();56 }).trim();
@@ -51,6 +59,8 @@ const getInstalledCliVersion = (): string | null => { · 2 unmodified lines
51 }59 }
52};60};
53 61
62const getInstalledCliVersion = (): string | null => getCliVersion(CLI_BIN_PATH);
63
54const installCli = (): void => {64const installCli = (): void => {
55 if (isDev) return;65 if (isDev) return;
56 66
@@ -58,7 +68,7 @@ const installCli = (): void => { · 1 unmodified line
58 if (!existsSync(sidecar)) return;68 if (!existsSync(sidecar)) return;
59 69
60 // Check if installed version is already same or newer70 // Check if installed version is already same or newer
61 const appVersion = app.getVersion();71 const bundledVersion = getCliVersion(sidecar) ?? app.getVersion();
62 const installedVersion = getInstalledCliVersion();72 const installedVersion = getInstalledCliVersion();
63 if (installedVersion) {73 if (installedVersion) {
64 const parse = (v: string) =>74 const parse = (v: string) =>
@@ -70,7 +80,7 @@ const installCli = (): void => { · 5 unmodified lines
70 return isNaN(n) ? 0 : n;80 return isNaN(n) ? 0 : n;
71 });81 });
72 const installed = parse(installedVersion);82 const installed = parse(installedVersion);
73 const bundled = parse(appVersion);83 const bundled = parse(bundledVersion);
74 const len = Math.max(installed.length, bundled.length);84 const len = Math.max(installed.length, bundled.length);
75 let cmp = 0;85 let cmp = 0;
76 for (let i = 0; i < len && cmp === 0; i++) {86 for (let i = 0; i < len && cmp === 0; i++) {
@@ -86,7 +96,7 @@ const installCli = (): void => { · 9 unmodified lines
86 chmodSync(CLI_BIN_PATH, 0o755);96 chmodSync(CLI_BIN_PATH, 0o755);
87 } catch {}97 } catch {}
88 console.log(98 console.log(
89 `Installed executor CLI ${appVersion} to ${CLI_BIN_PATH}` +99 `Installed executor CLI ${bundledVersion} to ${CLI_BIN_PATH}` +
90 (installedVersion ? ` (was ${installedVersion})` : ""),100 (installedVersion ? ` (was ${installedVersion})` : ""),
91 );101 );
92 102
@@ -543,7 +553,7 @@ const loadingHTML = (scopePath: string): string => { · 450 unmodified lines
543 const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";553 const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
544 const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)";554 const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)";
545 const barTrack = isDark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";555 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);
547 557
548 return `<!DOCTYPE html>558 return `<!DOCTYPE html>
549<html>559<html>
@@ -683,7 +693,7 @@ const errorHTML = (message: string): string => `<!DOCTYPE html> · 133 unmodified lines
683<body>693<body>
684 <div class="container">694 <div class="container">
685 <h2>Failed to start server</h2>695 <h2>Failed to start server</h2>
686 <pre>${message.replace(/</g, "&lt;")}</pre>696 <pre>${htmlEscape(message)}</pre>
687 <button onclick="window.electronAPI.selectScope()">Choose Different Folder</button>697 <button onclick="window.electronAPI.selectScope()">Choose Different Folder</button>
688 </div>698 </div>
689</body>699</body>
@@ -704,9 +714,9 @@ const welcomeHTML = (): string => { · 14 unmodified lines
704 const recentItems = settings.recentScopes714 const recentItems = settings.recentScopes
705 .map(715 .map(
706 (s) =>716 (s) =>
707 `<button class="scope-btn" onclick="window.electronAPI.switchScope('${s.replace(/'/g, "\\'")}')">717 `<button class="scope-btn" data-scope="${htmlEscape(s)}">
708 <span class="name">${basename(s)}</span>718 <span class="name">${htmlEscape(basename(s))}</span>
709 <span class="path">${s}</span>719 <span class="path">${htmlEscape(s)}</span>
710 </button>`,720 </button>`,
711 )721 )
712 .join("");722 .join("");
@@ -765,6 +775,14 @@ const welcomeHTML = (): string => { · 52 unmodified lines
765 : ""775 : ""
766 }776 }
767 </div>777 </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>
768</body>786</body>
769</html>`;787</html>`;
770};788};
packages/core/api/src/handlers/tools.ts+4−5
@@ -12,12 +12,11 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 11 unmodified lines
12 capture(12 capture(
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 const executor = yield* ExecutorService;14 const executor = yield* ExecutorService;
15 // Tools page is a management view — include policy-blocked tools15 // Keep the all-tools view bounded to metadata already available
16 // (so users can unblock them) and load annotations so the row can16 // from discovery. Per-source detail loads annotations for the
17 // show the plugin's default approval state when no user rule17 // smaller source-local management view.
18 // matches. Mirrors the per-source `sources.tools` handler.
19 const tools = yield* executor.tools.list({18 const tools = yield* executor.tools.list({
20 includeAnnotations: true,19 includeAnnotations: false,
21 includeBlocked: true,20 includeBlocked: true,
22 });21 });
23 return tools.map((t) => ({22 return tools.map((t) => ({
packages/core/execution/src/description.test.ts+5−4
@@ -70,10 +70,11 @@ describe("buildExecuteDescription", () => { · 69 unmodified lines
70 expect(description).toContain("Execute TypeScript in a sandboxed runtime");70 expect(description).toContain("Execute TypeScript in a sandboxed runtime");
71 // The namespaces section header.71 // The namespaces section header.
72 expect(description).toContain("## Available namespaces");72 expect(description).toContain("## Available namespaces");
73 // Each source renders with its ACTUAL id (not pluginId / name / UUID).73 // Each source renders with its ACTUAL id, without display labels or plugin ids.
74 expect(description).toContain("`github` — GitHub");74 expect(description).toContain("- `github`");
75 expect(description).toContain("`slack` — Slack Workspace");75 expect(description).toContain("- `slack`");
76 // And the plugin ids must NOT leak into the namespace list.76 expect(description).not.toContain("GitHub");
77 expect(description).not.toContain("Slack Workspace");
77 expect(description).not.toContain("`github-plugin`");78 expect(description).not.toContain("`github-plugin`");
78 expect(description).not.toContain("`slack-plugin`");79 expect(description).not.toContain("`slack-plugin`");
79 80
packages/core/execution/src/description.ts+5−3
@@ -63,10 +63,12 @@ const formatDescription = (sources: readonly Source[]): string => { · 62 unmodified lines
63 lines.push("");63 lines.push("");
64 lines.push("## Available namespaces");64 lines.push("## Available namespaces");
65 lines.push("");65 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);
67 for (const source of sorted) {67 for (const source of sorted) {
68 const label = source.name;68 lines.push(`- \`${source.id}\``);
69 lines.push(`- \`${source.id}\`${label !== source.id ? ` — ${label}` : ""}`);69 }
70 if (sources.length > sorted.length) {
71 lines.push(`- ... ${sources.length - sorted.length} more`);
70 }72 }
71 }73 }
72 74
packages/core/sdk/src/client.ts+9−3
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
20} from "react";20} from "react";
21import { HttpApi } from "effect/unstable/httpapi";21import { HttpApi } from "effect/unstable/httpapi";
22import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";22import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
23import { FetchHttpClient } from "effect/unstable/http";23import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
24import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";24import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
25 25
26// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
@@ -191,7 +191,7 @@ export interface CreatePluginAtomClientOptions { · 164 unmodified lines
191 /** Override the base URL. Defaults to `/api` (host strips this prefix191 /** Override the base URL. Defaults to `/api` (host strips this prefix
192 * when forwarding to the Effect handler) — same convention as the192 * when forwarding to the Effect handler) — same convention as the
193 * core `ExecutorApiClient`. */193 * core `ExecutorApiClient`. */
194 readonly baseUrl?: string;194 readonly baseUrl?: string | (() => string);
195}195}
196 196
197/**197/**
@@ -217,7 +217,13 @@ export const createPluginAtomClient = < · 19 unmodified lines
217 return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, {217 return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, {
218 api: bundle,218 api: bundle,
219 httpClient: FetchHttpClient.layer,219 httpClient: FetchHttpClient.layer,
220 baseUrl,220 ...(typeof baseUrl === "function"
221 ? {
222 transformClient: HttpClient.mapRequest((request) =>
223 HttpClientRequest.prependUrl(request, baseUrl()),
224 ),
225 }
226 : { baseUrl }),
221 });227 });
222};228};
223 229
packages/core/sdk/src/oxlint-plugin-executor.test.ts+73−0
@@ -0,0 +1,73 @@
1import { describe, expect, it } from "@effect/vitest";
2import { spawnSync } from "node:child_process";
3import { mkdir, rm, writeFile } from "node:fs/promises";
4import { join, resolve } from "node:path";
5
6const repoRoot = resolve(import.meta.dirname, "../../../..");
7
8const 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
27describe("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
48 readonly { readonly id: string; readonly name: string }[],48 readonly { readonly id: string; readonly name: string }[],
49 StorageFailure49 StorageFailure
50 >;50 >;
51 /** If false, `get` only runs for routed secret rows owned by this51 /** Whether the provider may be asked during id-only fallback resolution.
52 * provider and is skipped by provider-enumeration fallback lookup. */52 * Providers whose own auth depends on `ctx.secrets.get` should opt out to
53 * avoid recursive fallback through themselves. */
53 readonly allowFallback?: boolean;54 readonly allowFallback?: boolean;
54}55}
55 56
packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+11−0
@@ -7,6 +7,7 @@ const IPC_PREFIX = "@@executor-ipc@@"; · 6 unmodified lines
7 7
8const pendingToolCalls = new Map();8const pendingToolCalls = new Map();
9let started = false;9let started = false;
10let ipcNonce = "";
10 11
11/** @type {string[]} */12/** @type {string[]} */
12const logs = [];13const logs = [];
@@ -31,6 +32,7 @@ const createToolCaller = (toolPath) => (args) => · 18 unmodified lines
31 32
32 writeIpcMessage({33 writeIpcMessage({
33 type: "tool_call",34 type: "tool_call",
35 nonce: ipcNonce,
34 requestId,36 requestId,
35 toolPath,37 toolPath,
36 args: args === undefined ? {} : args,38 args: args === undefined ? {} : args,
@@ -105,6 +107,7 @@ const handleStart = (message) => { · 68 unmodified lines
105 if (started) {107 if (started) {
106 writeIpcMessage({108 writeIpcMessage({
107 type: "failed",109 type: "failed",
110 nonce: ipcNonce,
108 error: "start message already received",111 error: "start message already received",
109 logs,112 logs,
110 });113 });
@@ -112,11 +115,13 @@ const handleStart = (message) => { · 1 unmodified line
112 }115 }
113 116
114 started = true;117 started = true;
118 ipcNonce = typeof message.nonce === "string" ? message.nonce : "";
115 119
116 runUserCode(message.code)120 runUserCode(message.code)
117 .then((result) => {121 .then((result) => {
118 writeIpcMessage({122 writeIpcMessage({
119 type: "completed",123 type: "completed",
124 nonce: ipcNonce,
120 result,125 result,
121 logs,126 logs,
122 });127 });
@@ -124,6 +129,7 @@ const handleStart = (message) => { · 1 unmodified line
124 .catch((error) => {129 .catch((error) => {
125 writeIpcMessage({130 writeIpcMessage({
126 type: "failed",131 type: "failed",
132 nonce: ipcNonce,
127 error: toErrorMessage(error),133 error: toErrorMessage(error),
128 logs,134 logs,
129 });135 });
@@ -131,6 +137,10 @@ const handleStart = (message) => { · 1 unmodified line
131};137};
132 138
133const handleToolResult = (message) => {139const handleToolResult = (message) => {
140 if (message.nonce !== ipcNonce) {
141 return;
142 }
143
134 const pending = pendingToolCalls.get(message.requestId);144 const pending = pendingToolCalls.get(message.requestId);
135 if (!pending) {145 if (!pending) {
136 return;146 return;
@@ -193,6 +203,7 @@ const decodeLines = async () => { · 56 unmodified lines
193 } catch (error) {203 } catch (error) {
194 writeIpcMessage({204 writeIpcMessage({
195 type: "failed",205 type: "failed",
206 nonce: ipcNonce,
196 error: `invalid host message: ${toErrorMessage(error)}`,207 error: `invalid host message: ${toErrorMessage(error)}`,
197 logs,208 logs,
198 });209 });
packages/kernel/runtime-deno-subprocess/src/index.test.ts+20−0
@@ -137,6 +137,26 @@ describe.skipIf(!isDenoAvailable())("runtime-deno-subprocess", () => { · 136 unmodified lines
137 }),137 }),
138 );138 );
139 139
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
140 it.effect("respects timeout", () =>160 it.effect("respects timeout", () =>
141 Effect.gen(function* () {161 Effect.gen(function* () {
142 const executor = makeDenoSubprocessExecutor({162 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
55 55
56const WorkerToolCallMessage = Schema.Struct({56const WorkerToolCallMessage = Schema.Struct({
57 type: Schema.Literal("tool_call"),57 type: Schema.Literal("tool_call"),
58 nonce: Schema.String,
58 requestId: Schema.String,59 requestId: Schema.String,
59 toolPath: Schema.String,60 toolPath: Schema.String,
60 args: Schema.Unknown,61 args: Schema.Unknown,
@@ -62,12 +63,14 @@ const WorkerToolCallMessage = Schema.Struct({ · 1 unmodified line
62 63
63const WorkerCompletedMessage = Schema.Struct({64const WorkerCompletedMessage = Schema.Struct({
64 type: Schema.Literal("completed"),65 type: Schema.Literal("completed"),
66 nonce: Schema.String,
65 result: Schema.Unknown,67 result: Schema.Unknown,
66 logs: Schema.optional(Schema.Array(Schema.String)),68 logs: Schema.optional(Schema.Array(Schema.String)),
67});69});
68 70
69const WorkerFailedMessage = Schema.Struct({71const WorkerFailedMessage = Schema.Struct({
70 type: Schema.Literal("failed"),72 type: Schema.Literal("failed"),
73 nonce: Schema.String,
71 error: Schema.String,74 error: Schema.String,
72 logs: Schema.optional(Schema.Array(Schema.String)),75 logs: Schema.optional(Schema.Array(Schema.String)),
73});76});
@@ -126,9 +129,10 @@ const workerScriptPath = (): string => (cachedWorkerScriptPath ??= resolveWorker · 52 unmodified lines
126// ---------------------------------------------------------------------------129// ---------------------------------------------------------------------------
127 130
128type HostToWorkerMessage =131type HostToWorkerMessage =
129 | { type: "start"; code: string }132 | { type: "start"; code: string; nonce: string }
130 | {133 | {
131 type: "tool_result";134 type: "tool_result";
135 nonce: string;
132 requestId: string;136 requestId: string;
133 ok: boolean;137 ok: boolean;
134 value?: unknown;138 value?: unknown;
@@ -159,6 +163,7 @@ const executeInDeno = ( · 24 unmodified lines
159 const recoveredBody = recoverExecutionBody(code);163 const recoveredBody = recoverExecutionBody(code);
160 const denoExecutable = options.denoExecutable ?? defaultDenoExecutable();164 const denoExecutable = options.denoExecutable ?? defaultDenoExecutable();
161 const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);165 const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
166 const nonce = crypto.randomUUID();
162 167
163 return Effect.gen(function* () {168 return Effect.gen(function* () {
164 const runSync = Effect.runSync;169 const runSync = Effect.runSync;
@@ -189,9 +194,13 @@ const executeInDeno = ( · 24 unmodified lines
189 const line = rawLine.trim();194 const line = rawLine.trim();
190 if (!line.startsWith(IPC_PREFIX)) return;195 if (!line.startsWith(IPC_PREFIX)) return;
191 196
192 const decoded = decodeWorkerMessage(JSON.parse(line.slice(IPC_PREFIX.length)));197 try {
193 if (Option.isSome(decoded)) {198 const decoded = decodeWorkerMessage(JSON.parse(line.slice(IPC_PREFIX.length)));
194 runSync(Queue.offer(messages, decoded.value as WorkerToHostMessage));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.
195 }204 }
196 },205 },
197 onStderr: () => {},206 onStderr: () => {},
@@ -220,7 +229,7 @@ const executeInDeno = ( · 22 unmodified lines
220 });229 });
221 230
222 // Send code to the subprocess231 // Send code to the subprocess
223 writeMessage(worker.stdin, { type: "start", code: recoveredBody });232 writeMessage(worker.stdin, { type: "start", code: recoveredBody, nonce });
224 233
225 // Set up timeout — kills process and completes the deferred234 // Set up timeout — kills process and completes the deferred
226 const timer = setTimeout(() => {235 const timer = setTimeout(() => {
@@ -250,6 +259,7 @@ const executeInDeno = ( · 23 unmodified lines
250 Effect.map(259 Effect.map(
251 (value): HostToWorkerMessage => ({260 (value): HostToWorkerMessage => ({
252 type: "tool_result",261 type: "tool_result",
262 nonce,
253 requestId: msg.requestId,263 requestId: msg.requestId,
254 ok: true,264 ok: true,
255 value,265 value,
@@ -258,6 +268,7 @@ const executeInDeno = ( · 2 unmodified lines
258 Effect.catchCause((cause) =>268 Effect.catchCause((cause) =>
259 Effect.succeed<HostToWorkerMessage>({269 Effect.succeed<HostToWorkerMessage>({
260 type: "tool_result",270 type: "tool_result",
271 nonce,
261 requestId: msg.requestId,272 requestId: msg.requestId,
262 ok: false,273 ok: false,
263 error: causeMessage(cause),274 error: causeMessage(cause),
packages/kernel/runtime-dynamic-worker/src/executor.ts+19−9
@@ -90,7 +90,6 @@ const normalizeErrorObject = (error: Error) => ({ · 89 unmodified lines
90 __type: "Error" as const,90 __type: "Error" as const,
91 name: error.name,91 name: error.name,
92 message: error.message,92 message: error.message,
93 ...(typeof error.stack === "string" && error.stack.length > 0 ? { stack: error.stack } : {}),
94});93});
95 94
96const isNormalizedErrorObject = (95const isNormalizedErrorObject = (
@@ -260,9 +259,13 @@ const isPlainObject = (value: object): boolean => { · 163 unmodified lines
260 return proto === Object.prototype || proto === null;259 return proto === Object.prototype || proto === null;
261};260};
262 261
263const rehydrateBinary = (value: unknown): unknown => {262const rehydrateBinary = (value: unknown, seen = new WeakSet<object>()): unknown => {
264 if (value === null || typeof value !== "object") return value;263 if (value === null || typeof value !== "object") return value;
265 if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;264 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);
266 if (isBinaryEnvelope(value)) {269 if (isBinaryEnvelope(value)) {
267 if (value.kind === "file" && typeof value.name === "string") {270 if (value.kind === "file" && typeof value.name === "string") {
268 return new File([value.buffer], value.name, {271 return new File([value.buffer], value.name, {
@@ -272,20 +275,24 @@ const rehydrateBinary = (value: unknown): unknown => { · 3 unmodified lines
272 }275 }
273 return new Blob([value.buffer], { type: value.type });276 return new Blob([value.buffer], { type: value.type });
274 }277 }
275 if (Array.isArray(value)) return value.map(rehydrateBinary);278 if (Array.isArray(value)) return value.map((item) => rehydrateBinary(item, seen));
276 if (!isPlainObject(value)) return value;279 if (!isPlainObject(value)) return value;
277 const out: Record<string, unknown> = {};280 const out: Record<string, unknown> = {};
278 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {281 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
279 out[k] = rehydrateBinary(v);282 out[k] = rehydrateBinary(v, seen);
280 }283 }
281 return out;284 return out;
282};285};
283 286
284// Async because `Blob.arrayBuffer()` is async. Used on tool results before287// Async because `Blob.arrayBuffer()` is async. Used on tool results before
285// the dispatcher hands them back to the sandbox.288// the dispatcher hands them back to the sandbox.
286const encodeBinary = async (value: unknown): Promise<unknown> => {289const encodeBinary = async (value: unknown, seen = new WeakSet<object>()): Promise<unknown> => {
287 if (value === null || typeof value !== "object") return value;290 if (value === null || typeof value !== "object") return value;
288 if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;291 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);
289 if (typeof File !== "undefined" && value instanceof File) {296 if (typeof File !== "undefined" && value instanceof File) {
290 return {297 return {
291 __executorBinary: 1 as const,298 __executorBinary: 1 as const,
@@ -304,11 +311,11 @@ const encodeBinary = async (value: unknown): Promise<unknown> => { · 12 unmodified lines
304 buffer: await value.arrayBuffer(),311 buffer: await value.arrayBuffer(),
305 };312 };
306 }313 }
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)));
308 if (!isPlainObject(value)) return value;315 if (!isPlainObject(value)) return value;
309 const out: Record<string, unknown> = {};316 const out: Record<string, unknown> = {};
310 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {317 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);
312 }319 }
313 return out;320 return out;
314};321};
@@ -347,9 +354,12 @@ export class ToolDispatcher extends RpcTarget { · 32 unmodified lines
347 }354 }
348 355
349 async call(path: string, args: unknown): Promise<WorkerRpcResponse> {356 async call(path: string, args: unknown): Promise<WorkerRpcResponse> {
350 const decodedArgs = rehydrateBinary(args);
351 return this.#runPromise(357 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 })),
353 Effect.flatMap((value) =>363 Effect.flatMap((value) =>
354 Effect.tryPromise({364 Effect.tryPromise({
355 try: (): Promise<WorkerRpcResponse> =>365 try: (): Promise<WorkerRpcResponse> =>
packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+80−0
@@ -97,6 +97,22 @@ describe("ToolDispatcher", () => { · 96 unmodified lines
97 expect(result).toEqual({ ok: true, result: undefined });97 expect(result).toEqual({ ok: true, result: undefined });
98 });98 });
99 99
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
100 it("passes the tool path correctly", async () => {116 it("passes the tool path correctly", async () => {
101 let capturedPath = "";117 let capturedPath = "";
102 const invoker = makeInvoker(({ path }) => {118 const invoker = makeInvoker(({ path }) => {
@@ -241,6 +257,37 @@ describe("makeDynamicWorkerExecutor", () => { · 138 unmodified lines
241 expect(result.error).toBe("not authorized");257 expect(result.error).toBe("not authorized");
242 });258 });
243 259
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
244 it("surfaces object-shaped tool errors in execution result", async () => {291 it("surfaces object-shaped tool errors in execution result", async () => {
245 const executor = makeDynamicWorkerExecutor({ loader });292 const executor = makeDynamicWorkerExecutor({ loader });
246 const invoker = {293 const invoker = {
@@ -300,6 +347,39 @@ describe("makeDynamicWorkerExecutor", () => { · 53 unmodified lines
300 expect(result.result).toBe(30);347 expect(result.result).toBe(30);
301 });348 });
302 349
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
303 it("respects timeout", async () => {383 it("respects timeout", async () => {
304 const executor = makeDynamicWorkerExecutor({ loader, timeoutMs: 500 });384 const executor = makeDynamicWorkerExecutor({ loader, timeoutMs: 500 });
305 const invoker = makeInvoker(() => null);385 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
31 " __type: 'Error',",31 " __type: 'Error',",
32 " name: value.name,",32 " name: value.name,",
33 " message: value.message,",33 " message: value.message,",
34 " ...(typeof value.stack === 'string' && value.stack.length > 0 ? { stack: value.stack } : {}),",
35 " };",34 " };",
36 " }",35 " }",
37 " if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {",36 " 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
84 " const __isBinaryEnvelope = (v) =>",83 " const __isBinaryEnvelope = (v) =>",
85 " v && typeof v === 'object' && v.__executorBinary === 1 &&",84 " v && typeof v === 'object' && v.__executorBinary === 1 &&",
86 " v.buffer instanceof ArrayBuffer && typeof v.type === 'string';",85 " v.buffer instanceof ArrayBuffer && typeof v.type === 'string';",
87 " const __encodeBinary = async (value) => {",86 " const __encodeBinary = async (value, seen = new WeakSet()) => {",
88 " if (value === null || typeof value !== 'object') return value;",87 " if (value === null || typeof value !== 'object') return value;",
89 " if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;",88 " 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);",
90 " if (typeof File !== 'undefined' && value instanceof File) {",91 " if (typeof File !== 'undefined' && value instanceof File) {",
91 " return {",92 " return {",
92 " __executorBinary: 1,",93 " __executorBinary: 1,",
@@ -100,15 +101,17 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 7 unmodified lines
100 " if (value instanceof Blob) {",101 " if (value instanceof Blob) {",
101 " return { __executorBinary: 1, kind: 'blob', type: value.type, buffer: await value.arrayBuffer() };",102 " return { __executorBinary: 1, kind: 'blob', type: value.type, buffer: await value.arrayBuffer() };",
102 " }",103 " }",
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)));",
104 " if (!__isPlainObject(value)) return value;",105 " if (!__isPlainObject(value)) return value;",
105 " const out = {};",106 " 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);",
107 " return out;",108 " return out;",
108 " };",109 " };",
109 " const __decodeBinary = (value) => {",110 " const __decodeBinary = (value, seen = new WeakSet()) => {",
110 " if (value === null || typeof value !== 'object') return value;",111 " if (value === null || typeof value !== 'object') return value;",
111 " if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;",112 " 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);",
112 " if (__isBinaryEnvelope(value)) {",115 " if (__isBinaryEnvelope(value)) {",
113 " if (value.kind === 'file' && typeof value.name === 'string') {",116 " if (value.kind === 'file' && typeof value.name === 'string') {",
114 " return new File([value.buffer], value.name, {",117 " return new File([value.buffer], value.name, {",
@@ -118,10 +121,10 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 3 unmodified lines
118 " }",121 " }",
119 " return new Blob([value.buffer], { type: value.type });",122 " return new Blob([value.buffer], { type: value.type });",
120 " }",123 " }",
121 " if (Array.isArray(value)) return value.map(__decodeBinary);",124 " if (Array.isArray(value)) return value.map((item) => __decodeBinary(item, seen));",
122 " if (!__isPlainObject(value)) return value;",125 " if (!__isPlainObject(value)) return value;",
123 " const out = {};",126 " 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);",
125 " return out;",128 " return out;",
126 " };",129 " };",
127 " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",130 " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
@@ -135,9 +138,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 7 unmodified lines
135 " return (async () => {",138 " return (async () => {",
136 " const encoded = await __encodeBinary(args[0]);",139 " const encoded = await __encodeBinary(args[0]);",
137 " const data = await __dispatcher.call(toolPath, encoded);",140 " const data = await __dispatcher.call(toolPath, encoded);",
138 " if (!data.ok) {",141 " if (!data.ok) throw new Error(data.error && data.error.message ? data.error.message : 'Tool execution failed');",
139 " throw (data.error && data.error.primary !== null ? data.error.primary : data.error.message);",
140 " }",
141 " return __decodeBinary(data.result);",142 " return __decodeBinary(data.result);",
142 " })();",143 " })();",
143 " },",144 " },",
packages/kernel/runtime-quickjs/README.md+1−1
@@ -35,7 +35,7 @@ const engine = createExecutionEngine({ · 34 unmodified lines
35 35
36| Option | Default | Description |36| Option | Default | Description |
37| ------------------- | ---------- | --------------------------------- |37| ------------------- | ---------- | --------------------------------- |
38| `timeoutMs` | `5_000` | Max wall-clock time per execution |38| `timeoutMs` | `300_000` | Max wall-clock time per execution |
39| `memoryLimitBytes` | `64 * 1MB` | Max memory the VM can allocate |39| `memoryLimitBytes` | `64 * 1MB` | Max memory the VM can allocate |
40| `maxStackSizeBytes` | `1 * 1MB` | Max call-stack depth |40| `maxStackSizeBytes` | `1 * 1MB` | Max call-stack depth |
41 41
packages/kernel/runtime-quickjs/src/index.test.ts+16−0
@@ -160,6 +160,22 @@ describe("quickjs executor", () => { · 159 unmodified lines
160 }),160 }),
161 );161 );
162 162
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
163 it.effect("passes tool result into next tool call", () =>179 it.effect("passes tool result into next tool call", () =>
164 Effect.gen(function* () {180 Effect.gen(function* () {
165 const invoker = makeTestInvoker({181 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
37 readonly message: string;37 readonly message: string;
38}> {}38}> {}
39 39
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.
40const DEFAULT_TIMEOUT_MS = 5 * 60_000;42const DEFAULT_TIMEOUT_MS = 5 * 60_000;
43const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
44const DEFAULT_MAX_STACK_SIZE_BYTES = 1 * 1024 * 1024;
41const EXECUTION_FILENAME = "executor-quickjs-runtime.js";45const EXECUTION_FILENAME = "executor-quickjs-runtime.js";
42 46
43const toError = (cause: unknown): Error =>47const toError = (cause: unknown): Error =>
@@ -297,13 +301,8 @@ const evaluateInQuickJs = async ( · 253 unmodified lines
297 const runtime = QuickJS.newRuntime();301 const runtime = QuickJS.newRuntime();
298 302
299 try {303 try {
300 if (options.memoryLimitBytes !== undefined) {304 runtime.setMemoryLimit(options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES);
301 runtime.setMemoryLimit(options.memoryLimitBytes);305 runtime.setMaxStackSize(options.maxStackSizeBytes ?? DEFAULT_MAX_STACK_SIZE_BYTES);
302 }
303
304 if (options.maxStackSizeBytes !== undefined) {
305 runtime.setMaxStackSize(options.maxStackSizeBytes);
306 }
307 306
308 runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));307 runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));
309 308
packages/plugins/google-discovery/package.json+6−0
@@ -37,6 +37,12 @@ · 36 unmodified lines
37 "types": "./dist/sdk/index.d.ts",37 "types": "./dist/sdk/index.d.ts",
38 "default": "./dist/core.js"38 "default": "./dist/core.js"
39 }39 }
40 },
41 "./client": {
42 "import": {
43 "types": "./dist/react/plugin-client.d.ts",
44 "default": "./dist/client.js"
45 }
40 }46 }
41 }47 }
42 },48 },
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
3import { GoogleDiscoveryGroup } from "../api/group";3import { GoogleDiscoveryGroup } from "../api/group";
4 4
5export const GoogleDiscoveryClient = createPluginAtomClient(GoogleDiscoveryGroup, {5export const GoogleDiscoveryClient = createPluginAtomClient(GoogleDiscoveryGroup, {
6 baseUrl: getBaseUrl(),6 baseUrl: getBaseUrl,
7});7});
packages/plugins/google-discovery/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
7 },8 },
8 format: ["esm"],9 format: ["esm"],
9 dts: false,10 dts: false,
packages/plugins/graphql/package.json+6−0
@@ -39,6 +39,12 @@ · 38 unmodified lines
39 "default": "./dist/core.js"39 "default": "./dist/core.js"
40 }40 }
41 },41 },
42 "./client": {
43 "import": {
44 "types": "./dist/react/plugin-client.d.ts",
45 "default": "./dist/client.js"
46 }
47 },
42 "./testing": {48 "./testing": {
43 "import": {49 "import": {
44 "types": "./dist/testing/index.d.ts",50 "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
3import { GraphqlGroup } from "../api/group";3import { GraphqlGroup } from "../api/group";
4 4
5export const GraphqlClient = createPluginAtomClient(GraphqlGroup, {5export const GraphqlClient = createPluginAtomClient(GraphqlGroup, {
6 baseUrl: getBaseUrl(),6 baseUrl: getBaseUrl,
7});7});
packages/plugins/graphql/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
7 testing: "src/testing/index.ts",8 testing: "src/testing/index.ts",
8 },9 },
9 format: ["esm"],10 format: ["esm"],
packages/plugins/keychain/src/index.ts+5−3
@@ -18,8 +18,9 @@ import { makeKeychainProvider, scopedKeychainServiceName } from "./provider"; · 17 unmodified lines
18// error, but `setPassword` fails because the secret-service backend18// error, but `setPassword` fails because the secret-service backend
19// isn't actually reachable. Writing is the capability the executor19// isn't actually reachable. Writing is the capability the executor
20// cares about, so test it directly.20// cares about, so test it directly.
21const PROBE_ACCOUNT = "__executor_keychain_probe__";
22const PROBE_VALUE = "probe";21const PROBE_VALUE = "probe";
22const probeAccount = (): string =>
23 `__executor_keychain_probe__:${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
23 24
24// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
25// Re-exports26// Re-exports
@@ -88,9 +89,10 @@ export const keychainPlugin = definePlugin((options?: KeychainPluginConfig) => ( · 62 unmodified lines
88 Effect.gen(function* () {89 Effect.gen(function* () {
89 const baseServiceName = resolveServiceName(options?.serviceName);90 const baseServiceName = resolveServiceName(options?.serviceName);
90 const probeServiceName = scopedKeychainServiceName(baseServiceName, ctx.scopes[0]!.id);91 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(
92 Effect.andThen(94 Effect.andThen(
93 deletePassword(probeServiceName, PROBE_ACCOUNT).pipe(Effect.catch(() => Effect.void)),95 deletePassword(probeServiceName, account).pipe(Effect.catch(() => Effect.void)),
94 ),96 ),
95 Effect.as(true),97 Effect.as(true),
96 Effect.catch(() =>98 Effect.catch(() =>
packages/plugins/mcp/package.json+7−0
@@ -39,6 +39,12 @@ · 38 unmodified lines
39 "default": "./dist/core.js"39 "default": "./dist/core.js"
40 }40 }
41 },41 },
42 "./client": {
43 "import": {
44 "types": "./dist/react/plugin-client.d.ts",
45 "default": "./dist/client.js"
46 }
47 },
42 "./testing": {48 "./testing": {
43 "import": {49 "import": {
44 "types": "./dist/testing/index.d.ts",50 "types": "./dist/testing/index.d.ts",
@@ -55,6 +61,7 @@ · 10 unmodified lines
55 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"61 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
56 },62 },
57 "dependencies": {63 "dependencies": {
64 "@cfworker/json-schema": "^4.1.1",
58 "@effect/platform-node": "catalog:",65 "@effect/platform-node": "catalog:",
59 "@executor-js/config": "workspace:*",66 "@executor-js/config": "workspace:*",
60 "@executor-js/sdk": "workspace:*",67 "@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
3import { McpGroup } from "../api/group";3import { McpGroup } from "../api/group";
4 4
5export const McpClient = createPluginAtomClient(McpGroup, {5export const McpClient = createPluginAtomClient(McpGroup, {
6 baseUrl: getBaseUrl(),6 baseUrl: getBaseUrl,
7});7});
packages/plugins/mcp/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
7 testing: "src/testing/index.ts",8 testing: "src/testing/index.ts",
8 },9 },
9 format: ["esm"],10 format: ["esm"],
packages/plugins/onepassword/package.json+6−0
@@ -36,6 +36,12 @@ · 35 unmodified lines
36 "types": "./dist/sdk/index.d.ts",36 "types": "./dist/sdk/index.d.ts",
37 "default": "./dist/core.js"37 "default": "./dist/core.js"
38 }38 }
39 },
40 "./client": {
41 "import": {
42 "types": "./dist/react/plugin-client.d.ts",
43 "default": "./dist/client.js"
44 }
39 }45 }
40 }46 }
41 },47 },
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
3import { OnePasswordGroup } from "../api/group";3import { OnePasswordGroup } from "../api/group";
4 4
5export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, {5export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, {
6 baseUrl: getBaseUrl(),6 baseUrl: getBaseUrl,
7});7});
packages/plugins/onepassword/src/sdk/plugin.ts+20−4
@@ -159,6 +159,15 @@ const getServiceFromConfig = ( · 158 unmodified lines
159 Effect.flatMap((resolved) => makeOnePasswordService(resolved, { timeoutMs, preferSdk })),159 Effect.flatMap((resolved) => makeOnePasswordService(resolved, { timeoutMs, preferSdk })),
160 );160 );
161 161
162const 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
162// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
163// SecretProvider — read-only, resolves op:// URIs or vaultId-based lookups172// SecretProvider — read-only, resolves op:// URIs or vaultId-based lookups
164// ---------------------------------------------------------------------------173// ---------------------------------------------------------------------------
@@ -170,6 +179,7 @@ const makeProvider = ( · 5 unmodified lines
170): SecretProvider => ({179): SecretProvider => ({
171 key: "onepassword",180 key: "onepassword",
172 writable: false,181 writable: false,
182 allowFallback: false,
173 183
174 // 1Password vaults are named in the stored config; the executor-scope184 // 1Password vaults are named in the stored config; the executor-scope
175 // arg isn't used for routing here. A future refactor could let the185 // arg isn't used for routing here. A future refactor could let the
@@ -179,9 +189,8 @@ const makeProvider = ( · 3 unmodified lines
179 Effect.flatMap((config) => {189 Effect.flatMap((config) => {
180 if (!config) return Effect.succeed(null as string | null);190 if (!config) return Effect.succeed(null as string | null);
181 191
182 const uri = secretId.startsWith("op://")192 const uri = configuredVaultUri(config, secretId);
183 ? secretId193 if (uri === null) return Effect.succeed(null as string | null);
184 : `op://${config.vaultId}/${secretId}/${CREDENTIAL_FIELD}`;
185 194
186 return getServiceFromConfig(config, ctx, timeoutMs, preferSdk).pipe(195 return getServiceFromConfig(config, ctx, timeoutMs, preferSdk).pipe(
187 Effect.flatMap((svc) => svc.resolveSecret(uri)),196 Effect.flatMap((svc) => svc.resolveSecret(uri)),
@@ -261,8 +270,15 @@ const makeOnePasswordExtension = ( · 73 unmodified lines
261 message: "1Password is not configured",270 message: "1Password is not configured",
262 });271 });
263 }272 }
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 }
264 const svc = yield* getServiceFromConfig(config, ctx, timeoutMs, preferSdk);280 const svc = yield* getServiceFromConfig(config, ctx, timeoutMs, preferSdk);
265 return yield* svc.resolveSecret(uri);281 return yield* svc.resolveSecret(scopedUri);
266 }),282 }),
267 };283 };
268};284};
packages/plugins/onepassword/src/sdk/service.ts+23−15
@@ -1,4 +1,4 @@
1import { Context, Duration, Effect } from "effect";1import { Context, Duration, Effect, Semaphore } from "effect";
2import * as op from "@1password/op-js";2import * as op from "@1password/op-js";
3 3
4import { OnePasswordError } from "./errors";4import { OnePasswordError } from "./errors";
@@ -138,26 +138,34 @@ export const makeNativeSdkService = ( · 133 unmodified lines
138// CLI backend — uses @1password/op-js (shells out to `op` CLI)138// CLI backend — uses @1password/op-js (shells out to `op` CLI)
139// ---------------------------------------------------------------------------139// ---------------------------------------------------------------------------
140 140
141const cliAuthLock = Semaphore.makeUnsafe(1);
142
141export const makeCliService = (143export const makeCliService = (
142 auth: ResolvedAuth,144 auth: ResolvedAuth,
143): Effect.Effect<OnePasswordService, OnePasswordError> =>145): Effect.Effect<OnePasswordService, OnePasswordError> =>
144 Effect.sync(() => {146 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
152 const wrapSync = <A>(fn: () => A, operation: string): Effect.Effect<A, OnePasswordError> =>147 const wrapSync = <A>(fn: () => A, operation: string): Effect.Effect<A, OnePasswordError> =>
153 Effect.try({148 cliAuthLock
154 try: fn,149 .withPermits(1)(
155 catch: () =>150 Effect.try({
156 new OnePasswordError({151 try: () => {
157 operation,152 if (auth.kind === "service-account") {
158 message: `1Password CLI ${operation} failed`,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 }),
159 }),166 }),
160 }).pipe(Effect.withSpan(`onepassword.cli.${operation}`));167 )
168 .pipe(Effect.withSpan(`onepassword.cli.${operation}`));
161 169
162 return OnePasswordServiceTag.of({170 return OnePasswordServiceTag.of({
163 resolveSecret: (uri) => wrapSync(() => op.read.parse(uri), "secret resolution"),171 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
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
7 },8 },
8 format: ["esm"],9 format: ["esm"],
9 dts: false,10 dts: false,
packages/plugins/openapi/package.json+6−0
@@ -39,6 +39,12 @@ · 38 unmodified lines
39 "default": "./dist/core.js"39 "default": "./dist/core.js"
40 }40 }
41 },41 },
42 "./client": {
43 "import": {
44 "types": "./dist/react/plugin-client.d.ts",
45 "default": "./dist/client.js"
46 }
47 },
42 "./testing": {48 "./testing": {
43 "import": {49 "import": {
44 "types": "./dist/testing/index.d.ts",50 "types": "./dist/testing/index.d.ts",
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+3−24
@@ -76,12 +76,8 @@ import { · 75 unmodified lines
76 oauth2ConnectionSlot,76 oauth2ConnectionSlot,
77 queryParamBindingSlot,77 queryParamBindingSlot,
78} from "../sdk/store";78} from "../sdk/store";
79import {79import { ConfiguredHeaderBinding, OAuth2SourceConfig, type ServerInfo } from "../sdk/types";
80 ConfiguredHeaderBinding,80import { expandServerUrlOptions } from "../sdk/openapi-utils";
81 OAuth2SourceConfig,
82 type ServerInfo,
83 type ServerVariable,
84} from "../sdk/types";
85 81
86export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";82export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
87export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";83export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
@@ -336,24 +332,7 @@ export default function AddOpenApiSource(props: { · 248 unmodified lines
336 // ---- Derived state ----332 // ---- Derived state ----
337 333
338 const expandServerOptions = (server: ServerInfo) => {334 const expandServerOptions = (server: ServerInfo) => {
339 const vars: Record<string, ServerVariable> = Option.getOrElse(335 return expandServerUrlOptions(server).map((value) => ({
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) => ({
357 value,336 value,
358 label: value,337 label: value,
359 }));338 }));
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
3import { OpenApiGroup } from "../api/group";3import { OpenApiGroup } from "../api/group";
4 4
5export const OpenApiClient = createPluginAtomClient(OpenApiGroup, {5export const OpenApiClient = createPluginAtomClient(OpenApiGroup, {
6 baseUrl: getBaseUrl(),6 baseUrl: getBaseUrl,
7});7});
packages/plugins/openapi/src/sdk/openapi-utils.ts+29−1
@@ -7,6 +7,7 @@ · 6 unmodified lines
7import { Option } from "effect";7import { Option } from "effect";
8import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";8import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
9import type { ParsedDocument } from "./parse";9import type { ParsedDocument } from "./parse";
10import type { ServerVariable } from "./types";
10 11
11// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
12// Type aliases — collapse V3 / V3_1 unions into single names13// 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
63 return out;64 return out;
64};65};
65 66
67export const OPENAPI_MAX_SERVER_VARIABLE_OPTIONS = 64;
68
66type ServerLike = {69type ServerLike = {
67 url: string;70 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
74export 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;
69};97};
70 98
71export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {99export const resolveBaseUrl = (servers: readonly ServerLike[]): string => {
packages/plugins/openapi/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
7 testing: "src/testing/index.ts",8 testing: "src/testing/index.ts",
8 },9 },
9 format: ["esm"],10 format: ["esm"],
packages/react/src/api/client.tsx+4−2
@@ -1,5 +1,5 @@
1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2import { FetchHttpClient } from "effect/unstable/http";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3import { ExecutorApi } from "@executor-js/api";3import { ExecutorApi } from "@executor-js/api";
4 4
5import { getBaseUrl } from "./base-url";5import { getBaseUrl } from "./base-url";
@@ -11,7 +11,9 @@ import { getBaseUrl } from "./base-url"; · 5 unmodified lines
11const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorApiClient", {11const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorApiClient", {
12 api: ExecutorApi,12 api: ExecutorApi,
13 httpClient: FetchHttpClient.layer,13 httpClient: FetchHttpClient.layer,
14 baseUrl: getBaseUrl(),14 transformClient: HttpClient.mapRequest((request) =>
15 HttpClientRequest.prependUrl(request, getBaseUrl()),
16 ),
15});17});
16 18
17export { ExecutorApiClient };19export { ExecutorApiClient };
packages/react/src/components/chart.test.ts+18−0
@@ -0,0 +1,18 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { chartCssColorValue, chartCssVariableName } from "./chart";
4
5describe("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
25 config: ChartConfig;25 config: ChartConfig;
26};26};
27 27
28const cssIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_-]*$/;
29const 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
32export const chartCssVariableName = (key: string): string | null =>
33 cssIdentifierPattern.test(key) ? `--color-${key}` : null;
34
35export const chartCssColorValue = (color: string): string | null => {
36 const trimmed = color.trim();
37 return cssColorPattern.test(trimmed) ? trimmed : null;
38};
39
28const ChartContext = React.createContext<ChartContextProps | null>(null);40const ChartContext = React.createContext<ChartContextProps | null>(null);
29 41
30function useChart() {42function useChart() {
@@ -92,8 +104,11 @@ ${prefix} [data-chart=${id}] { · 61 unmodified lines
92${colorConfig104${colorConfig
93 .map(([key, itemConfig]) => {105 .map(([key, itemConfig]) => {
94 const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;106 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;
96 })110 })
111 .filter((line): line is string => line !== null)
97 .join("\n")}112 .join("\n")}
98}113}
99`,114`,
packages/react/src/components/markdown.test.ts+18−0
@@ -0,0 +1,18 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { sanitizeMarkdownUrl } from "./markdown";
4
5describe("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
2import { Streamdown, type Components } from "streamdown";2import { Streamdown, type Components } from "streamdown";
3import { CodeBlock } from "./code-block";3import { CodeBlock } from "./code-block";
4 4
5const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
6
7export 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
5function extractText(node: ReactNode): string {19function extractText(node: ReactNode): string {
6 if (typeof node === "string") return node;20 if (typeof node === "string") return node;
7 if (typeof node === "number") return String(node);21 if (typeof node === "number") return String(node);
@@ -28,6 +42,22 @@ function PreBlock( · 20 unmodified lines
28 return <pre>{props.children}</pre>;42 return <pre>{props.children}</pre>;
29}43}
30 44
45function 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
56function Image(props: ComponentPropsWithoutRef<"img"> & { node?: unknown }) {
57 const { alt } = props;
58 return alt ? <span>{alt}</span> : null;
59}
60
31const PROSE_CLASSES = [61const PROSE_CLASSES = [
32 "text-sm leading-relaxed text-muted-foreground",62 "text-sm leading-relaxed text-muted-foreground",
33 // paragraphs63 // paragraphs
@@ -62,10 +92,15 @@ const PROSE_CLASSES = [ · 28 unmodified lines
62].join(" ");92].join(" ");
63 93
64export function Markdown(props: { children: string; className?: string }) {94export 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;
66 return (96 return (
67 <div className={props.className ? `${PROSE_CLASSES} ${props.className}` : PROSE_CLASSES}>97 <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 >
69 {props.children}104 {props.children}
70 </Streamdown>105 </Streamdown>
71 </div>106 </div>
packages/react/src/components/mcp-install-card.test.ts+32−0
@@ -0,0 +1,32 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { buildMcpInstallCommand, shellQuoteWord } from "./mcp-install-card";
4
5describe("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
23 window.location.hostname === "127.0.0.1" ||23 window.location.hostname === "127.0.0.1" ||
24 window.location.hostname.endsWith(".localhost"));24 window.location.hostname.endsWith(".localhost"));
25 25
26export const shellQuoteWord = (value: string): string => {
27 if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value;
28 return `'${value.replace(/'/g, `'"'"'`)}'`;
29};
30
31export 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
26export function McpInstallCard(props: { className?: string }) {49export function McpInstallCard(props: { className?: string }) {
27 const showStdio = isLocal;50 const showStdio = isLocal;
28 const [mode, setMode] = useState<TransportMode>(showStdio ? "stdio" : "http");51 const [mode, setMode] = useState<TransportMode>(showStdio ? "stdio" : "http");
@@ -33,16 +56,12 @@ export function McpInstallCard(props: { className?: string }) { · 4 unmodified lines
33 setOrigin(window.location.origin);56 setOrigin(window.location.origin);
34 }, []);57 }, []);
35 58
36 const scopeFlag = scopeInfo.dir ? ` --scope ${JSON.stringify(scopeInfo.dir)}` : "";59 const command = buildMcpInstallCommand({
37 60 mode,
38 const command =61 isDev,
39 mode === "stdio"62 origin,
40 ? isDev63 scopeDir: scopeInfo.dir,
41 ? `npx add-mcp "bun run dev:cli mcp${scopeFlag}" --name "executor"`64 });
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"';
46 65
47 const subtitle =66 const subtitle =
48 mode === "stdio"67 mode === "stdio"
packages/react/src/components/schema-explorer.test.ts+16−0
@@ -0,0 +1,16 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { safeSchemaValueLabel } from "./schema-explorer";
4
5describe("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
26 format?: string;26 format?: string;
27};27};
28 28
29export 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
29// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
30// Ref resolution — lazy, only on expand41// Ref resolution — lazy, only on expand
31// ---------------------------------------------------------------------------42// ---------------------------------------------------------------------------
@@ -64,11 +75,11 @@ const getTypeLabel = (schema: JsonSchema, root: JsonSchema): string => { · 32 unmodified lines
64 return getRefName(schema.$ref) ?? "ref";75 return getRefName(schema.$ref) ?? "ref";
65 }76 }
66 77
67 if (schema.const !== undefined) return JSON.stringify(schema.const);78 if (schema.const !== undefined) return safeSchemaValueLabel(schema.const);
68 79
69 if (schema.enum) {80 if (schema.enum) {
70 if (schema.enum.length <= 3) {81 if (schema.enum.length <= 3) {
71 return schema.enum.map((v) => JSON.stringify(v)).join(" | ");82 return schema.enum.map((v) => safeSchemaValueLabel(v)).join(" | ");
72 }83 }
73 return `enum (${schema.enum.length})`;84 return `enum (${schema.enum.length})`;
74 }85 }
@@ -263,7 +274,7 @@ function PropertyRow(props: { · 188 unmodified lines
263 ))}274 ))}
264 {schema.default !== undefined && (275 {schema.default !== undefined && (
265 <p className="text-xs leading-5 text-muted-foreground">276 <p className="text-xs leading-5 text-muted-foreground">
266 = {JSON.stringify(schema.default)}277 = {safeSchemaValueLabel(schema.default)}
267 </p>278 </p>
268 )}279 )}
269 </div>280 </div>
packages/react/src/components/source-favicon.test.tsx+13−0
@@ -0,0 +1,13 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { SourceFavicon } from "./source-favicon";
4
5describe("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 @@
1import { BoxIcon } from "lucide-react";1import { BoxIcon } from "lucide-react";
2import { useState } from "react";
3import { getDomain } from "tldts";
4 2
5// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
6// SourceFavicon — renders a small favicon derived from a source URL.4// SourceFavicon — renders a neutral local source icon.
7// Falls back to a neutral dot if the URL is missing or the image fails to load.5// Do not fetch third-party favicon services here; source URLs may be private.
8// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
9 7
10function domainOf(url: string): string | null {8export function SourceFavicon({ size = 16 }: { url?: string; size?: number }) {
11 try {
12 return getDomain(url) ?? getDomain(new URL(url).hostname) ?? null;
13 } catch {
14 return null;
15 }
16}
17
18export 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
32 return (9 return (
33 <img10 <BoxIcon
34 src={`https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`}11 aria-hidden
35 alt=""12 className="shrink-0 text-muted-foreground"
36 width={size}
37 height={size}
38 loading="lazy"
39 onError={() => setFailed(true)}
40 className="shrink-0 rounded-sm"
41 style={{ width: size, height: size }}13 style={{ width: size, height: size }}
42 />14 />
43 );15 );
packages/react/src/plugins/secret-credential-scope.ts+9−0
@@ -0,0 +1,9 @@
1import type { ScopeId } from "@executor-js/sdk";
2
3import type { SecretPickerSecret } from "./secret-picker";
4
5export 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
25} from "../components/select";25} from "../components/select";
26import type { VariantProps } from "class-variance-authority";26import type { VariantProps } from "class-variance-authority";
27 27
28import { secretValueInputType } from "./secret-input";
28import { getUniqueSecretId, isSecretIdTaken } from "./secret-id";29import { getUniqueSecretId, isSecretIdTaken } from "./secret-id";
29 30
30// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
@@ -219,6 +220,7 @@ function ValueField(props: { revealable?: boolean; placeholder?: string; autoFoc · 188 unmodified lines
219 const { state, actions } = useSecretForm();220 const { state, actions } = useSecretForm();
220 const inputId = useId();221 const inputId = useId();
221 const revealable = props.revealable ?? false;222 const revealable = props.revealable ?? false;
223 const revealed = revealable && state.revealed;
222 const errored = state.status.kind === "error";224 const errored = state.status.kind === "error";
223 225
224 return (226 return (
@@ -227,16 +229,15 @@ function ValueField(props: { revealable?: boolean; placeholder?: string; autoFoc · 2 unmodified lines
227 <div className="relative">229 <div className="relative">
228 <Input230 <Input
229 id={inputId}231 id={inputId}
230 type={revealable ? "text" : "password"}232 type={secretValueInputType({ revealable, revealed })}
231 value={state.value}233 value={state.value}
232 onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}234 onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}
233 placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}235 placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}
234 autoFocus={props.autoFocus}236 autoFocus={props.autoFocus}
237 autoComplete="new-password"
235 className={revealable ? "pr-9 font-mono" : "font-mono"}238 className={revealable ? "pr-9 font-mono" : "font-mono"}
236 style={239 style={
237 revealable && !state.revealed240 revealable && !revealed ? ({ WebkitTextSecurity: "disc" } as CSSProperties) : undefined
238 ? ({ WebkitTextSecurity: "disc" } as CSSProperties)
239 : undefined
240 }241 }
241 />242 />
242 {revealable && (243 {revealable && (
packages/react/src/plugins/secret-header-auth.test.ts+10−1
@@ -1,7 +1,8 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { ScopeId } from "@executor-js/sdk";2import { ScopeId } from "@executor-js/sdk";
3 3
4import { secretsForCredentialTarget } from "./secret-header-auth";4import { secretsForCredentialTarget } from "./secret-credential-scope";
5import { secretValueInputType } from "./secret-input";
5 6
6describe("secretsForCredentialTarget", () => {7describe("secretsForCredentialTarget", () => {
7 it("only exposes secrets owned by the target credential scope", () => {8 it("only exposes secrets owned by the target credential scope", () => {
@@ -16,3 +17,11 @@ describe("secretsForCredentialTarget", () => { · 8 unmodified lines
16 ).toEqual(["shared-token"]);17 ).toEqual(["shared-token"]);
17 });18 });
18});19});
20
21describe("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
25 CredentialTargetScopeSelector,25 CredentialTargetScopeSelector,
26 type CredentialTargetScopeOption,26 type CredentialTargetScopeOption,
27} from "./credential-target-scope";27} from "./credential-target-scope";
28import { secretsForCredentialTarget } from "./secret-credential-scope";
28 29
29export const secretsForCredentialTarget = (30export { secretsForCredentialTarget };
30 secrets: readonly SecretPickerSecret[],
31 targetScope: ScopeId,
32): readonly SecretPickerSecret[] =>
33 secrets.filter((secret) => secret.scopeId === String(targetScope));
34 31
35export interface HeaderAuthPreset {32export interface HeaderAuthPreset {
36 readonly key: string;33 readonly key: string;
packages/react/src/plugins/secret-input.ts+4−0
@@ -0,0 +1,4 @@
1export 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
186 186
187download_and_install() {187download_and_install() {
188 print_message info "\n${MUTED}Installing ${NC}${APP} ${MUTED}version: ${NC}${specific_version}"188 print_message info "\n${MUTED}Installing ${NC}${APP} ${MUTED}version: ${NC}${specific_version}"
189 local tmp_dir="${TMPDIR:-/tmp}/${APP}_install_$$"189 local tmp_dir
190 mkdir -p "$tmp_dir"190 tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/${APP}_install_XXXXXXXXXX")
191 trap 'rm -rf "$tmp_dir"' RETURN
191 192
192 curl -# -L -o "${tmp_dir}/${filename}" "$url"193 curl -# -L -o "${tmp_dir}/${filename}" "$url"
193 194
@@ -205,6 +206,7 @@ download_and_install() { · 11 unmodified lines
205 206
206 chmod 755 "${INSTALL_DIR}/${APP}"207 chmod 755 "${INSTALL_DIR}/${APP}"
207 rm -rf "$tmp_dir"208 rm -rf "$tmp_dir"
209 trap - RETURN
208}210}
209 211
210install_from_binary() {212install_from_binary() {
scripts/oxlint-plugin-executor/rules/no-conditional-tests.js+4−41
@@ -1,22 +1,4 @@
1import { getCallName } from "../utils.js";1import { getCallName, isTestLike } from "../utils.js";
2import { isTestLike } from "../utils.js";
3import { unwrapExpression } from "../utils.js";
4
5const testRegistrars = new Set(["it", "test"]);
6
7const isFunction = (node) =>
8 node?.type === "ArrowFunctionExpression" ||
9 node?.type === "FunctionExpression" ||
10 node?.type === "FunctionDeclaration";
11
12const 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};
20 2
21export default {3export default {
22 meta: {4 meta: {
@@ -28,31 +10,18 @@ export default { · 5 unmodified lines
28 create(context) {10 create(context) {
29 if (!isTestLike(context.filename)) return {};11 if (!isTestLike(context.filename)) return {};
30 12
31 const testCallbacks = new WeakSet();
32 let testDepth = 0;
33 let conditionalDepth = 0;13 let conditionalDepth = 0;
34 14
35 const enterFunction = (node) => {
36 if (testCallbacks.has(node)) testDepth++;
37 };
38 const exitFunction = (node) => {
39 if (testCallbacks.has(node)) testDepth--;
40 };
41 const enterConditional = () => {15 const enterConditional = () => {
42 if (testDepth > 0) conditionalDepth++;16 conditionalDepth++;
43 };17 };
44 const exitConditional = () => {18 const exitConditional = () => {
45 if (testDepth > 0) conditionalDepth--;19 conditionalDepth--;
46 };20 };
47 21
48 return {22 return {
49 CallExpression(node) {23 CallExpression(node) {
50 if (isTestRegistrationCall(node)) {24 if (conditionalDepth === 0) return;
51 const callback = node.arguments.find(isFunction);
52 if (callback) testCallbacks.add(callback);
53 }
54
55 if (testDepth === 0 || conditionalDepth === 0) return;
56 const name = getCallName(node.callee);25 const name = getCallName(node.callee);
57 if (name !== "expect") return;26 if (name !== "expect") return;
58 context.report({27 context.report({
@@ -61,12 +30,6 @@ export default { · 2 unmodified lines
61 "Avoid conditional expect calls; split the test or assert both branches explicitly. Skill: wrdn-effect-vitest-tests.",30 "Avoid conditional expect calls; split the test or assert both branches explicitly. Skill: wrdn-effect-vitest-tests.",
62 });31 });
63 },32 },
64 FunctionDeclaration: enterFunction,
65 "FunctionDeclaration:exit": exitFunction,
66 FunctionExpression: enterFunction,
67 "FunctionExpression:exit": exitFunction,
68 ArrowFunctionExpression: enterFunction,
69 "ArrowFunctionExpression:exit": exitFunction,
70 IfStatement: enterConditional,33 IfStatement: enterConditional,
71 "IfStatement:exit": exitConditional,34 "IfStatement:exit": exitConditional,
72 ConditionalExpression: enterConditional,35 ConditionalExpression: enterConditional,