History

Improve tool detail fallbacks and task watch reliability

This avoids rendering empty schema blocks in the tools explorer and adds polling fallback when websocket subscriptions are unavailable in MCP task watch streams. It also adds a security probe script to validate that untrusted runtime code cannot access token environment variables.

Rhys Sullivan committed 8 months ago

Commit 7c7b2ca1 parent f9c49a94 files · +262 −36Browse files

Unified · Split

4 files
  1. executor/apps/web/src/components/tools/explorer.tsx +25 −2
  2. executor/apps/web/src/components/tools/explorer/tool-detail.tsx +17 −10
  3. executor/packages/database/convex/http/mcp_service.ts +74 −24
  4. executor/scripts/dev/security/probe.token-access.ts +146 −0
executor/apps/web/src/components/tools/explorer.tsx+25−2
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 } from "./explorer-derived";
2121 import { collectGroupKeys } from "@/lib/tool/explorer-grouping";
2222 import { sourceLabel } from "@/lib/tool/source-utils";
23+import { traverseSchema } from "@/lib/tool/schema-traverse";
2324 import {
2425 EmptyState,
2526 LoadingState,
@@ -38,6 +39,27 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines
3839
3940 // ── Main Explorer ──
4041
42+function asRecord(value: unknown): Record<string, unknown> {
43+ return value && typeof value === "object" && !Array.isArray(value)
44+ ? value as Record<string, unknown>
45+ : {};
46+}
47+
48+function parseSchemaJson(value: string): Record<string, unknown> {
49+ if (!value) return {};
50+
51+ try {
52+ const parsed = JSON.parse(value) as unknown;
53+ return asRecord(parsed);
54+ } catch {
55+ return {};
56+ }
57+}
58+
59+function hasRenderableSchemaFields(schemaJson: string): boolean {
60+ return traverseSchema(parseSchemaJson(schemaJson), { maxEntries: 1 }).entries.length > 0;
61+}
62+
4163 interface ToolExplorerProps {
4264 tools: ToolDescriptor[];
4365 sources: ToolSourceRecord[];
@@ -117,9 +139,10 @@ export function ToolExplorer({ · 73 unmodified lines
117139
118140 const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown";
119141 const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown";
120− const hasSchemas = inputSchemaJson.length > 0 || outputSchemaJson.length > 0;
142+ const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}" && hasRenderableSchemaFields(inputSchemaJson);
143+ const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}" && hasRenderableSchemaFields(outputSchemaJson);
121144
122− return description.length > 0 || hasInputHint || hasOutputHint || hasSchemas;
145+ return description.length > 0 || hasInputHint || hasOutputHint || hasInputSchema || hasOutputSchema;
123146 }, []);
124147
125148 const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({});
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+17−10
@@ -371,8 +371,6 @@ function ToolDetailContent({ · 370 unmodified lines
371371 const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown";
372372 const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}";
373373 const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}";
374− const showInputHint = hasInputHint && !hasInputSchema;
375− const showOutputHint = hasOutputHint && !hasOutputSchema;
376374
377375 const inputFields = useMemo(
378376 () => traverseSchema(parseSchemaJson(inputSchemaJson), { maxEntries: 30, maxDepth: 5 }),
@@ -383,11 +381,20 @@ function ToolDetailContent({ · 4 unmodified lines
383381 [outputSchemaJson],
384382 );
385383
384+ const hasInputFields = inputFields.entries.length > 0;
385+ const hasOutputFields = outputFields.entries.length > 0;
386+
387+ const canRenderInputSchema = hasInputSchema && hasInputFields;
388+ const canRenderOutputSchema = hasOutputSchema && hasOutputFields;
389+
390+ const shouldShowInputHint = hasInputHint && !canRenderInputSchema;
391+ const shouldShowOutputHint = hasOutputHint && !canRenderOutputSchema;
392+
386393 const hasDetails = description.length > 0
387− || showInputHint
388− || showOutputHint
389− || hasInputSchema
390− || hasOutputSchema;
394+ || shouldShowInputHint
395+ || shouldShowOutputHint
396+ || canRenderInputSchema
397+ || canRenderOutputSchema;
391398 const showLoading = Boolean(loading);
392399
393400 return (
@@ -415,11 +422,11 @@ function ToolDetailContent({ · 21 unmodified lines
415422 ) : null}
416423
417424 {/* Type hint fallbacks (when no full schema is available) */}
418− {showInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null}
419− {showOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null}
425+ {shouldShowInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null}
426+ {shouldShowOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null}
420427
421428 {/* Structured schema rendering */}
422− {hasInputSchema ? (
429+ {canRenderInputSchema ? (
423430 <SchemaFieldsSection
424431 label="Arguments"
425432 entries={inputFields.entries}
@@ -428,7 +435,7 @@ function ToolDetailContent({ · 2 unmodified lines
428435 />
429436 ) : null}
430437
431− {hasOutputSchema ? (
438+ {canRenderOutputSchema ? (
432439 <SchemaFieldsSection
433440 label="Returns"
434441 entries={outputFields.entries}
executor/packages/database/convex/http/mcp_service.ts+74−24
@@ -11,6 +11,11 @@ import { api, internal } from "../_generated/api"; · 10 unmodified lines
1111 import type { Id } from "../_generated/dataModel.d.ts";
1212 import type { ActionCtx } from "../_generated/server";
1313
14+type TaskWatchStatusPayload = {
15+ status?: string;
16+ pendingApprovalCount?: number;
17+};
18+
1419 export function createMcpExecutorService(ctx: ActionCtx) {
1520 return {
1621 createTask: async (input: {
@@ -50,32 +55,77 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 33 unmodified lines
5055 return () => {};
5156 }
5257
53− const client = new ConvexClient(callbackConvexUrl, {
54− skipConvexDeploymentUrlCheck: true,
55− });
58+ const callbackArgs = {
59+ internalSecret: callbackInternalSecret,
60+ runId: taskId,
61+ workspaceId,
62+ };
5663 let sequence = 0;
57− const unsubscribe = client.onUpdate(
58− api.runtimeCallbacks.getTaskWatchStatus,
59− {
60− internalSecret: callbackInternalSecret,
61− runId: taskId,
62− workspaceId,
63− },
64− (payload: unknown) => {
65− sequence += 1;
66− listener({
67− id: sequence,
68− eventName: "task",
69− payload,
70− createdAt: Date.now(),
71− });
72− },
73− );
64+ try {
65+ const client = new ConvexClient(callbackConvexUrl, {
66+ skipConvexDeploymentUrlCheck: true,
67+ });
68+ const unsubscribe = client.onUpdate(
69+ api.runtimeCallbacks.getTaskWatchStatus,
70+ callbackArgs,
71+ (payload: TaskWatchStatusPayload | null | undefined) => {
72+ sequence += 1;
73+ listener({
74+ id: sequence,
75+ eventName: "task",
76+ payload,
77+ createdAt: Date.now(),
78+ });
79+ },
80+ );
7481
75− return () => {
76− unsubscribe();
77− client.close();
78− };
82+ return () => {
83+ unsubscribe();
84+ client.close();
85+ };
86+ } catch {
87+ // ConvexClient requires websocket support, which isn't always available in
88+ // production HTTP-only Convex function hosts. Fall back to polling-based
89+ // updates to avoid runtime failures.
90+ const pollMs = 750;
91+ let cancelled = false;
92+ let timer: ReturnType<typeof setTimeout> | undefined;
93+
94+ const poll = async () => {
95+ if (cancelled) {
96+ return;
97+ }
98+
99+ try {
100+ const payload = await ctx.runQuery(api.runtimeCallbacks.getTaskWatchStatus, callbackArgs);
101+ sequence += 1;
102+ listener({
103+ id: sequence,
104+ eventName: "task",
105+ payload: {
106+ status: payload?.status,
107+ pendingApprovalCount: payload?.pendingApprovalCount,
108+ },
109+ createdAt: Date.now(),
110+ });
111+ } catch {
112+ // Ignore transient polling failures and continue retrying.
113+ }
114+
115+ if (!cancelled) {
116+ timer = setTimeout(poll, pollMs);
117+ }
118+ };
119+
120+ void poll();
121+
122+ return () => {
123+ cancelled = true;
124+ if (timer) {
125+ clearTimeout(timer);
126+ }
127+ };
128+ }
79129 },
80130 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {
81131 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
executor/scripts/dev/security/probe.token-access.ts+146−0
@@ -0,0 +1,146 @@
1+#!/usr/bin/env bun
2+
3+import { ConvexHttpClient } from "convex/browser";
4+import { api } from "../../../packages/database/convex/_generated/api";
5+
6+type RuntimeId = "local-bun" | "cloudflare-worker-loader";
7+
8+function requireConvexUrl(): string {
9+ const value = process.env.CONVEX_URL ?? process.env.CONVEX_SITE_URL;
10+ if (!value) {
11+ throw new Error("Missing CONVEX_URL or CONVEX_SITE_URL");
12+ }
13+
14+ return value;
15+}
16+
17+function buildProbeCode(): string {
18+ return [
19+ "const out = {};",
20+ "const summarize = (value) => {",
21+ " if (value === undefined) return { type: 'undefined' };",
22+ " if (value === null) return { type: 'null' };",
23+ " const t = typeof value;",
24+ " if (t === 'string') return { type: 'string', length: value.length, prefix: value.slice(0, 8) };",
25+ " if (t === 'number' || t === 'boolean' || t === 'bigint') return { type: t, value };",
26+ " if (t === 'function') return { type: 'function', name: value.name || '<anonymous>' };",
27+ " if (t === 'object') {",
28+ " try { return { type: 'object', keys: Object.keys(value).slice(0, 30) }; } catch { return { type: 'object' }; }",
29+ " }",
30+ " return { type: t };",
31+ "};",
32+ "const tokenKeys = ['CLOUDFLARE_SANDBOX_AUTH_TOKEN', 'EXECUTOR_INTERNAL_TOKEN', 'AUTH_TOKEN', 'CLOUDFLARE_SANDBOX_RUN_URL', 'CONVEX_URL'];",
33+ "let g;",
34+ "try { g = Function('return globalThis')(); } catch (error) { out.globalThisError = String(error); }",
35+ "out.globalThis = summarize(g);",
36+ "out.globalKeys = g ? Object.getOwnPropertyNames(g).slice(0, 80) : [];",
37+ "let processRef;",
38+ "try { processRef = Function(\"return typeof process !== 'undefined' ? process : undefined\")(); } catch (error) { out.processEvalError = String(error); }",
39+ "out.process = summarize(processRef);",
40+ "out.processEnv = summarize(processRef?.env);",
41+ "const processEnvProbe = {};",
42+ "for (const key of tokenKeys) {",
43+ " let value;",
44+ " try { value = processRef?.env?.[key]; } catch (error) { value = `<error:${String(error)}>`; }",
45+ " processEnvProbe[key] = summarize(value);",
46+ "}",
47+ "out.processEnvProbe = processEnvProbe;",
48+ "let bunRef;",
49+ "try { bunRef = Function(\"return typeof Bun !== 'undefined' ? Bun : undefined\")(); } catch (error) { out.bunEvalError = String(error); }",
50+ "out.Bun = summarize(bunRef);",
51+ "let denoRef;",
52+ "try { denoRef = Function(\"return typeof Deno !== 'undefined' ? Deno : undefined\")(); } catch (error) { out.denoEvalError = String(error); }",
53+ "out.Deno = summarize(denoRef);",
54+ "try {",
55+ " const src = tools.discover?.toString?.() ?? '<missing>';",
56+ " out.discoverToString = src.slice(0, 220);",
57+ "} catch (error) {",
58+ " out.discoverToStringError = String(error);",
59+ "}",
60+ "return out;",
61+ ].join("\n");
62+}
63+
64+function extractSuspiciousKeys(result: unknown): string[] {
65+ const rec = (result && typeof result === "object") ? result as Record<string, unknown> : {};
66+ const probe = (rec.processEnvProbe && typeof rec.processEnvProbe === "object")
67+ ? rec.processEnvProbe as Record<string, unknown>
68+ : {};
69+
70+ const leaked: string[] = [];
71+ for (const [key, value] of Object.entries(probe)) {
72+ const summary = (value && typeof value === "object") ? value as Record<string, unknown> : {};
73+ const type = summary.type;
74+ const length = summary.length;
75+ if (type === "string" && typeof length === "number" && length > 0) {
76+ leaked.push(key);
77+ }
78+ }
79+
80+ return leaked;
81+}
82+
83+async function runForRuntime(
84+ client: ConvexHttpClient,
85+ workspaceId: string,
86+ sessionId: string,
87+ runtimeId: RuntimeId,
88+ code: string,
89+) {
90+ const response = await client.action(api.executor.createTask, {
91+ workspaceId: workspaceId as never,
92+ sessionId,
93+ runtimeId,
94+ waitForResult: true,
95+ timeoutMs: 120_000,
96+ code,
97+ metadata: {
98+ purpose: "security-token-probe",
99+ runtimeId,
100+ },
101+ }) as {
102+ task: { id: string; status: string; error?: string };
103+ result?: unknown;
104+ };
105+
106+ return response;
107+}
108+
109+async function main() {
110+ const client = new ConvexHttpClient(requireConvexUrl(), {
111+ skipConvexDeploymentUrlCheck: true,
112+ });
113+
114+ const sessionId = `mcp_token_probe_${crypto.randomUUID().slice(0, 8)}`;
115+ const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, { sessionId }) as {
116+ sessionId: string;
117+ };
118+ const created = await client.mutation(api.organizations.create, {
119+ sessionId: bootstrap.sessionId,
120+ name: `Token Probe Org ${crypto.randomUUID().slice(0, 8)}`,
121+ }) as {
122+ workspace: { id: string };
123+ };
124+ const workspaceId = created.workspace.id;
125+ const code = buildProbeCode();
126+
127+ for (const runtimeId of ["local-bun", "cloudflare-worker-loader"] as const) {
128+ try {
129+ const response = await runForRuntime(client, workspaceId, sessionId, runtimeId, code);
130+ const leaked = extractSuspiciousKeys(response.result);
131+
132+ console.log(`\n=== ${runtimeId} ===`);
133+ console.log(`taskStatus=${response.task.status}`);
134+ if (response.task.error) {
135+ console.log(`taskError=${response.task.error}`);
136+ }
137+ console.log(`leakedKeys=${leaked.length > 0 ? leaked.join(",") : "none"}`);
138+ console.log(JSON.stringify(response.result ?? null, null, 2));
139+ } catch (error) {
140+ console.log(`\n=== ${runtimeId} FAILED ===`);
141+ console.log(error instanceof Error ? error.message : String(error));
142+ }
143+ }
144+}
145+
146+await main();