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