diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.test.ts b/packages/kernel/runtime-dynamic-worker/src/executor.test.ts index 74342b8f9..baae587de 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.test.ts @@ -43,6 +43,7 @@ describe("buildExecutorModule", () => { it("catches errors and returns them", () => { const module = buildExecutorModule("async () => 42", 5000); expect(module).toContain("catch (err)"); - expect(module).toContain("error: err.message"); + expect(module).toContain("const __formatError = (err) =>"); + expect(module).toContain("error: __formatError(err)"); }); }); diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.ts b/packages/kernel/runtime-dynamic-worker/src/executor.ts index 9c2f3542b..22ac2bc74 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.ts @@ -57,6 +57,30 @@ export type DynamicWorkerExecutorOptions = { const DEFAULT_TIMEOUT_MS = 5 * 60_000; const ENTRY_MODULE = "executor.js"; +const formatUnknownError = (cause: unknown): string => { + if (cause instanceof Error) { + return cause.message; + } + + if (typeof cause === "string") { + return cause; + } + + if (typeof cause === "object" && cause !== null) { + if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { + return cause.message; + } + + try { + return JSON.stringify(cause); + } catch { + return String(cause); + } + } + + return String(cause); +}; + // --------------------------------------------------------------------------- // ToolDispatcher — bridges RPC calls back to SandboxToolInvoker // --------------------------------------------------------------------------- @@ -83,12 +107,7 @@ export class ToolDispatcher extends RpcTarget { Effect.catchAll((cause) => Effect.succeed( JSON.stringify({ - error: - cause instanceof Error - ? cause.message - : typeof cause === "object" && cause !== null && "message" in cause - ? String((cause as { message: unknown }).message) - : String(cause), + error: formatUnknownError(cause), }), ), ), @@ -155,7 +174,7 @@ const runInDynamicWorker = ( try: () => evaluate(options, code, toolInvoker), catch: (cause) => new DynamicWorkerExecutionError({ - message: cause instanceof Error ? cause.message : String(cause), + message: formatUnknownError(cause), }), }); diff --git a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts index 0699ab536..717758a17 100644 --- a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts @@ -44,6 +44,24 @@ describe("ToolDispatcher", () => { expect(JSON.parse(result)).toEqual({ error: "tool broke" }); }); + it("serializes object-shaped tool errors", async () => { + const dispatcher = new ToolDispatcher({ + invoke: () => + Effect.fail({ + code: "forbidden", + detail: "missing team access", + }), + }); + + const result = await dispatcher.call("broken.tool", "{}"); + expect(JSON.parse(result)).toEqual({ + error: JSON.stringify({ + code: "forbidden", + detail: "missing team access", + }), + }); + }); + it("handles undefined args", async () => { const invoker = makeInvoker(({ args }) => args); const dispatcher = new ToolDispatcher(invoker); @@ -122,6 +140,21 @@ describe("makeDynamicWorkerExecutor", () => { expect(result.result).toBeNull(); }); + it("serializes thrown objects instead of returning [object Object]", async () => { + const executor = makeDynamicWorkerExecutor({ loader }); + const invoker = makeInvoker(() => null); + + const result = await Effect.runPromise( + executor.execute( + 'async () => { throw { code: "bad_request", detail: "team missing" }; }', + invoker, + ), + ); + + expect(result.error).toBe('{"code":"bad_request","detail":"team missing"}'); + expect(result.result).toBeNull(); + }); + it("invokes tools via the proxy and returns results", async () => { const executor = makeDynamicWorkerExecutor({ loader }); const invoker = makeInvoker(({ path, args }) => { diff --git a/packages/kernel/runtime-dynamic-worker/src/module-template.ts b/packages/kernel/runtime-dynamic-worker/src/module-template.ts index 2c6902290..952856f22 100644 --- a/packages/kernel/runtime-dynamic-worker/src/module-template.ts +++ b/packages/kernel/runtime-dynamic-worker/src/module-template.ts @@ -18,6 +18,19 @@ export const buildExecutorModule = (normalizedCode: string, timeoutMs: number): ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };', ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };', ' console.error = (...a) => { __logs.push("[error] " + a.map(String).join(" ")); };', + " const __formatError = (err) => {", + " if (err instanceof Error) return err.message;", + " if (typeof err === 'string') return err;", + " if (err && typeof err === 'object') {", + " if (typeof err.message === 'string' && err.message.length > 0) return err.message;", + " try {", + " return JSON.stringify(err);", + " } catch {", + " return String(err);", + " }", + " }", + " return String(err);", + " };", "", " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", " get(_target, prop) {", @@ -45,7 +58,7 @@ export const buildExecutorModule = (normalizedCode: string, timeoutMs: number): " ]);", " return { result, logs: __logs };", " } catch (err) {", - " return { result: undefined, error: err.message ?? String(err), logs: __logs };", + " return { result: undefined, error: __formatError(err), logs: __logs };", " }", " }", "}", diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index 749b29532..153d70774 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -14,6 +14,7 @@ import { } from "@executor/react/components/card-stack"; import { SourceIdentityFields, + slugifyNamespace, useSourceIdentity, } from "@executor/react/plugins/source-identity"; import { @@ -118,7 +119,7 @@ function InlineCreateSecret(props: { /> {error &&

{error}

} -
+
@@ -127,7 +128,7 @@ function InlineCreateSecret(props: { onClick={handleSave} disabled={!secretId.trim() || !secretValue.trim() || saving} > - {saving ? "Saving…" : "Create & use"} + {saving ? "Saving…" : "Create and use"}
@@ -614,7 +615,7 @@ export default function AddGoogleDiscoverySource(props: { payload: { name: identity.name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), - namespace: identity.namespace.trim() || undefined, + namespace: slugifyNamespace(identity.namespace) || undefined, auth: authKind === "oauth2" ? (oauthAuth ?? { kind: "none" as const }) diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index adc75b83d..ccc2f6eba 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -6,6 +6,7 @@ import { HeadersList } from "@executor/react/plugins/headers-list"; import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; import { displayNameFromUrl, + slugifyNamespace, SourceIdentityFields, useSourceIdentity, } from "@executor/react/plugins/source-identity"; @@ -70,7 +71,7 @@ export default function AddGraphqlSource(props: { payload: { endpoint: endpoint.trim(), name: identity.name.trim() || undefined, - namespace: identity.namespace.trim() || undefined, + namespace: slugifyNamespace(identity.namespace) || undefined, ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), }, }); diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 5666b1d1f..52f41a5ee 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -28,6 +28,7 @@ import { HeadersList } from "@executor/react/plugins/headers-list"; import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; import { displayNameFromUrl, + slugifyNamespace, SourceIdentityFields, useSourceIdentity, } from "@executor/react/plugins/source-identity"; @@ -486,7 +487,7 @@ export default function AddMcpSource(props: { payload: { transport: "remote" as const, name: remoteIdentity.name.trim() || probe.serverName || probe.name, - namespace: remoteIdentity.namespace.trim() || undefined, + namespace: slugifyNamespace(remoteIdentity.namespace) || undefined, endpoint: state.url.trim(), auth, ...(Object.keys(headers).length > 0 ? { headers } : {}), @@ -548,7 +549,7 @@ export default function AddMcpSource(props: { payload: { transport: "stdio" as const, name: stdioIdentity.name.trim() || cmd, - namespace: stdioIdentity.namespace.trim() || undefined, + namespace: slugifyNamespace(stdioIdentity.namespace) || undefined, command: cmd, args: parseStdioArgs(stdioArgs), env: parseStdioEnv(stdioEnv), diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index fb458a807..c557be02c 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -9,6 +9,7 @@ import { type HeaderState, } from "@executor/react/plugins/secret-header-auth"; import { + slugifyNamespace, SourceIdentityFields, useSourceIdentity, } from "@executor/react/plugins/source-identity"; @@ -226,7 +227,7 @@ export default function AddOpenApiSource(props: { payload: { spec: specUrl, name: identity.name.trim() || undefined, - namespace: identity.namespace.trim() || undefined, + namespace: slugifyNamespace(identity.namespace) || undefined, baseUrl: baseUrl.trim() || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), }, diff --git a/packages/react/src/plugins/namespace.ts b/packages/react/src/plugins/namespace.ts new file mode 100644 index 000000000..e4dcb0268 --- /dev/null +++ b/packages/react/src/plugins/namespace.ts @@ -0,0 +1,22 @@ +/** + * Normalizes a display name into a valid namespace identifier: lowercase + * snake_case, only `[a-z0-9_]`, no leading/trailing underscores. Produces + * strings that are safe to use as TypeScript/tool-name prefixes. + */ +export function slugifyNamespace(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Sanitizes namespace input as the user types without removing intentional + * underscores that are still in-progress at the field boundaries. + */ +export function normalizeNamespaceInput(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_"); +} diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index ae0dac751..db0ced4ed 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -150,7 +150,7 @@ function InlineCreateSecret(props: { {error && {error}} -
+
@@ -159,7 +159,7 @@ function InlineCreateSecret(props: { onClick={handleSave} disabled={!secretId.trim() || !secretValue.trim() || saving} > - {saving ? "Saving…" : "Create & use"} + {saving ? "Saving…" : "Create and use"}
diff --git a/packages/react/src/plugins/source-identity.tsx b/packages/react/src/plugins/source-identity.tsx index 49187bfec..db4cd646b 100644 --- a/packages/react/src/plugins/source-identity.tsx +++ b/packages/react/src/plugins/source-identity.tsx @@ -7,23 +7,8 @@ import { CardStackEntryField, } from "../components/card-stack"; import { Input } from "../components/input"; - -// --------------------------------------------------------------------------- -// Slug helper -// --------------------------------------------------------------------------- - -/** - * Normalizes a display name into a valid namespace identifier: lowercase - * snake_case, only `[a-z0-9_]`, no leading/trailing underscores. Produces - * strings that are safe to use as TypeScript/tool-name prefixes. - */ -export function slugifyNamespace(input: string): string { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); -} +import { normalizeNamespaceInput, slugifyNamespace } from "./namespace"; +export { normalizeNamespaceInput, slugifyNamespace } from "./namespace"; /** * Derives a display-name candidate from a URL by extracting its apex domain @@ -86,7 +71,7 @@ export function useSourceIdentity(options?: UseSourceIdentityOptions): SourceIde }, []); const setNamespace = useCallback((next: string) => { - setNamespaceOverride(slugifyNamespace(next)); + setNamespaceOverride(normalizeNamespaceInput(next)); }, []); const reset = useCallback(() => { diff --git a/tests/source-identity.test.ts b/tests/source-identity.test.ts new file mode 100644 index 000000000..ac31a2898 --- /dev/null +++ b/tests/source-identity.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeNamespaceInput, + slugifyNamespace, +} from "../packages/react/src/plugins/namespace"; + +describe("source identity namespace helpers", () => { + it("preserves underscores while the user is typing", () => { + expect(normalizeNamespaceInput("archil_useast1")).toBe("archil_useast1"); + expect(normalizeNamespaceInput("archil_")).toBe("archil_"); + expect(normalizeNamespaceInput("_archil")).toBe("_archil"); + }); + + it("canonicalizes the saved namespace", () => { + expect(slugifyNamespace("archil_")).toBe("archil"); + expect(slugifyNamespace("_archil")).toBe("archil"); + expect(slugifyNamespace("Archil USEast1")).toBe("archil_useast1"); + }); +});