Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/kernel/runtime-dynamic-worker/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
});
});
33 changes: 26 additions & 7 deletions packages/kernel/runtime-dynamic-worker/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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),
}),
),
),
Expand Down Expand Up @@ -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),
}),
});

Expand Down
33 changes: 33 additions & 0 deletions packages/kernel/runtime-dynamic-worker/src/invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }) => {
Expand Down
15 changes: 14 additions & 1 deletion packages/kernel/runtime-dynamic-worker/src/module-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {",
Expand Down Expand Up @@ -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 };",
" }",
" }",
"}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@executor/react/components/card-stack";
import {
SourceIdentityFields,
slugifyNamespace,
useSourceIdentity,
} from "@executor/react/plugins/source-identity";
import {
Expand Down Expand Up @@ -118,7 +119,7 @@ function InlineCreateSecret(props: {
/>
</div>
{error && <p className="text-[11px] text-destructive">{error}</p>}
<div className="flex gap-1.5 pt-0.5">
<div className="flex justify-end gap-1.5 pt-0.5">
<Button variant="outline" size="xs" onClick={props.onCancel}>
Cancel
</Button>
Expand All @@ -127,7 +128,7 @@ function InlineCreateSecret(props: {
onClick={handleSave}
disabled={!secretId.trim() || !secretValue.trim() || saving}
>
{saving ? "Saving…" : "Create & use"}
{saving ? "Saving…" : "Create and use"}
</Button>
</div>
</div>
Expand Down Expand Up @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/graphql/src/react/AddGraphqlSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } : {}),
},
});
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/mcp/src/react/AddMcpSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/openapi/src/react/AddOpenApiSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type HeaderState,
} from "@executor/react/plugins/secret-header-auth";
import {
slugifyNamespace,
SourceIdentityFields,
useSourceIdentity,
} from "@executor/react/plugins/source-identity";
Expand Down Expand Up @@ -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 } : {}),
},
Expand Down
22 changes: 22 additions & 0 deletions packages/react/src/plugins/namespace.ts
Original file line number Diff line number Diff line change
@@ -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, "_");
}
4 changes: 2 additions & 2 deletions packages/react/src/plugins/secret-header-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ function InlineCreateSecret(props: {
{error && <FieldError>{error}</FieldError>}
</Field>
</FieldGroup>
<div className="flex gap-1.5 pt-0.5">
<div className="flex justify-end gap-1.5 pt-0.5">
<Button variant="outline" size="xs" onClick={props.onCancel}>
Cancel
</Button>
Expand All @@ -159,7 +159,7 @@ function InlineCreateSecret(props: {
onClick={handleSave}
disabled={!secretId.trim() || !secretValue.trim() || saving}
>
{saving ? "Saving…" : "Create & use"}
{saving ? "Saving…" : "Create and use"}
</Button>
</div>
</div>
Expand Down
21 changes: 3 additions & 18 deletions packages/react/src/plugins/source-identity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,7 +71,7 @@ export function useSourceIdentity(options?: UseSourceIdentityOptions): SourceIde
}, []);

const setNamespace = useCallback((next: string) => {
setNamespaceOverride(slugifyNamespace(next));
setNamespaceOverride(normalizeNamespaceInput(next));
}, []);

const reset = useCallback(() => {
Expand Down
20 changes: 20 additions & 0 deletions tests/source-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading