* test: cover hyphenated paths in browser * refactor: narrow hyphenated artifact path fix * Test queue timeout with a controlled clock * Handle single-quoted artifact integration paths * Use the catalog path for the artifact schema query ---------
Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Render artifacts that call integrations or tools with hyphenated slugs. | |
| @@ -0,0 +1,109 @@ | ||
| 1 | +import { randomBytes } from "node:crypto"; | |
| 2 | +import { expect } from "@effect/vitest"; | |
| 3 | +import { Effect, Schema } from "effect"; | |
| 4 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 5 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 6 | +import { | |
| 7 | + ArtifactId, | |
| 8 | + AuthTemplateSlug, | |
| 9 | + ConnectionName, | |
| 10 | + IntegrationSlug, | |
| 11 | +} from "@executor-js/sdk/shared"; | |
| 12 | + | |
| 13 | +import { createEmulatorInstance } from "../src/emulator-instance"; | |
| 14 | +import { scenario } from "../src/scenario"; | |
| 15 | +import { Api, Browser, Mcp, Target } from "../src/services"; | |
| 16 | +import { visit } from "../src/surfaces/browser"; | |
| 17 | + | |
| 18 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 19 | +const decodeCreatedArtifact = Schema.decodeUnknownSync( | |
| 20 | + Schema.Struct({ structuredContent: Schema.Struct({ artifactId: ArtifactId }) }), | |
| 21 | +); | |
| 22 | + | |
| 23 | +scenario( | |
| 24 | + "Artifacts · a hyphenated integration renders live tool data through a bracket path", | |
| 25 | + { timeout: 180_000 }, | |
| 26 | + Effect.gen(function* () { | |
| 27 | + const target = yield* Target; | |
| 28 | + const browser = yield* Browser; | |
| 29 | + const mcp = yield* Mcp; | |
| 30 | + const { client: makeClient } = yield* Api; | |
| 31 | + const identity = yield* target.newIdentity(); | |
| 32 | + const client = yield* makeClient(api, identity); | |
| 33 | + const session = mcp.session(identity); | |
| 34 | + const slug = IntegrationSlug.make(`artifact-schema-${randomBytes(4).toString("hex")}`); | |
| 35 | + const baseUrl = yield* createEmulatorInstance("resend", "artifact-path"); | |
| 36 | + let artifactId: ArtifactId | undefined; | |
| 37 | + | |
| 38 | + yield* Effect.gen(function* () { | |
| 39 | + // The emulator's public schema is a real JSON endpoint. Its descriptive | |
| 40 | + // title gives the rendered query a stable caller-visible result. | |
| 41 | + yield* client.openapi.addSpec({ | |
| 42 | + payload: { | |
| 43 | + slug, | |
| 44 | + baseUrl, | |
| 45 | + spec: { | |
| 46 | + kind: "blob", | |
| 47 | + value: JSON.stringify({ | |
| 48 | + openapi: "3.0.3", | |
| 49 | + info: { title: "Service schema", version: "1" }, | |
| 50 | + servers: [{ url: baseUrl }], | |
| 51 | + paths: { | |
| 52 | + "/openapi.json": { | |
| 53 | + get: { | |
| 54 | + operationId: "readSchema", | |
| 55 | + responses: { "200": { description: "Schema" } }, | |
| 56 | + }, | |
| 57 | + }, | |
| 58 | + }, | |
| 59 | + }), | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + }); | |
| 63 | + yield* client.connections.create({ | |
| 64 | + payload: { | |
| 65 | + owner: "org", | |
| 66 | + name: ConnectionName.make("public"), | |
| 67 | + integration: slug, | |
| 68 | + template: AuthTemplateSlug.make("none"), | |
| 69 | + values: {}, | |
| 70 | + }, | |
| 71 | + }); | |
| 72 | + const created = yield* session.call("create-artifact", { | |
| 73 | + title: `Service schema ${slug}`, | |
| 74 | + code: `function App() { | |
| 75 | + const query = useQuery(tools['${slug}'].openapiJson.readSchema.queryOptions({})); | |
| 76 | + return <pre data-testid="live-schema">{query.isPending ? "Loading schema" : JSON.stringify(query.data ?? query.error)}</pre>; | |
| 77 | + }`, | |
| 78 | + }); | |
| 79 | + expect(created.ok, created.text).toBe(true); | |
| 80 | + const envelope = decodeCreatedArtifact(created.raw); | |
| 81 | + artifactId = envelope.structuredContent.artifactId; | |
| 82 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 83 | + await step("Open the artifact and read the service schema", async () => { | |
| 84 | + await visit(page, `/artifacts/${artifactId}`); | |
| 85 | + const data = page | |
| 86 | + .frameLocator('[data-testid="artifact-shell-frame"]') | |
| 87 | + .frameLocator("iframe") | |
| 88 | + .getByTestId("live-schema"); | |
| 89 | + await data.waitFor({ timeout: 30_000 }); | |
| 90 | + await data.filter({ hasText: /Resend/i }).waitFor({ timeout: 30_000 }); | |
| 91 | + expect(await data.textContent()).toContain("openapi"); | |
| 92 | + }); | |
| 93 | + }); | |
| 94 | + }).pipe( | |
| 95 | + Effect.ensuring( | |
| 96 | + Effect.gen(function* () { | |
| 97 | + if (artifactId !== undefined) | |
| 98 | + yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore); | |
| 99 | + yield* client.connections | |
| 100 | + .remove({ | |
| 101 | + params: { owner: "org", integration: slug, name: ConnectionName.make("public") }, | |
| 102 | + }) | |
| 103 | + .pipe(Effect.ignore); | |
| 104 | + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 105 | + }), | |
| 106 | + ), | |
| 107 | + ); | |
| 108 | + }), | |
| 109 | +); | |
| @@ -33,12 +33,16 @@ export type RequestTrustedInteraction = ( · 32 unmodified lines | ||
| 33 | 33 | interaction: TrustedInteraction, |
| 34 | 34 | ) => Promise<TrustedInteractionResponse>; |
| 35 | 35 | |
| 36 | −const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; | |
| 36 | +const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; | |
| 37 | +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/; | |
| 38 | + | |
| 39 | +const formatToolPathSegment = (segment: string): string => | |
| 40 | + TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`; | |
| 37 | 41 | |
| 38 | 42 | /** |
| 39 | 43 | * The ONE grammar the shell ever puts on the `execute-action` wire: |
| 40 | 44 | * |
| 41 | − * return await tools.<ident>("<role>")?(.<ident>)*(<JSON>) | |
| 45 | + * return await tools<segment>("<role>")?<segment>*(<JSON>) | |
| 42 | 46 | * |
| 43 | 47 | * A single proxy-shaped tool call, nothing else — no statements, no loops, no |
| 44 | 48 | * composition. The server parses `execute-action` against exactly this shape |
| @@ -71,10 +75,12 @@ export function toolCallCode( · 26 unmodified lines | ||
| 71 | 75 | if (role !== undefined && (typeof role !== "string" || role.length === 0)) { |
| 72 | 76 | throw new Error("Invalid tool role."); |
| 73 | 77 | } |
| 74 | − const [head, ...rest] = parts; | |
| 78 | + const head = parts[0]; | |
| 79 | + if (head === undefined) throw new Error("Invalid tool path."); | |
| 80 | + const rest = parts.slice(1); | |
| 75 | 81 | const tag = role === undefined ? "" : `(${JSON.stringify(role)})`; |
| 76 | − const trailer = rest.length > 0 ? `.${rest.join(".")}` : ""; | |
| 77 | − return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`; | |
| 82 | + const target = `${formatToolPathSegment(head)}${tag}${rest.map(formatToolPathSegment).join("")}`; | |
| 83 | + return `return await tools${target}(${JSON.stringify(args[0] ?? {})})`; | |
| 78 | 84 | } |
| 79 | 85 | |
| 80 | 86 | /** |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { parseToolCallCode } from "@executor-js/host-mcp/tool-call-code"; | |
| 2 | +import { formatToolCallCode, parseToolCallCode } from "@executor-js/host-mcp/tool-call-code"; | |
| 3 | 3 | |
| 4 | 4 | import { toolCallCode } from "./proxy"; |
| 5 | 5 | |
| @@ -30,6 +30,11 @@ describe("execute-action tool-call grammar", () => { · 24 unmodified lines | ||
| 30 | 30 | path: ["search"], |
| 31 | 31 | args: [{ query: "github issues", limit: 12 }], |
| 32 | 32 | }, |
| 33 | + { | |
| 34 | + label: "a hyphenated integration slug", | |
| 35 | + path: ["cloudflare-bindings", "d1_database_query"], | |
| 36 | + args: [{ database_id: "db", sql: "SELECT 1" }], | |
| 37 | + }, | |
| 33 | 38 | { |
| 34 | 39 | label: "an argument with a $ in an identifier-ish key", |
| 35 | 40 | path: ["mongo", "org", "main", "find"], |
| @@ -82,6 +87,12 @@ describe("execute-action tool-call grammar", () => { · 46 unmodified lines | ||
| 82 | 87 | }); |
| 83 | 88 | } |
| 84 | 89 | |
| 90 | + it("formats a resolved hyphenated integration safely", () => { | |
| 91 | + expect(formatToolCallCode(["cloudflare-bindings", "org", "default", "query"], {})).toBe( | |
| 92 | + 'return await tools["cloudflare-bindings"].org.default.query({})', | |
| 93 | + ); | |
| 94 | + }); | |
| 95 | + | |
| 85 | 96 | it("refuses to emit a path that would not parse", () => { |
| 86 | 97 | expect(() => toolCallCode([], [])).toThrow("Invalid tool path."); |
| 87 | 98 | expect(() => toolCallCode(["github", "issues; drop"], [])).toThrow("Invalid tool path."); |
| @@ -29,6 +29,21 @@ describe("extractArtifactRoles", () => { · 28 unmodified lines | ||
| 29 | 29 | expect(roles).toEqual([{ role: "vercel", integration: "vercel" }]); |
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | + it("reads a hyphenated integration from a bracket reference", () => { | |
| 33 | + const roles = extractArtifactRoles( | |
| 34 | + `useQuery(tools["cloudflare-bindings"].d1_database_query.queryOptions({ sql: "SELECT 1" }));`, | |
| 35 | + ); | |
| 36 | + expect(roles).toEqual([{ role: "cloudflare-bindings", integration: "cloudflare-bindings" }]); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("reads a hyphenated integration from single-quoted bracket references", () => { | |
| 40 | + expect( | |
| 41 | + extractArtifactRoles( | |
| 42 | + `useQuery(tools['cloudflare-bindings']('production').query.queryOptions({}));`, | |
| 43 | + ), | |
| 44 | + ).toEqual([{ role: "production", integration: "cloudflare-bindings" }]); | |
| 45 | + }); | |
| 46 | + | |
| 32 | 47 | it("collapses repeated references to one role", () => { |
| 33 | 48 | const roles = extractArtifactRoles( |
| 34 | 49 | `useQuery(tools.linear.issues.list.queryOptions({})); |
| @@ -92,14 +92,17 @@ const withCommentsBlanked = (code: string): string => · 91 unmodified lines | ||
| 92 | 92 | code.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (text) => text.replace(/[^\n]/g, " ")); |
| 93 | 93 | |
| 94 | 94 | /** |
| 95 | − * A `tools.<root>` reference, with the optional role call that follows it. | |
| 95 | + * A tools root reference, with the optional role call that follows it. | |
| 96 | 96 | * |
| 97 | 97 | * The role is captured from either quote flavour. Anything else after the root |
| 98 | 98 | * — property access, a call with an object — is left to the caller's own path |
| 99 | 99 | * handling; extraction only cares which integration slot is being reached. |
| 100 | 100 | */ |
| 101 | −const TOOLS_REFERENCE = | |
| 102 | − /(?<![.\w$])tools\s*\.\s*([A-Za-z_$][\w$]*)\s*(?:\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\s*\))?/g; | |
| 101 | +const TOOL_ROOT = String.raw`(?:\.\s*([A-Za-z_$][\w$]*)|\[\s*(?:"([A-Za-z_$][\w$-]*)"|'([A-Za-z_$][\w$-]*)')\s*\])`; | |
| 102 | +const TOOLS_REFERENCE = new RegExp( | |
| 103 | + String.raw`(?<![.\w$])tools\s*${TOOL_ROOT}\s*(?:\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\s*\))?`, | |
| 104 | + "g", | |
| 105 | +); | |
| 103 | 106 | |
| 104 | 107 | /** |
| 105 | 108 | * An old-style address: a tier literal in the segment right after the |
| @@ -138,9 +141,9 @@ export const extractArtifactRoles = (code: string): readonly ArtifactRole[] => { · 32 unmodified lines | ||
| 138 | 141 | const scannable = withCommentsBlanked(code); |
| 139 | 142 | const found = new Map<string, ArtifactRole>(); |
| 140 | 143 | for (const match of scannable.matchAll(TOOLS_REFERENCE)) { |
| 141 | − const integration = match[1]; | |
| 144 | + const integration = match[1] ?? match[2] ?? match[3]; | |
| 142 | 145 | if (integration === undefined || RESERVED_TOOL_ROOTS.has(integration)) continue; |
| 143 | − const role = match[2] ?? match[3] ?? integration; | |
| 146 | + const role = match[4] ?? match[5] ?? integration; | |
| 144 | 147 | if (role.length === 0) continue; |
| 145 | 148 | if (!found.has(role)) found.set(role, { role, integration }); |
| 146 | 149 | } |
| @@ -12,13 +12,13 @@ · 11 unmodified lines | ||
| 12 | 12 | * the shell ever writes any. So the server parses `execute-action` against the |
| 13 | 13 | * one grammar the proxy emits: |
| 14 | 14 | * |
| 15 | − * return await tools.<ident>("<role>")?(.<ident>)*(<JSON>) | |
| 15 | + * return await tools<segment>("<role>")?<segment>*(<JSON>) | |
| 16 | 16 | * |
| 17 | 17 | * One awaited tool call, one JSON-literal argument, nothing else — no |
| 18 | 18 | * statements, no loops, no composition. `execute` (the model-facing codemode |
| 19 | 19 | * tool) is untouched; this constraint is only for the app-originated channel. |
| 20 | 20 | * |
| 21 | − * The leading identifier is an INTEGRATION, not a connection: artifact paths | |
| 21 | + * The leading segment is an INTEGRATION, not a connection: artifact paths | |
| 22 | 22 | * carry no tier and no connection name (see `artifact-bindings.ts`). The |
| 23 | 23 | * optional string call right after it is the integration ROLE, which is how an |
| 24 | 24 | * artifact using two accounts of one integration says which it means. Both are |
| @@ -32,16 +32,26 @@ · 7 unmodified lines | ||
| 32 | 32 | |
| 33 | 33 | import { Option, Schema } from "effect"; |
| 34 | 34 | |
| 35 | −const TOOL_CALL_CODE = | |
| 36 | − /^return await tools\.([A-Za-z_$][\w$]*)(?:\((("(?:[^"\\]|\\.)*"))\))?((?:\.[A-Za-z_$][\w$]*)*)\((.*)\);?$/s; | |
| 35 | +const JSON_STRING_LITERAL = String.raw`"(?:[^"\\]|\\.)*"`; | |
| 36 | +const IDENTIFIER = String.raw`[A-Za-z_$][\w$]*`; | |
| 37 | +const SLUG = String.raw`[A-Za-z_$][\w$-]*`; | |
| 38 | +const PATH_SEGMENT = String.raw`(?:\.${IDENTIFIER}|\["${SLUG}"\])`; | |
| 39 | +const TOOL_CALL_CODE = new RegExp( | |
| 40 | + String.raw`^return await tools(${PATH_SEGMENT})(?:\((${JSON_STRING_LITERAL})\))?((?:${PATH_SEGMENT})*)\((.*)\);?$`, | |
| 41 | + "s", | |
| 42 | +); | |
| 43 | +const PATH_SEGMENT_MATCHER = new RegExp(String.raw`(?:\.(${IDENTIFIER})|\["(${SLUG})"\])`, "g"); | |
| 37 | 44 | |
| 38 | 45 | /** The proxy's argument is always `JSON.stringify` output, so anything that |
| 39 | 46 | * does not decode is, by construction, not something the proxy emitted. */ |
| 40 | 47 | const decodeArgs = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); |
| 41 | 48 | const decodeRole = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String)); |
| 42 | 49 | |
| 50 | +const decodePath = (serialized: string): readonly string[] => | |
| 51 | + Array.from(serialized.matchAll(PATH_SEGMENT_MATCHER), (match) => match[1] ?? match[2] ?? ""); | |
| 52 | + | |
| 43 | 53 | export type ParsedToolCall = { |
| 44 | − /** The dotted path segments under `tools`, e.g. `["github", "issues", "create"]`. | |
| 54 | + /** The path segments under `tools`, e.g. `["github", "issues", "create"]`. | |
| 45 | 55 | * The head is an integration slug (or a system-tool root); it is never a |
| 46 | 56 | * tier or a connection name. */ |
| 47 | 57 | readonly path: readonly string[]; |
| @@ -56,7 +66,7 @@ export type ParsedToolCall = { · 8 unmodified lines | ||
| 56 | 66 | /** The message handed back to the iframe when its code is not a tool call. */ |
| 57 | 67 | export const TOOL_CALL_CONTRACT_MESSAGE = [ |
| 58 | 68 | "execute-action accepts a single tool call, not arbitrary code.", |
| 59 | − 'The only accepted form is `return await tools.<integration>("<role>")?.<path>(<json>)` —', | |
| 69 | + 'The only accepted form is `return await tools<integration>("<role>")?<path>(<json>)` —', | |
| 60 | 70 | "exactly what the shell's `tools.*` proxy emits.", |
| 61 | 71 | "Interactive UI reaches integrations declaratively:", |
| 62 | 72 | "`tools.<integration>.<tool>.queryOptions(...)` / `.infiniteQueryOptions(...)` for reads,", |
| @@ -72,14 +82,14 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => { · 9 unmodified lines | ||
| 72 | 82 | const match = TOOL_CALL_CODE.exec(code.trim()); |
| 73 | 83 | if (!match) return null; |
| 74 | 84 | |
| 75 | − const [, root, serializedRole, , dottedRest, serializedArgs] = match; | |
| 76 | − if (root === undefined || dottedRest === undefined || serializedArgs === undefined) return null; | |
| 85 | + const [, root, serializedRole, serializedRest, serializedArgs] = match; | |
| 86 | + if (root === undefined || serializedRest === undefined || serializedArgs === undefined) | |
| 87 | + return null; | |
| 77 | 88 | |
| 78 | 89 | const args = decodeArgs(serializedArgs); |
| 79 | 90 | if (Option.isNone(args)) return null; |
| 80 | 91 | |
| 81 | − const rest = dottedRest.length > 0 ? dottedRest.slice(1).split(".") : []; | |
| 82 | − const path = [root, ...rest]; | |
| 92 | + const path = decodePath(`${root}${serializedRest}`); | |
| 83 | 93 | |
| 84 | 94 | if (serializedRole === undefined) return { path, args: args.value }; |
| 85 | 95 | |
| @@ -91,7 +101,11 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => { · 5 unmodified lines | ||
| 91 | 101 | return { path, role: role.value, args: args.value }; |
| 92 | 102 | }; |
| 93 | 103 | |
| 94 | −const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; | |
| 104 | +const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; | |
| 105 | +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/; | |
| 106 | + | |
| 107 | +const formatToolPathSegment = (segment: string): string => | |
| 108 | + TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`; | |
| 95 | 109 | |
| 96 | 110 | /** |
| 97 | 111 | * Build the codemode call for a RESOLVED address — the full |
| @@ -115,5 +129,5 @@ export const formatToolCallCode = (path: readonly string[], args: unknown): stri · 17 unmodified lines | ||
| 115 | 129 | throw new Error("Invalid resolved tool path."); |
| 116 | 130 | } |
| 117 | 131 | } |
| 118 | − return `return await tools.${path.join(".")}(${JSON.stringify(args ?? {})})`; | |
| 132 | + return `return await tools${path.map(formatToolPathSegment).join("")}(${JSON.stringify(args ?? {})})`; | |
| 119 | 133 | }; |