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