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");
+ });
+});