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
20} from "./explorer-derived";20} from "./explorer-derived";
21import { collectGroupKeys } from "@/lib/tool/explorer-grouping";21import { collectGroupKeys } from "@/lib/tool/explorer-grouping";
22import { sourceLabel } from "@/lib/tool/source-utils";22import { sourceLabel } from "@/lib/tool/source-utils";
23import { traverseSchema } from "@/lib/tool/schema-traverse";
23import {24import {
24 EmptyState,25 EmptyState,
25 LoadingState,26 LoadingState,
@@ -38,6 +39,27 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines
38 39
39// ── Main Explorer ──40// ── Main Explorer ──
40 41
42function asRecord(value: unknown): Record<string, unknown> {
43 return value && typeof value === "object" && !Array.isArray(value)
44 ? value as Record<string, unknown>
45 : {};
46}
47
48function 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
59function hasRenderableSchemaFields(schemaJson: string): boolean {
60 return traverseSchema(parseSchemaJson(schemaJson), { maxEntries: 1 }).entries.length > 0;
61}
62
41interface ToolExplorerProps {63interface ToolExplorerProps {
42 tools: ToolDescriptor[];64 tools: ToolDescriptor[];
43 sources: ToolSourceRecord[];65 sources: ToolSourceRecord[];
@@ -117,9 +139,10 @@ export function ToolExplorer({ · 73 unmodified lines
117 139
118 const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown";140 const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown";
119 const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown";141 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);
121 144
122 return description.length > 0 || hasInputHint || hasOutputHint || hasSchemas;145 return description.length > 0 || hasInputHint || hasOutputHint || hasInputSchema || hasOutputSchema;
123 }, []);146 }, []);
124 147
125 const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({});148 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
371 const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown";371 const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown";
372 const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}";372 const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}";
373 const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}";373 const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}";
374 const showInputHint = hasInputHint && !hasInputSchema;
375 const showOutputHint = hasOutputHint && !hasOutputSchema;
376 374
377 const inputFields = useMemo(375 const inputFields = useMemo(
378 () => traverseSchema(parseSchemaJson(inputSchemaJson), { maxEntries: 30, maxDepth: 5 }),376 () => traverseSchema(parseSchemaJson(inputSchemaJson), { maxEntries: 30, maxDepth: 5 }),
@@ -383,11 +381,20 @@ function ToolDetailContent({ · 4 unmodified lines
383 [outputSchemaJson],381 [outputSchemaJson],
384 );382 );
385 383
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
386 const hasDetails = description.length > 0393 const hasDetails = description.length > 0
387 || showInputHint394 || shouldShowInputHint
388 || showOutputHint395 || shouldShowOutputHint
389 || hasInputSchema396 || canRenderInputSchema
390 || hasOutputSchema;397 || canRenderOutputSchema;
391 const showLoading = Boolean(loading);398 const showLoading = Boolean(loading);
392 399
393 return (400 return (
@@ -415,11 +422,11 @@ function ToolDetailContent({ · 21 unmodified lines
415 ) : null}422 ) : null}
416 423
417 {/* Type hint fallbacks (when no full schema is available) */}424 {/* Type hint fallbacks (when no full schema is available) */}
418 {showInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null}425 {shouldShowInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null}
419 {showOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null}426 {shouldShowOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null}
420 427
421 {/* Structured schema rendering */}428 {/* Structured schema rendering */}
422 {hasInputSchema ? (429 {canRenderInputSchema ? (
423 <SchemaFieldsSection430 <SchemaFieldsSection
424 label="Arguments"431 label="Arguments"
425 entries={inputFields.entries}432 entries={inputFields.entries}
@@ -428,7 +435,7 @@ function ToolDetailContent({ · 2 unmodified lines
428 />435 />
429 ) : null}436 ) : null}
430 437
431 {hasOutputSchema ? (438 {canRenderOutputSchema ? (
432 <SchemaFieldsSection439 <SchemaFieldsSection
433 label="Returns"440 label="Returns"
434 entries={outputFields.entries}441 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
11import type { Id } from "../_generated/dataModel.d.ts";11import type { Id } from "../_generated/dataModel.d.ts";
12import type { ActionCtx } from "../_generated/server";12import type { ActionCtx } from "../_generated/server";
13 13
14type TaskWatchStatusPayload = {
15 status?: string;
16 pendingApprovalCount?: number;
17};
18
14export function createMcpExecutorService(ctx: ActionCtx) {19export function createMcpExecutorService(ctx: ActionCtx) {
15 return {20 return {
16 createTask: async (input: {21 createTask: async (input: {
@@ -50,32 +55,77 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 33 unmodified lines
50 return () => {};55 return () => {};
51 }56 }
52 57
53 const client = new ConvexClient(callbackConvexUrl, {58 const callbackArgs = {
54 skipConvexDeploymentUrlCheck: true,59 internalSecret: callbackInternalSecret,
55 });60 runId: taskId,
61 workspaceId,
62 };
56 let sequence = 0;63 let sequence = 0;
57 const unsubscribe = client.onUpdate(64 try {
58 api.runtimeCallbacks.getTaskWatchStatus,65 const client = new ConvexClient(callbackConvexUrl, {
59 {66 skipConvexDeploymentUrlCheck: true,
60 internalSecret: callbackInternalSecret,67 });
61 runId: taskId,68 const unsubscribe = client.onUpdate(
62 workspaceId,69 api.runtimeCallbacks.getTaskWatchStatus,
63 },70 callbackArgs,
64 (payload: unknown) => {71 (payload: TaskWatchStatusPayload | null | undefined) => {
65 sequence += 1;72 sequence += 1;
66 listener({73 listener({
67 id: sequence,74 id: sequence,
68 eventName: "task",75 eventName: "task",
69 payload,76 payload,
70 createdAt: Date.now(),77 createdAt: Date.now(),
71 });78 });
72 },79 },
73 );80 );
74 81
75 return () => {82 return () => {
76 unsubscribe();83 unsubscribe();
77 client.close();84 client.close();
78 };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 }
79 },129 },
80 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {130 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {
81 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {131 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
3import { ConvexHttpClient } from "convex/browser";
4import { api } from "../../../packages/database/convex/_generated/api";
5
6type RuntimeId = "local-bun" | "cloudflare-worker-loader";
7
8function 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
17function 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
64function 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
83async 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
109async 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
146await main();