History

Harden credential and tool-source parsing with typed validators and scoped runtime metadata.

Rhys Sullivan committed 8 months ago

Commit 5bb275e1 parent 0a65e9971 files · +2764 −1025Browse files

Unified · Split

71 files
  1. executor/apps/web/src/app/api/auth/anonymous-token/route.ts +9 −4
  2. executor/apps/web/src/lib/mcp/oauth-provider.test.ts +51 −0
  3. executor/apps/web/src/lib/mcp/oauth-provider.ts +55 −42
  4. executor/apps/web/src/lib/openapi/spec-inspector.ts +58 −34
  5. executor/apps/web/src/lib/session-storage.ts +49 −5
  6. executor/apps/web/src/lib/types.ts +22 −3
  7. executor/packages/core/src/credential-providers.test.ts +30 −1
  8. executor/packages/core/src/credential-providers.ts +100 −71
  9. executor/packages/core/src/credentials/workos-vault.test.ts +64 −0
  10. executor/packages/core/src/credentials/workos-vault.ts +128 −0
  11. executor/packages/core/src/graphql/field-tools.ts +10 −3
  12. executor/packages/core/src/managed/runtime-bootstrap.ts +2 −1
  13. executor/packages/core/src/managed/runtime-info.ts +7 −5
  14. executor/packages/core/src/mcp/server-approval.ts +14 −12
  15. executor/packages/core/src/openapi-auth.ts +7 −3
  16. executor/packages/core/src/openapi-compaction.ts +18 −14
  17. executor/packages/core/src/openapi-prepare.ts +46 −35
  18. executor/packages/core/src/openapi/schema-hints.ts +41 −37
  19. executor/packages/core/src/openapi/tool-builder.ts +38 −23
  20. executor/packages/core/src/postman-runtime.ts +9 −3
  21. executor/packages/core/src/postman-utils.ts +8 −2
  22. executor/packages/core/src/postman/collection-utils.ts +77 −32
  23. executor/packages/core/src/runtimes/runtime-core.ts +10 −4
  24. executor/packages/core/src/tool-discovery.ts +7 −5
  25. executor/packages/core/src/tool-discovery/indexing.ts +19 −8
  26. executor/packages/core/src/tool-source-loaders/graphql-loader.ts +69 −20
  27. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +10 −4
  28. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +54 −25
  29. executor/packages/core/src/tool-sources.test.ts +91 −0
  30. executor/packages/core/src/tool-typing/convex-sanitize.ts +6 −2
  31. executor/packages/core/src/tool-typing/schema-utils.ts +8 −4
  32. executor/packages/core/src/tool-typing/typebundle.ts +6 −2
  33. executor/packages/core/src/tool/source-auth.test.ts +77 −0
  34. executor/packages/core/src/tool/source-auth.ts +83 −0
  35. executor/packages/core/src/tool/source-execution.ts +10 −3
  36. executor/packages/core/src/tool/source-serialization.ts +76 −29
  37. executor/packages/core/src/types.ts +26 −6
  38. executor/packages/core/src/utils.ts +13 −7
  39. executor/packages/core/src/workspace/tool-cache.test.ts +17 −3
  40. executor/packages/database/convex/_generated/api.d.ts +79 −25
  41. executor/packages/database/convex/credentialsNode.ts +2 −0
  42. executor/packages/database/convex/database/credentials.ts +156 −55
  43. executor/packages/database/convex/database/policies.ts +75 −13
  44. executor/packages/database/convex/database/task_events.ts +1 −2
  45. executor/packages/database/convex/database/tasks.ts +4 −2
  46. executor/packages/database/convex/database/tool_sources.ts +64 −16
  47. executor/packages/database/convex/migrations.ts +4 −2
  48. executor/packages/database/convex/schema.ts +46 −18
  49. executor/packages/database/convex/toolRegistry.ts +62 −0
  50. executor/packages/database/convex/workspace.ts +51 −1
  51. executor/packages/database/src/accounts/delete-current-account.ts +24 −0
  52. executor/packages/database/src/auth/identity.ts +7 −3
  53. executor/packages/database/src/credentials-node/upsert-credential.ts +100 −85
  54. executor/packages/database/src/database/mappers.ts +59 −13
  55. executor/packages/database/src/database/readers.ts +18 −3
  56. executor/packages/database/src/database/tool_source_config.ts +20 −14
  57. executor/packages/database/src/database/validators.ts +5 −0
  58. executor/packages/database/src/lib/object.ts +9 −10
  59. executor/packages/database/src/runtime/policy.ts +51 −10
  60. executor/packages/database/src/runtime/tool_call_credentials.ts +43 −58
  61. executor/packages/database/src/runtime/tool_call_resolution.test.ts +54 −0
  62. executor/packages/database/src/runtime/tool_call_resolution.ts +43 −14
  63. executor/packages/database/src/runtime/tool_invocation.ts +27 −21
  64. executor/packages/database/src/runtime/tool_registry_state.ts +48 −27
  65. executor/packages/database/src/runtime/tool_source_loading.ts +89 −74
  66. executor/packages/database/src/runtime/workspace_tools.ts +65 −31
  67. executor/packages/runner-sandbox-host/src/bridge.ts +42 −35
  68. executor/packages/runner-sandbox-host/src/request.ts +18 −6
  69. executor/packages/runner-sandbox-host/src/sandbox.ts +32 −15
  70. executor/packages/runner-sandbox-host/src/types.ts +20 −9
  71. opencode.json +21 −11
executor/apps/web/src/app/api/auth/anonymous-token/route.ts+9−4
@@ -1,5 +1,10 @@
11 import type { NextRequest } from "next/server";
22 import { NextResponse } from "next/server";
3+import { z } from "zod";
4+
5+const anonymousTokenRequestSchema = z.object({
6+ actorId: z.string().trim().min(1).optional(),
7+});
38
49 function toSiteUrl(convexUrl?: string): string | null {
510 if (!convexUrl) {
@@ -48,9 +53,9 @@ export async function POST(request: NextRequest) { · 42 unmodified lines
4853
4954 let body: { actorId?: string } = {};
5055 try {
51− const parsed = await request.json() as { actorId?: unknown };
52− if (typeof parsed.actorId === "string" && parsed.actorId.trim().length > 0) {
53− body.actorId = parsed.actorId.trim();
56+ const parsed = anonymousTokenRequestSchema.safeParse(await request.json());
57+ if (parsed.success && parsed.data.actorId) {
58+ body.actorId = parsed.data.actorId;
5459 }
5560 } catch {
5661 body = {};
@@ -67,7 +72,7 @@ export async function POST(request: NextRequest) { · 10 unmodified lines
6772 });
6873
6974 const text = await response.text();
70− let payload: unknown = null;
75+ let payload: unknown;
7176 try {
7277 payload = JSON.parse(text);
7378 } catch {
executor/apps/web/src/lib/mcp/oauth-provider.test.ts+51−0
@@ -0,0 +1,51 @@
1+import { expect, test } from "bun:test";
2+import {
3+ decodePendingCookieValue,
4+ decodePopupResultCookieValue,
5+ encodePendingCookieValue,
6+ encodePopupResultCookieValue,
7+ type McpOAuthPending,
8+ type McpOAuthPopupResult,
9+} from "./oauth-provider";
10+
11+test("pending cookie round-trips with versioned envelope", () => {
12+ const pending: McpOAuthPending = {
13+ state: "state-123",
14+ sourceUrl: "https://example.com/mcp",
15+ redirectUrl: "https://app.example.com/oauth/callback",
16+ codeVerifier: "verifier-123",
17+ };
18+
19+ const encoded = encodePendingCookieValue(pending);
20+ expect(decodePendingCookieValue(encoded)).toEqual(pending);
21+});
22+
23+test("pending cookie decoder supports legacy payload shape", () => {
24+ const legacyPayload = {
25+ state: "legacy-state",
26+ sourceUrl: "https://legacy.example.com/mcp",
27+ redirectUrl: "https://legacy.example.com/callback",
28+ };
29+ const raw = Buffer.from(JSON.stringify(legacyPayload), "utf8").toString("base64url");
30+
31+ expect(decodePendingCookieValue(raw)).toEqual(legacyPayload);
32+});
33+
34+test("popup cookie round-trips with versioned envelope", () => {
35+ const result: McpOAuthPopupResult = {
36+ ok: true,
37+ sourceUrl: "https://example.com/mcp",
38+ accessToken: "token-123",
39+ refreshToken: "refresh-123",
40+ scope: "read write",
41+ expiresIn: 3600,
42+ };
43+
44+ const encoded = encodePopupResultCookieValue(result);
45+ expect(decodePopupResultCookieValue(encoded)).toEqual(result);
46+});
47+
48+test("popup cookie decoder rejects invalid payloads", () => {
49+ const invalidRaw = Buffer.from(JSON.stringify({ ok: "yes" }), "utf8").toString("base64url");
50+ expect(decodePopupResultCookieValue(invalidRaw)).toBeNull();
51+});
executor/apps/web/src/lib/mcp/oauth-provider.ts+55−42
@@ -1,5 +1,6 @@
11 import { randomUUID } from "node:crypto";
22 import { Result } from "better-result";
3+import { z } from "zod";
34 import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
45 import type {
56 OAuthClientInformationMixed,
@@ -28,6 +29,44 @@ export type McpOAuthPopupResult = { · 22 unmodified lines
2829 const COOKIE_PREFIX = "executor_mcp_oauth_";
2930 export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";
3031
32+const pendingSchema = z.object({
33+ state: z.string(),
34+ sourceUrl: z.string(),
35+ redirectUrl: z.string(),
36+ codeVerifier: z.string().optional(),
37+ clientInformation: z.custom<OAuthClientInformationMixed>().optional(),
38+});
39+
40+const versionedPendingSchema = z.object({
41+ version: z.literal(1),
42+ pending: pendingSchema,
43+});
44+
45+const popupResultSchema = z.object({
46+ ok: z.boolean(),
47+ sourceUrl: z.string().optional(),
48+ accessToken: z.string().optional(),
49+ refreshToken: z.string().optional(),
50+ scope: z.string().optional(),
51+ expiresIn: z.number().optional(),
52+ error: z.string().optional(),
53+});
54+
55+const versionedPopupResultSchema = z.object({
56+ version: z.literal(1),
57+ result: popupResultSchema,
58+});
59+
60+function decodeCookieJson(raw: string): unknown | null {
61+ const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
62+ if (decodedResult.isErr()) {
63+ return null;
64+ }
65+
66+ const parsedResult = Result.try(() => JSON.parse(decodedResult.value));
67+ return parsedResult.isOk() ? parsedResult.value : null;
68+}
69+
3170 export function buildPendingCookieName(state: string): string {
3271 return `${COOKIE_PREFIX}${state}`;
3372 }
@@ -37,67 +76,41 @@ export function createOAuthState(): string { · 3 unmodified lines
3776 }
3877
3978 export function encodePendingCookieValue(pending: McpOAuthPending): string {
40− return Buffer.from(JSON.stringify(pending), "utf8").toString("base64url");
79+ return Buffer.from(JSON.stringify({ version: 1, pending }), "utf8").toString("base64url");
4180 }
4281
4382 export function decodePendingCookieValue(raw: string): McpOAuthPending | null {
44− const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
45− if (!decodedResult.isOk()) {
46− return null;
47− }
48−
49− const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>);
50− if (!parsedResult.isOk()) {
83+ const decoded = decodeCookieJson(raw);
84+ if (!decoded) {
5185 return null;
5286 }
5387
54− const parsed = parsedResult.value;
55− if (
56− typeof parsed.state !== "string"
57− || typeof parsed.sourceUrl !== "string"
58− || typeof parsed.redirectUrl !== "string"
59− ) {
60− return null;
88+ const versionedResult = versionedPendingSchema.safeParse(decoded);
89+ if (versionedResult.success) {
90+ return versionedResult.data.pending;
6191 }
6292
63− return {
64− state: parsed.state,
65− sourceUrl: parsed.sourceUrl,
66− redirectUrl: parsed.redirectUrl,
67− ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}),
68− ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}),
69− };
93+ const legacyResult = pendingSchema.safeParse(decoded);
94+ return legacyResult.success ? legacyResult.data : null;
7095 }
7196
7297 export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
73− return Buffer.from(JSON.stringify(result), "utf8").toString("base64url");
98+ return Buffer.from(JSON.stringify({ version: 1, result }), "utf8").toString("base64url");
7499 }
75100
76101 export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {
77− const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
78− if (!decodedResult.isOk()) {
102+ const decoded = decodeCookieJson(raw);
103+ if (!decoded) {
79104 return null;
80105 }
81106
82− const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>);
83− if (!parsedResult.isOk()) {
84− return null;
85− }
86−
87− const parsed = parsedResult.value;
88− if (typeof parsed.ok !== "boolean") {
89− return null;
107+ const versionedResult = versionedPopupResultSchema.safeParse(decoded);
108+ if (versionedResult.success) {
109+ return versionedResult.data.result;
90110 }
91111
92− return {
93− ok: parsed.ok,
94− ...(typeof parsed.sourceUrl === "string" ? { sourceUrl: parsed.sourceUrl } : {}),
95− ...(typeof parsed.accessToken === "string" ? { accessToken: parsed.accessToken } : {}),
96− ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
97− ...(typeof parsed.scope === "string" ? { scope: parsed.scope } : {}),
98− ...(typeof parsed.expiresIn === "number" ? { expiresIn: parsed.expiresIn } : {}),
99− ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
100− };
112+ const legacyResult = popupResultSchema.safeParse(decoded);
113+ return legacyResult.success ? legacyResult.data : null;
101114 }
102115
103116 export class McpPopupOAuthProvider implements OAuthClientProvider {
executor/apps/web/src/lib/openapi/spec-inspector.ts+58−34
@@ -1,4 +1,6 @@
1+import { Result } from "better-result";
12 import { parse as parseYaml } from "yaml";
3+import { z } from "zod";
24 import type { CredentialScope, SourceAuthType } from "@/lib/types";
35
46 type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">;
@@ -15,11 +17,36 @@ type OpenApiInspectionResult = { · 10 unmodified lines
1517 inferredAuth: InferredSpecAuth;
1618 };
1719
18−function asRecord(value: unknown): Record<string, unknown> {
19− if (!value || typeof value !== "object" || Array.isArray(value)) {
20− return {};
20+const recordSchema = z.record(z.unknown());
21+
22+const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
23+
24+const securitySchemeSchema = z.object({
25+ type: z.string().optional(),
26+ scheme: z.string().optional(),
27+ in: z.string().optional(),
28+ name: z.string().optional(),
29+});
30+
31+function toRecordValue(value: unknown): Record<string, unknown> {
32+ const parsed = recordSchema.safeParse(value);
33+ return parsed.success ? parsed.data : {};
34+}
35+
36+function parseObjectFromText(text: string, format: "json" | "yaml"): Record<string, unknown> | null {
37+ const parsed = format === "json"
38+ ? Result.try(() => JSON.parse(text))
39+ : Result.try(() => parseYaml(text));
40+ if (parsed.isErr()) {
41+ return null;
42+ }
43+
44+ const parsedRecord = recordSchema.safeParse(parsed.value);
45+ if (!parsedRecord.success || Object.keys(parsedRecord.data).length === 0) {
46+ return null;
2147 }
22− return value as Record<string, unknown>;
48+
49+ return parsedRecord.data;
2350 }
2451
2552 function parseOpenApiPayload(raw: string, sourceUrl: string, contentType: string): Record<string, unknown> {
@@ -27,40 +54,34 @@ function parseOpenApiPayload(raw: string, sourceUrl: string, contentType: string · 1 unmodified line
2754 const loweredUrl = sourceUrl.toLowerCase();
2855 const preferJson = loweredContentType.includes("json") || loweredUrl.endsWith(".json");
2956
30− const tryJson = () => asRecord(JSON.parse(raw));
31− const tryYaml = () => asRecord(parseYaml(raw));
32−
33− const parsed = preferJson
34− ? (() => {
35− try {
36− return tryJson();
37− } catch {
38− return tryYaml();
39− }
40− })()
41− : (() => {
42− try {
43− return tryYaml();
44− } catch {
45− return tryJson();
46− }
47− })();
48−
49− if (Object.keys(parsed).length === 0) {
57+ const primary = preferJson
58+ ? parseObjectFromText(raw, "json")
59+ : parseObjectFromText(raw, "yaml");
60+ const fallback = preferJson
61+ ? parseObjectFromText(raw, "yaml")
62+ : parseObjectFromText(raw, "json");
63+ const parsed = primary ?? fallback;
64+
65+ if (!parsed) {
5066 throw new Error("Spec payload is empty or not an object");
5167 }
5268
5369 return parsed;
5470 }
5571
56−function normalizeAuthScheme(scheme: Record<string, unknown>): {
72+function normalizeAuthScheme(scheme: unknown): {
5773 type: SupportedAuthType;
5874 header?: string;
5975 } | null {
60− const type = String(scheme.type ?? "").toLowerCase();
76+ const parsedScheme = securitySchemeSchema.safeParse(scheme);
77+ if (!parsedScheme.success) {
78+ return null;
79+ }
80+
81+ const type = (parsedScheme.data.type ?? "").toLowerCase();
6182
6283 if (type === "http") {
63− const httpScheme = String(scheme.scheme ?? "").toLowerCase();
84+ const httpScheme = (parsedScheme.data.scheme ?? "").toLowerCase();
6485 if (httpScheme === "bearer") {
6586 return { type: "bearer" };
6687 }
@@ -71,8 +92,8 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 4 unmodified lines
7192 }
7293
7394 if (type === "apikey") {
74− const location = String(scheme.in ?? "").toLowerCase();
75− const header = typeof scheme.name === "string" ? scheme.name.trim() : "";
95+ const location = (parsedScheme.data.in ?? "").toLowerCase();
96+ const header = (parsedScheme.data.name ?? "").trim();
7697 if (location === "header" && header.length > 0) {
7798 return { type: "apiKey", header };
7899 }
@@ -87,23 +108,26 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 8 unmodified lines
87108 }
88109
89110 function inferSecuritySchemaAuth(spec: Record<string, unknown>): InferredSpecAuth {
90− const components = asRecord(spec.components);
91− const securitySchemes = asRecord(components.securitySchemes);
111+ const components = toRecordValue(spec.components);
112+ const securitySchemes = toRecordValue(components.securitySchemes);
92113 const schemeNames = Object.keys(securitySchemes);
93114 if (schemeNames.length === 0) {
94115 return { type: "none", inferred: true };
95116 }
96117
97118 const globalSecurity = Array.isArray(spec.security)
98− ? spec.security.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))
119+ ? spec.security.flatMap((entry) => {
120+ const parsed = securityRequirementSchema.safeParse(entry);
121+ return parsed.success ? [parsed.data] : [];
122+ })
99123 : [];
100124 const referencedSchemeNames = globalSecurity.flatMap((entry) => Object.keys(entry));
101125 const candidateNames = referencedSchemeNames.length > 0
102− ? [...new Set(referencedSchemeNames.filter((name) => typeof securitySchemes[name] === "object"))]
126+ ? [...new Set(referencedSchemeNames.filter((name) => Object.prototype.hasOwnProperty.call(securitySchemes, name)))]
103127 : schemeNames;
104128
105129 const normalized = candidateNames
106− .map((name) => normalizeAuthScheme(asRecord(securitySchemes[name])))
130+ .map((name) => normalizeAuthScheme(securitySchemes[name]))
107131 .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
108132
109133 if (normalized.length === 0) {
executor/apps/web/src/lib/session-storage.ts+49−5
@@ -1,9 +1,16 @@
11 import type { Id } from "@executor/database/convex/_generated/dataModel";
2+import { z } from "zod";
23
34 const SESSION_KEY = "executor_session_id";
45 const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";
56 const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";
67
8+const workspaceByAccountSchema = z.record(z.string());
9+
10+function isWorkspaceId(value: string): value is Id<"workspaces"> {
11+ return value.trim().length > 0;
12+}
13+
714 export function readStoredSessionId() {
815 if (typeof window === "undefined") {
916 return null;
@@ -17,7 +24,12 @@ export function readStoredActiveWorkspaceId() { · 7 unmodified lines
1724 return null;
1825 }
1926
20− return localStorage.getItem(ACTIVE_WORKSPACE_KEY) as Id<"workspaces"> | null;
27+ const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY);
28+ if (!stored || !isWorkspaceId(stored)) {
29+ return null;
30+ }
31+
32+ return stored;
2133 }
2234
2335 export function persistSessionId(sessionId: string) {
@@ -33,18 +45,50 @@ export function clearSessionStorage() { · 9 unmodified lines
3345 localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
3446 }
3547
36−export function readWorkspaceByAccount() {
48+export function readWorkspaceByAccount(): Record<string, Id<"workspaces">> {
49+ if (typeof window === "undefined") {
50+ return {};
51+ }
52+
3753 const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);
3854 if (!raw) {
39− return {} as Record<string, Id<"workspaces">>;
55+ return {};
4056 }
4157
58+ let parsedJson: unknown;
4259 try {
43− const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>;
44− return parsed ?? {};
60+ parsedJson = JSON.parse(raw);
4561 } catch {
4662 return {};
4763 }
64+
65+ const parsedMap = workspaceByAccountSchema.safeParse(parsedJson);
66+ if (!parsedMap.success) {
67+ return {};
68+ }
69+
70+ let changed = false;
71+ const cleaned: Record<string, Id<"workspaces">> = {};
72+ for (const [accountIdRaw, workspaceIdRaw] of Object.entries(parsedMap.data)) {
73+ const accountId = accountIdRaw.trim();
74+ const workspaceId = workspaceIdRaw.trim();
75+ if (!accountId || !isWorkspaceId(workspaceId)) {
76+ changed = true;
77+ continue;
78+ }
79+
80+ if (accountId !== accountIdRaw || workspaceId !== workspaceIdRaw) {
81+ changed = true;
82+ }
83+
84+ cleaned[accountId] = workspaceId;
85+ }
86+
87+ if (changed) {
88+ writeWorkspaceByAccount(cleaned);
89+ }
90+
91+ return cleaned;
4892 }
4993
5094 function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {
executor/apps/web/src/lib/types.ts+22−3
@@ -5,8 +5,13 @@ import type { Id } from "@executor/database/convex/_generated/dataModel"; · 4 unmodified lines
55 export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied";
66 export type ApprovalStatus = "pending" | "approved" | "denied";
77 export type PolicyDecision = "allow" | "require_approval" | "deny";
8+export type PolicyScopeType = "organization" | "workspace";
9+export type PolicyMatchType = "glob" | "exact";
10+export type PolicyEffect = "allow" | "deny";
11+export type PolicyApprovalMode = "inherit" | "auto" | "required";
812 export type CredentialScope = "workspace" | "actor";
913 export type CredentialProvider = "local-convex" | "workos-vault";
14+export type OwnerScopeType = "organization" | "workspace";
1015 export type ToolApprovalMode = "auto" | "required";
1116 export type ToolSourceType = "mcp" | "openapi" | "graphql";
1217
@@ -65,9 +70,17 @@ export interface TaskEventRecord { · 52 unmodified lines
6570
6671 export interface AccessPolicyRecord {
6772 id: string;
68− workspaceId: string;
73+ scopeType?: PolicyScopeType;
74+ organizationId?: string;
75+ workspaceId?: string;
76+ targetActorId?: string;
6977 actorId?: string;
7078 clientId?: string;
79+ resourceType?: "tool_path";
80+ resourcePattern?: string;
81+ matchType?: PolicyMatchType;
82+ effect?: PolicyEffect;
83+ approvalMode?: PolicyApprovalMode;
7184 toolPathPattern: string;
7285 decision: PolicyDecision;
7386 priority: number;
@@ -78,7 +91,9 @@ export interface AccessPolicyRecord { · 4 unmodified lines
7891 export interface CredentialRecord {
7992 id: string;
8093 bindingId?: string;
81− workspaceId: string;
94+ ownerScopeType?: OwnerScopeType;
95+ organizationId?: string;
96+ workspaceId?: string;
8297 sourceKey: string;
8398 scope: CredentialScope;
8499 actorId?: string;
@@ -92,10 +107,14 @@ export interface CredentialRecord { · 7 unmodified lines
92107
93108 export interface ToolSourceRecord {
94109 id: string;
95− workspaceId: string;
110+ ownerScopeType?: OwnerScopeType;
111+ organizationId?: string;
112+ workspaceId?: string;
96113 name: string;
97114 type: ToolSourceType;
98115 config: Record<string, unknown>;
116+ specHash?: string;
117+ authFingerprint?: string;
99118 enabled: boolean;
100119 createdAt: number;
101120 updatedAt: number;
executor/packages/core/src/credential-providers.test.ts+30−1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2−import { resolveCredentialPayload } from "./credential-providers";
2+import { resolveCredentialPayload, resolveCredentialPayloadResult } from "./credential-providers";
33
44 test("local provider returns stored payload", async () => {
55 const payload = await resolveCredentialPayload({
@@ -46,6 +46,23 @@ test("WorkOS Vault provider falls back to id key and raw token", async () => { · 40 unmodified lines
4646 expect(payload).toEqual({ token: "ghp_raw_token" });
4747 });
4848
49+test("WorkOS Vault provider parses env-style key/value payloads", async () => {
50+ const payload = await resolveCredentialPayload(
51+ {
52+ provider: "workos-vault",
53+ secretJson: { objectId: "secret_789" },
54+ },
55+ {
56+ readVaultObject: async ({ objectId }) => {
57+ expect(objectId).toBe("secret_789");
58+ return "TOKEN=vault-token\nHEADER_NAME=\"x-api-key\"";
59+ },
60+ },
61+ );
62+
63+ expect(payload).toEqual({ TOKEN: "vault-token", HEADER_NAME: "x-api-key" });
64+});
65+
4966 test("WorkOS Vault provider gives actionable error on missing reference", async () => {
5067 await expect(
5168 resolveCredentialPayload({
@@ -54,3 +71,15 @@ test("WorkOS Vault provider gives actionable error on missing reference", async · 2 unmodified lines
5471 }),
5572 ).rejects.toThrow("Re-save this credential");
5673 });
74+
75+test("resolveCredentialPayloadResult returns Err for missing WorkOS reference", async () => {
76+ const result = await resolveCredentialPayloadResult({
77+ provider: "workos-vault",
78+ secretJson: {},
79+ });
80+
81+ expect(result.isErr()).toBe(true);
82+ if (result.isErr()) {
83+ expect(result.error.message).toContain("Re-save this credential");
84+ }
85+});
executor/packages/core/src/credential-providers.ts+100−71
@@ -1,10 +1,13 @@
11 "use node";
22
3−import { WorkOS } from "@workos-inc/node";
43 import { Result } from "better-result";
54 import { z } from "zod";
5+import {
6+ createWorkosClient,
7+ parseWorkosVaultReference,
8+ withWorkosVaultRetryResult,
9+} from "./credentials/workos-vault";
610 import type { CredentialProvider, CredentialRecord } from "./types";
7−import { asRecord } from "./utils";
811
912 type CredentialPayload = Record<string, unknown>;
1013
@@ -18,67 +21,31 @@ type VaultObjectReader = (input: VaultReadInput) => Promise<string>; · 7 unmodified lines
1821 type ProviderResolver = (
1922 record: Pick<CredentialRecord, "provider" | "secretJson">,
2023 readVaultObject: VaultObjectReader,
21−) => Promise<CredentialPayload | null>;
24+) => Promise<Result<CredentialPayload | null, Error>>;
2225
23−const workosVaultSecretConfigSchema = z.object({
24− objectId: z.string().optional(),
25− id: z.string().optional(),
26− apiKey: z.string().optional(),
27−});
26+const recordSchema = z.record(z.unknown());
2827
29−function resolveWorkosApiKey(explicitApiKey?: string): string {
30− const candidate =
31− explicitApiKey?.trim() ||
32− process.env.WORKOS_API_KEY?.trim() ||
33− "";
34−
35− if (!candidate) {
36− throw new Error("WorkOS Vault provider requires WORKOS_API_KEY");
37− }
38−
39− return candidate;
28+function coerceRecord(value: unknown): Record<string, unknown> {
29+ const parsed = recordSchema.safeParse(value);
30+ return parsed.success ? parsed.data : {};
4031 }
4132
42−function isRetryableVaultError(error: unknown): boolean {
43− if (!(error instanceof Error)) {
44− return false;
45− }
46− const message = error.message.toLowerCase();
47− return (
48− message.includes("not yet ready") ||
49− message.includes("can be retried") ||
50− (message.includes("kek") && message.includes("ready"))
51− );
52−}
33+const envSecretKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
5334
54−async function sleep(ms: number): Promise<void> {
55− await new Promise((resolve) => setTimeout(resolve, ms));
56−}
57−
58−async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> {
59− const maxAttempts = 5;
60− let delayMs = 250;
61−
62− for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
63− try {
64− return await operation();
65− } catch (error) {
66− if (!isRetryableVaultError(error) || attempt === maxAttempts) {
67− throw error;
68− }
69− await sleep(delayMs);
70− delayMs *= 2;
71− }
35+async function defaultReadVaultObject(input: VaultReadInput): Promise<string> {
36+ const workosResult = createWorkosClient(input.apiKey);
37+ if (workosResult.isErr()) {
38+ throw new Error("WorkOS Vault provider requires WORKOS_API_KEY");
7239 }
7340
74− throw new Error("Unreachable retry state");
75−}
76−
77−async function defaultReadVaultObject(input: VaultReadInput): Promise<string> {
78− const workos = new WorkOS(resolveWorkosApiKey(input.apiKey));
79− const object = await withVaultRetry(async () => {
80− return await workos.vault.readObject({ id: input.objectId });
41+ const readResult = await withWorkosVaultRetryResult(async () => {
42+ return await workosResult.value.vault.readObject({ id: input.objectId });
8143 });
44+ if (readResult.isErr()) {
45+ throw readResult.error;
46+ }
47+
48+ const object = readResult.value;
8249 const value = typeof object.value === "string" ? object.value : "";
8350 if (!value.trim()) {
8451 throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`);
@@ -92,41 +59,91 @@ function parseSecretValue(raw: string): CredentialPayload { · 7 unmodified lines
9259 return {};
9360 }
9461
95− const parsedJson = Result.try(() => JSON.parse(value));
96− if (parsedJson.isOk()) {
97− const parsed = parsedJson.value;
98− const asObject = asRecord(parsed);
62+ try {
63+ const parsed = JSON.parse(value);
64+ const asObject = coerceRecord(parsed);
9965 if (Object.keys(asObject).length > 0) {
10066 return asObject;
10167 }
68+ } catch {
69+ // Continue with env-style and token fallback parsing.
70+ }
71+
72+ const envStyleResult = parseEnvStyleSecret(value);
73+ if (envStyleResult) {
74+ return envStyleResult;
10275 }
10376
10477 return { token: value };
10578 }
10679
80+function stripWrappingQuotes(value: string): string {
81+ if (value.length < 2) {
82+ return value;
83+ }
84+
85+ const startsWithSingle = value.startsWith("'") && value.endsWith("'");
86+ const startsWithDouble = value.startsWith('"') && value.endsWith('"');
87+ return startsWithSingle || startsWithDouble ? value.slice(1, -1) : value;
88+}
89+
90+function parseEnvStyleSecret(raw: string): CredentialPayload | null {
91+ const lines = raw
92+ .split(/\r?\n/)
93+ .map((line) => line.trim())
94+ .filter((line) => line.length > 0 && !line.startsWith("#"));
95+ if (lines.length === 0) {
96+ return null;
97+ }
98+
99+ const parsedPayload: CredentialPayload = {};
100+ for (const line of lines) {
101+ const separatorIndex = line.indexOf("=");
102+ if (separatorIndex <= 0) {
103+ return null;
104+ }
105+
106+ const keyRaw = line.slice(0, separatorIndex).trim();
107+ const keyResult = envSecretKeySchema.safeParse(keyRaw);
108+ if (!keyResult.success) {
109+ return null;
110+ }
111+
112+ const valueRaw = line.slice(separatorIndex + 1).trim();
113+ parsedPayload[keyResult.data] = stripWrappingQuotes(valueRaw);
114+ }
115+
116+ return Object.keys(parsedPayload).length > 0 ? parsedPayload : null;
117+}
118+
107119 async function resolveLocalConvex(
108120 record: Pick<CredentialRecord, "secretJson">,
109−): Promise<CredentialPayload | null> {
110− return asRecord(record.secretJson);
121+): Promise<Result<CredentialPayload | null, Error>> {
122+ return Result.ok(coerceRecord(record.secretJson));
111123 }
112124
113125 async function resolveWorkosVault(
114126 record: Pick<CredentialRecord, "secretJson">,
115127 readVaultObject: VaultObjectReader,
116−): Promise<CredentialPayload | null> {
117− const parsedConfig = workosVaultSecretConfigSchema.safeParse(asRecord(record.secretJson));
118− const config = parsedConfig.success ? parsedConfig.data : {};
119− const objectId = config.objectId?.trim() || config.id?.trim() || "";
128+): Promise<Result<CredentialPayload | null, Error>> {
129+ const parsedReference = parseWorkosVaultReference(coerceRecord(record.secretJson));
130+ const objectId = parsedReference.objectId ?? "";
120131
121132 if (!objectId) {
122− throw new Error(
123− "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.",
133+ return Result.err(
134+ new Error("Encrypted credential is missing its secure reference. Re-save this credential in the dashboard."),
124135 );
125136 }
126− const apiKey = config.apiKey?.trim() || undefined;
137+ const apiKey = parsedReference.apiKey;
127138
128− const raw = await readVaultObject({ objectId, apiKey });
129− return parseSecretValue(raw);
139+ const rawResult = await Result.tryPromise(async () => {
140+ return await readVaultObject({ objectId, apiKey });
141+ });
142+ if (rawResult.isErr()) {
143+ return Result.err(new Error(rawResult.error.message));
144+ }
145+
146+ return Result.ok(parseSecretValue(rawResult.value));
130147 }
131148
132149 const providers: Record<CredentialProvider, ProviderResolver> = {
@@ -138,10 +155,22 @@ export async function resolveCredentialPayload( · 5 unmodified lines
138155 record: Pick<CredentialRecord, "provider" | "secretJson">,
139156 options?: { readVaultObject?: VaultObjectReader },
140157 ): Promise<CredentialPayload | null> {
158+ const resolved = await resolveCredentialPayloadResult(record, options);
159+ if (resolved.isErr()) {
160+ throw resolved.error;
161+ }
162+
163+ return resolved.value;
164+}
165+
166+export async function resolveCredentialPayloadResult(
167+ record: Pick<CredentialRecord, "provider" | "secretJson">,
168+ options?: { readVaultObject?: VaultObjectReader },
169+): Promise<Result<CredentialPayload | null, Error>> {
141170 const provider = record.provider;
142171 const resolver = providers[provider];
143172 if (!resolver) {
144− throw new Error(`Unsupported credential provider: ${provider}`);
173+ return Result.err(new Error(`Unsupported credential provider: ${provider}`));
145174 }
146175
147176 return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject);
executor/packages/core/src/credentials/workos-vault.test.ts+64−0
@@ -0,0 +1,64 @@
1+import { expect, test } from "bun:test";
2+import {
3+ extractWorkosVaultObjectId,
4+ isWorkosVaultRetryableError,
5+ parseWorkosVaultReference,
6+ withWorkosVaultRetryResult,
7+} from "./workos-vault";
8+
9+test("parseWorkosVaultReference normalizes object id aliases", () => {
10+ expect(parseWorkosVaultReference({ objectId: " secret_123 " })).toEqual({ objectId: "secret_123" });
11+ expect(parseWorkosVaultReference({ id: "secret_456", apiKey: " key_abc " })).toEqual({
12+ objectId: "secret_456",
13+ apiKey: "key_abc",
14+ });
15+ expect(parseWorkosVaultReference("not-an-object")).toEqual({});
16+});
17+
18+test("extractWorkosVaultObjectId returns null when missing", () => {
19+ expect(extractWorkosVaultObjectId({ apiKey: "key" })).toBeNull();
20+ expect(extractWorkosVaultObjectId({ id: "secret_789" })).toBe("secret_789");
21+});
22+
23+test("isWorkosVaultRetryableError detects retryable vault messages", () => {
24+ expect(isWorkosVaultRetryableError(new Error("KEK not yet ready"))).toBe(true);
25+ expect(isWorkosVaultRetryableError(new Error("This can be retried"))).toBe(true);
26+ expect(isWorkosVaultRetryableError(new Error("Permission denied"))).toBe(false);
27+});
28+
29+test("withWorkosVaultRetryResult retries retryable errors and succeeds", async () => {
30+ let attempts = 0;
31+ const result = await withWorkosVaultRetryResult(async () => {
32+ attempts += 1;
33+ if (attempts < 3) {
34+ throw new Error("KEK not yet ready");
35+ }
36+ return "ok";
37+ }, {
38+ maxAttempts: 5,
39+ initialDelayMs: 0,
40+ maxDelayMs: 1,
41+ });
42+
43+ expect(result.isOk()).toBe(true);
44+ if (result.isOk()) {
45+ expect(result.value).toBe("ok");
46+ }
47+ expect(attempts).toBe(3);
48+});
49+
50+test("withWorkosVaultRetryResult returns configured exhaustion error", async () => {
51+ const result = await withWorkosVaultRetryResult(async () => {
52+ throw new Error("can be retried");
53+ }, {
54+ maxAttempts: 2,
55+ initialDelayMs: 0,
56+ maxDelayMs: 1,
57+ exhaustionErrorMessage: "still warming up",
58+ });
59+
60+ expect(result.isErr()).toBe(true);
61+ if (result.isErr()) {
62+ expect(result.error.message).toBe("still warming up");
63+ }
64+});
executor/packages/core/src/credentials/workos-vault.ts+128−0
@@ -0,0 +1,128 @@
1+"use node";
2+
3+import { WorkOS } from "@workos-inc/node";
4+import { Result } from "better-result";
5+import { z } from "zod";
6+
7+const workosVaultReferenceSchema = z.object({
8+ objectId: z.string().optional(),
9+ id: z.string().optional(),
10+ apiKey: z.string().optional(),
11+});
12+
13+function normalizeUnknownError(error: unknown): Error {
14+ return error instanceof Error ? error : new Error(String(error));
15+}
16+
17+function normalizeDelayMs(value: number, fallback: number): number {
18+ if (!Number.isFinite(value) || value < 0) {
19+ return fallback;
20+ }
21+ return Math.floor(value);
22+}
23+
24+function normalizeAttempts(value: number, fallback: number): number {
25+ if (!Number.isFinite(value) || value < 1) {
26+ return fallback;
27+ }
28+ return Math.floor(value);
29+}
30+
31+export function parseWorkosVaultReference(value: unknown): {
32+ objectId?: string;
33+ apiKey?: string;
34+} {
35+ const parsed = workosVaultReferenceSchema.safeParse(value);
36+ if (!parsed.success) {
37+ return {};
38+ }
39+
40+ const objectId = (parsed.data.objectId ?? parsed.data.id ?? "").trim();
41+ const apiKey = (parsed.data.apiKey ?? "").trim();
42+
43+ return {
44+ ...(objectId ? { objectId } : {}),
45+ ...(apiKey ? { apiKey } : {}),
46+ };
47+}
48+
49+export function extractWorkosVaultObjectId(value: unknown): string | null {
50+ const parsed = parseWorkosVaultReference(value);
51+ return parsed.objectId ?? null;
52+}
53+
54+export function resolveWorkosApiKey(explicitApiKey?: string): Result<string, Error> {
55+ const candidate = explicitApiKey?.trim() || process.env.WORKOS_API_KEY?.trim() || "";
56+ if (!candidate) {
57+ return Result.err(new Error("Encrypted storage requires WORKOS_API_KEY"));
58+ }
59+
60+ return Result.ok(candidate);
61+}
62+
63+export function createWorkosClient(explicitApiKey?: string): Result<WorkOS, Error> {
64+ const apiKeyResult = resolveWorkosApiKey(explicitApiKey);
65+ if (apiKeyResult.isErr()) {
66+ return apiKeyResult;
67+ }
68+
69+ return Result.ok(new WorkOS(apiKeyResult.value));
70+}
71+
72+export function isWorkosVaultRetryableError(error: unknown): boolean {
73+ if (!(error instanceof Error)) {
74+ return false;
75+ }
76+
77+ const message = error.message.toLowerCase();
78+ return (
79+ message.includes("not yet ready")
80+ || message.includes("can be retried")
81+ || (message.includes("kek") && message.includes("ready"))
82+ );
83+}
84+
85+async function sleep(ms: number): Promise<void> {
86+ await new Promise((resolve) => setTimeout(resolve, ms));
87+}
88+
89+export interface WorkosVaultRetryOptions {
90+ maxAttempts?: number;
91+ initialDelayMs?: number;
92+ maxDelayMs?: number;
93+ exhaustionErrorMessage?: string;
94+}
95+
96+export async function withWorkosVaultRetryResult<T>(
97+ operation: () => Promise<T>,
98+ options: WorkosVaultRetryOptions = {},
99+): Promise<Result<T, Error>> {
100+ const maxAttempts = normalizeAttempts(options.maxAttempts ?? 5, 5);
101+ const maxDelayMs = normalizeDelayMs(options.maxDelayMs ?? 10_000, 10_000);
102+ let delayMs = normalizeDelayMs(options.initialDelayMs ?? 250, 250);
103+
104+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
105+ const valueResult = await Result.tryPromise(operation);
106+ if (valueResult.isOk()) {
107+ return valueResult;
108+ }
109+
110+ const cause = valueResult.error.cause;
111+ const normalizedError = normalizeUnknownError(cause);
112+ if (!isWorkosVaultRetryableError(normalizedError)) {
113+ return Result.err(normalizedError);
114+ }
115+
116+ if (attempt === maxAttempts) {
117+ if (options.exhaustionErrorMessage) {
118+ return Result.err(new Error(options.exhaustionErrorMessage));
119+ }
120+ return Result.err(normalizedError);
121+ }
122+
123+ await sleep(delayMs);
124+ delayMs = Math.min(Math.max(0, delayMs * 2), maxDelayMs);
125+ }
126+
127+ return Result.err(new Error("WorkOS Vault retry loop exhausted unexpectedly"));
128+}
executor/packages/core/src/graphql/field-tools.ts+10−3
@@ -1,5 +1,12 @@
11 import type { GraphqlExecutionEnvelope } from "../tool/source-execution";
2−import { asRecord } from "../utils";
2+import { z } from "zod";
3+
4+const recordSchema = z.record(z.unknown());
5+
6+function coerceRecord(value: unknown): Record<string, unknown> {
7+ const parsed = recordSchema.safeParse(value);
8+ return parsed.success ? parsed.data : {};
9+}
310
411 export interface GqlTypeRef {
512 kind: string;
@@ -49,7 +56,7 @@ export function selectGraphqlFieldEnvelope( · 43 unmodified lines
4956 envelope: GraphqlExecutionEnvelope,
5057 operationName: string,
5158 ): GraphqlExecutionEnvelope {
52− const record = asRecord(envelope.data);
59+ const record = coerceRecord(envelope.data);
5360 if (Object.prototype.hasOwnProperty.call(record, operationName)) {
5461 return {
5562 data: record[operationName],
@@ -82,7 +89,7 @@ export function normalizeGraphqlFieldVariables( · 26 unmodified lines
8289 && typeof value === "object"
8390 && !Array.isArray(value)
8491 ) {
85− const nested = asRecord(value);
92+ const nested = coerceRecord(value);
8693 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {
8794 return { [argName]: nested[argName] };
8895 }
executor/packages/core/src/managed/runtime-bootstrap.ts+2−1
@@ -82,9 +82,10 @@ async function getConfiguredFunctionsPath(candidate: string): Promise<string | n · 81 unmodified lines
8282 try {
8383 const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8");
8484 const parsed = convexJsonSchema.safeParse(JSON.parse(raw));
85− if (!parsed.success || typeof parsed.data.functions !== "string") {
85+ if (!parsed.success || !parsed.data.functions) {
8686 return null;
8787 }
88+
8889 const value = parsed.data.functions.trim();
8990 return value.length > 0 ? value : null;
9091 } catch {
executor/packages/core/src/managed/runtime-info.ts+7−5
@@ -1,7 +1,6 @@
11 import fs from "node:fs/promises";
22 import os from "node:os";
33 import path from "node:path";
4−import { Result } from "better-result";
54 import { z } from "zod";
65
76 import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime";
@@ -151,10 +150,13 @@ export function runtimeInfo(): ManagedRuntimeInfo { · 143 unmodified lines
151150 export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> {
152151 if (await pathExists(info.configPath)) {
153152 const raw = await fs.readFile(info.configPath, "utf8");
154− const parsedResult = Result.try(() => JSON.parse(raw));
155− const parsed = parsedResult.isOk()
156− ? parseManagedRuntimeConfig(parsedResult.value)
157− : null;
153+ let parsedValue: unknown;
154+ try {
155+ parsedValue = JSON.parse(raw);
156+ } catch {
157+ parsedValue = null;
158+ }
159+ const parsed = parseManagedRuntimeConfig(parsedValue);
158160
159161 if (!parsed) {
160162 const config = defaultConfig();
executor/packages/core/src/mcp/server-approval.ts+14−12
@@ -9,7 +9,6 @@ import type { · 8 unmodified lines
99 McpExecutorService,
1010 } from "./server-contracts";
1111 import { getTaskTerminalState } from "./server-utils";
12−import { asRecord } from "../utils";
1312
1413 const elicitationResponseSchema = z.object({
1514 action: z.enum(["accept", "decline", "cancel"]),
@@ -19,6 +18,11 @@ const elicitationResponseSchema = z.object({ · 3 unmodified lines
1918 }).optional(),
2019 });
2120
21+const subscriptionEventPayloadSchema = z.object({
22+ status: z.string().optional(),
23+ pendingApprovalCount: z.coerce.number().optional(),
24+});
25+
2226 function formatApprovalInput(input: unknown, maxLength = 2000): string {
2327 let serialized: string;
2428 try {
@@ -88,7 +92,7 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 63 unmodified lines
8892 }
8993
9094 const action = parsedResponse.data.action;
91− const content = asRecord(parsedResponse.data.content);
95+ const content = parsedResponse.data.content;
9296
9397 if (action !== "accept") {
9498 return {
@@ -99,12 +103,10 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 4 unmodified lines
99103 };
100104 }
101105
102− const selectedDecision = content.decision;
106+ const selectedDecision = content?.decision;
103107 const decision = selectedDecision === "approved" ? "approved" : "denied";
104− const selectedReason = content.reason;
105− const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0
106− ? selectedReason
107− : undefined;
108+ const selectedReason = content?.reason?.trim();
109+ const reason = selectedReason && selectedReason.length > 0 ? selectedReason : undefined;
108110
109111 return { decision, reason };
110112 };
@@ -209,13 +211,13 @@ export function waitForTerminalTask( · 98 unmodified lines
209211 };
210212
211213 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {
212− const payload = asRecord(event.payload);
213− const type = typeof payload.status === "string" ? payload.status : undefined;
214− const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"
215− ? payload.pendingApprovalCount
214+ const parsedPayload = subscriptionEventPayloadSchema.safeParse(event.payload);
215+ const type = parsedPayload.success ? parsedPayload.data.status : undefined;
216+ const pendingApprovalCount = parsedPayload.success
217+ ? (parsedPayload.data.pendingApprovalCount ?? 0)
216218 : 0;
217219
218− if (typeof type === "string" && getTaskTerminalState(type)) {
220+ if (type && getTaskTerminalState(type)) {
219221 void done();
220222 return;
221223 }
executor/packages/core/src/openapi-auth.ts+7−3
@@ -1,6 +1,10 @@
11 import type { OpenApiAuth } from "./tool/source-types";
22 import { z } from "zod";
3−import { asRecord } from "./utils";
3+import { toPlainObject } from "./utils";
4+
5+function toRecordOrEmpty(value: unknown): Record<string, unknown> {
6+ return toPlainObject(value) ?? {};
7+}
48
59 const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
610
@@ -12,8 +16,8 @@ const securitySchemeSchema = z.object({ · 5 unmodified lines
1216 });
1317
1418 export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined {
15− const components = asRecord(spec.components);
16− const securitySchemes = asRecord(components.securitySchemes);
19+ const components = toRecordOrEmpty(spec.components);
20+ const securitySchemes = toRecordOrEmpty(components.securitySchemes);
1721 if (Object.keys(securitySchemes).length === 0) {
1822 return undefined;
1923 }
executor/packages/core/src/openapi-compaction.ts+18−14
@@ -11,7 +11,11 @@ import { · 10 unmodified lines
1111 responseTypeHintFromSchema,
1212 type OpenApiParameterHint,
1313 } from "./openapi/schema-hints";
14−import { asRecord } from "./utils";
14+import { toPlainObject } from "./utils";
15+
16+function toRecordOrEmpty(value: unknown): Record<string, unknown> {
17+ return toPlainObject(value) ?? {};
18+}
1519
1620 export interface CompactOpenApiPathsOptions {
1721 includeSchemas?: boolean;
@@ -28,13 +32,13 @@ export function compactOpenApiPaths( · 10 unmodified lines
2832 componentRequestBodies?: Record<string, unknown>,
2933 options: CompactOpenApiPathsOptions = {},
3034 ): Record<string, unknown> {
31− const paths = asRecord(pathsValue);
35+ const paths = toRecordOrEmpty(pathsValue);
3236 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
3337 const compactPaths: Record<string, unknown> = {};
34− const compParams = componentParameters ? asRecord(componentParameters) : {};
35− const compSchemas = componentSchemas ? asRecord(componentSchemas) : {};
36− const compResponses = componentResponses ? asRecord(componentResponses) : {};
37− const compRequestBodies = componentRequestBodies ? asRecord(componentRequestBodies) : {};
38+ const compParams = componentParameters ? toRecordOrEmpty(componentParameters) : {};
39+ const compSchemas = componentSchemas ? toRecordOrEmpty(componentSchemas) : {};
40+ const compResponses = componentResponses ? toRecordOrEmpty(componentResponses) : {};
41+ const compRequestBodies = componentRequestBodies ? toRecordOrEmpty(componentRequestBodies) : {};
3842 const includeSchemas = options.includeSchemas ?? true;
3943 const includeTypeHints = options.includeTypeHints ?? true;
4044 const includeParameterSchemas = options.includeParameterSchemas ?? true;
@@ -45,7 +49,7 @@ export function compactOpenApiPaths( · 4 unmodified lines
4549 const prefix = "#/components/parameters/";
4650 if (ref.startsWith(prefix)) {
4751 const key = ref.slice(prefix.length);
48− const resolved = asRecord(compParams[key]);
52+ const resolved = toRecordOrEmpty(compParams[key]);
4953 if (Object.keys(resolved).length > 0) return resolved;
5054 }
5155 }
@@ -55,7 +59,7 @@ export function compactOpenApiPaths( · 3 unmodified lines
5559 const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => {
5660 if (!Array.isArray(entries)) return [];
5761 return entries
58− .map((entry) => resolveParam(asRecord(entry)))
62+ .map((entry) => resolveParam(toRecordOrEmpty(entry)))
5963 .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string")
6064 .map((entry) => ({
6165 name: String(entry.name),
@@ -66,7 +70,7 @@ export function compactOpenApiPaths( · 4 unmodified lines
6670 };
6771
6872 for (const [pathTemplate, pathValue] of Object.entries(paths)) {
69− const pathObject = asRecord(pathValue);
73+ const pathObject = toRecordOrEmpty(pathValue);
7074 const compactPathObject: Record<string, unknown> = {};
7175 const sharedParameters = normalizeParameters(pathObject.parameters);
7276 if (sharedParameters.length > 0) {
@@ -74,7 +78,7 @@ export function compactOpenApiPaths( · 1 unmodified line
7478 }
7579
7680 for (const method of methods) {
77− const operation = asRecord(pathObject[method]);
81+ const operation = toRecordOrEmpty(pathObject[method]);
7882 if (Object.keys(operation).length === 0) continue;
7983
8084 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
@@ -106,16 +110,16 @@ export function compactOpenApiPaths( · 25 unmodified lines
106110 // Always attempt to compute minimal input/output schemas. This keeps the
107111 // prepared spec compact while enabling schema-first tool signatures.
108112 if (includeSchemas || hasGeneratedTypes) {
109− const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies);
110− const requestBodyContent = asRecord(requestBody.content);
113+ const requestBody = resolveRequestBodyRef(toRecordOrEmpty(operation.requestBody), compRequestBodies);
114+ const requestBodyContent = toRecordOrEmpty(requestBody.content);
111115 const rawRequestBodySchema = getPreferredContentSchema(requestBodyContent);
112116 requestBodySchema = resolveSchemaRef(rawRequestBodySchema, compSchemas);
113117
114− const responses = asRecord(operation.responses);
118+ const responses = toRecordOrEmpty(operation.responses);
115119 for (const [status, responseValue] of Object.entries(responses)) {
116120 if (!status.startsWith("2")) continue;
117121 responseStatus = status;
118− const resolvedResponse = resolveResponseRef(asRecord(responseValue), compResponses);
122+ const resolvedResponse = resolveResponseRef(toRecordOrEmpty(responseValue), compResponses);
119123 responseSchema = resolveSchemaRef(
120124 getPreferredResponseSchema(resolvedResponse),
121125 compSchemas,
executor/packages/core/src/openapi-prepare.ts+46−35
@@ -6,10 +6,15 @@ import { inferOpenApiAuth } from "./openapi-auth"; · 5 unmodified lines
66 import { compactOpenApiPaths } from "./openapi-compaction";
77 import { extractOperationIdsFromDts } from "./openapi/schema-hints";
88 import type { PreparedOpenApiSpec } from "./tool/source-types";
9−import { asRecord } from "./utils";
9+import { toPlainObject } from "./utils";
1010
1111 const unknownRecordSchema = z.record(z.unknown());
1212
13+const openApiDocumentSchema = z.object({
14+ openapi: z.string().optional(),
15+ swagger: z.string().optional(),
16+}).catchall(z.unknown());
17+
1318 interface SwaggerParserAdapter {
1419 bundle(spec: unknown): Promise<unknown>;
1520 parse(spec: unknown): Promise<unknown>;
@@ -41,16 +46,13 @@ function toRecordResult(value: unknown, label: string): Result<Record<string, un · 25 unmodified lines
4146 return Result.ok(parsed.data);
4247 }
4348
44−function describeResultError(error: unknown): string {
45− const cause = asRecord(error).cause;
46− if (cause instanceof Error) {
47− return cause.message;
48− }
49− if (typeof cause === "string" && cause.trim().length > 0) {
50− return cause;
49+function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof openapiTS>[0] {
50+ const parsed = openApiDocumentSchema.safeParse(spec);
51+ if (!parsed.success) {
52+ throw new Error(`OpenAPI document is invalid: ${parsed.error.message}`);
5153 }
5254
53− return error instanceof Error ? error.message : String(error);
55+ return parsed.data as Parameters<typeof openapiTS>[0] & Record<string, unknown>;
5456 }
5557
5658 function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {
@@ -62,7 +64,9 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 5 unmodified lines
6264 let target: unknown = spec;
6365 for (const segment of segments) {
6466 if (target && typeof target === "object") {
65− target = asRecord(target)[segment];
67+ const targetRecord = toPlainObject(target);
68+ if (!targetRecord) return false;
69+ target = targetRecord[segment];
6670 } else {
6771 return false;
6872 }
@@ -73,11 +77,12 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 4 unmodified lines
7377 function hasBrokenDiscriminators(obj: unknown): boolean {
7478 if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators);
7579 if (obj && typeof obj === "object") {
76− const record = asRecord(obj);
80+ const record = toPlainObject(obj);
81+ if (!record) return false;
7782 if (record.discriminator && typeof record.discriminator === "object") {
78− const disc = asRecord(record.discriminator);
83+ const disc = toPlainObject(record.discriminator) ?? {};
7984 if (disc.mapping && typeof disc.mapping === "object") {
80− const mapping = asRecord(disc.mapping);
85+ const mapping = toPlainObject(disc.mapping) ?? {};
8186 if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) {
8287 return true;
8388 }
@@ -93,13 +98,14 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 9 unmodified lines
9398 function walk(obj: unknown): unknown {
9499 if (Array.isArray(obj)) return obj.map(walk);
95100 if (obj && typeof obj === "object") {
96− const record = asRecord(obj);
101+ const record = toPlainObject(obj);
102+ if (!record) return obj;
97103 const clone: Record<string, unknown> = {};
98104 for (const [key, value] of Object.entries(record)) {
99105 if (key === "discriminator" && typeof value === "object" && value !== null) {
100− const disc = asRecord(value);
106+ const disc = toPlainObject(value) ?? {};
101107 if (disc.mapping && typeof disc.mapping === "object") {
102− const mapping = asRecord(disc.mapping);
108+ const mapping = toPlainObject(disc.mapping) ?? {};
103109 const hasBroken = Object.values(mapping).some(
104110 (ref) => typeof ref === "string" && !refExists(ref),
105111 );
@@ -127,7 +133,7 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 21 unmodified lines
127133
128134 async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string | null> {
129135 try {
130− const ast = await openapiTS(spec as never, { silent: true });
136+ const ast = await openapiTS(toOpenApiTsInput(spec), { silent: true });
131137 return astToString(ast);
132138 } catch (error) {
133139 const msg = error instanceof Error ? error.message : String(error);
@@ -135,7 +141,7 @@ async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string · 1 unmodified line
135141 if (patched) {
136142 console.warn(`[executor] openapi-typescript failed, retrying with patched spec: ${msg}`);
137143 try {
138− const ast = await openapiTS(patched as never, { silent: true });
144+ const ast = await openapiTS(toOpenApiTsInput(patched), { silent: true });
139145 return astToString(ast);
140146 } catch (retryError) {
141147 const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
@@ -168,13 +174,15 @@ export async function prepareOpenApiSpec( · 26 unmodified lines
168174
169175 let parsed: Record<string, unknown>;
170176 if (typeof spec === "string") {
171− const parsedResult = await Result.tryPromise(() => parser.parse(spec));
172− if (parsedResult.isErr()) {
173− const msg = describeResultError(parsedResult.error);
177+ let parsedValue: unknown;
178+ try {
179+ parsedValue = await parser.parse(spec);
180+ } catch (error) {
181+ const msg = error instanceof Error ? error.message : String(error);
174182 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
175183 }
176184
177− const parsedRecord = toRecordResult(parsedResult.value, `Parsed OpenAPI source '${sourceName}'`);
185+ const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`);
178186 if (parsedRecord.isErr()) {
179187 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`);
180188 }
@@ -184,18 +192,23 @@ export async function prepareOpenApiSpec( · 3 unmodified lines
184192 parsed = spec;
185193 }
186194
187− let bundled: Record<string, unknown>;
195+ let bundled: Record<string, unknown> = parsed;
188196 const dtsPromise = shouldGenerateDts
189197 ? generateOpenApiDts(parsed)
190198 : Promise.resolve<string | null>(null);
191199 if (shouldBundle) {
192− const bundleResult = await Result.tryPromise(() => parser.bundle(parsed));
193− if (bundleResult.isErr()) {
194− const bundleMessage = describeResultError(bundleResult.error);
200+ let bundledValue: unknown;
201+ try {
202+ bundledValue = await parser.bundle(parsed);
203+ } catch (error) {
204+ const bundleMessage = error instanceof Error ? error.message : String(error);
195205 warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`);
196206 bundled = parsed;
197− } else {
198− const bundledRecord = toRecordResult(bundleResult.value, `Bundled OpenAPI source '${sourceName}'`);
207+ bundledValue = undefined;
208+ }
209+
210+ if (bundledValue !== undefined) {
211+ const bundledRecord = toRecordResult(bundledValue, `Bundled OpenAPI source '${sourceName}'`);
199212 if (bundledRecord.isErr()) {
200213 warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`);
201214 bundled = parsed;
@@ -203,8 +216,6 @@ export async function prepareOpenApiSpec( · 1 unmodified line
203216 bundled = bundledRecord.value;
204217 }
205218 }
206− } else {
207− bundled = parsed;
208219 }
209220 const dts = await dtsPromise;
210221
@@ -215,17 +226,17 @@ export async function prepareOpenApiSpec( · 4 unmodified lines
215226 return {
216227 servers: servers
217228 .map((server) => {
218− const value = asRecord(server).url;
229+ const value = toPlainObject(server)?.url;
219230 return typeof value === "string" ? value : "";
220231 })
221232 .filter((url) => url.length > 0),
222233 paths: compactOpenApiPaths(
223234 bundled.paths,
224235 operationTypeIds,
225− asRecord(asRecord(bundled.components).parameters),
226− asRecord(asRecord(bundled.components).schemas),
227− asRecord(asRecord(bundled.components).responses),
228− asRecord(asRecord(bundled.components).requestBodies),
236+ toPlainObject(toPlainObject(bundled.components)?.parameters) ?? {},
237+ toPlainObject(toPlainObject(bundled.components)?.schemas) ?? {},
238+ toPlainObject(toPlainObject(bundled.components)?.responses) ?? {},
239+ toPlainObject(toPlainObject(bundled.components)?.requestBodies) ?? {},
229240 {
230241 includeSchemas: profile === "full",
231242 includeTypeHints: true,
executor/packages/core/src/openapi/schema-hints.ts+41−37
@@ -1,4 +1,8 @@
1−import { asRecord } from "../utils";
1+import { toPlainObject } from "../utils";
2+
3+function toRecordOrEmpty(value: unknown): Record<string, unknown> {
4+ return toPlainObject(value) ?? {};
5+}
26
37 type JsonSchema = Record<string, unknown>;
48 const COMPONENT_REF_INLINE_DEPTH = 2;
@@ -6,7 +10,7 @@ const COMPONENT_REF_INLINE_DEPTH = 2; · 1 unmodified line
610 function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {
711 const shape = schema;
812 const type = typeof shape.type === "string" ? shape.type : undefined;
9− const props = asRecord(shape.properties);
13+ const props = toRecordOrEmpty(shape.properties);
1014 const propCount = Object.keys(props).length;
1115 if (type !== "object" && propCount === 0) return false;
1216 if (propCount === 0) return false;
@@ -38,18 +42,18 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 25 unmodified lines
3842 const preferredKeys = ["application/json", "*/*"];
3943
4044 for (const key of preferredKeys) {
41− const schema = asRecord(asRecord(content[key]).schema);
45+ const schema = toRecordOrEmpty(toRecordOrEmpty(content[key]).schema);
4246 if (Object.keys(schema).length > 0) return schema;
4347 }
4448
4549 for (const [key, value] of Object.entries(content)) {
4650 if (!key.includes("json")) continue;
47− const schema = asRecord(asRecord(value).schema);
51+ const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema);
4852 if (Object.keys(schema).length > 0) return schema;
4953 }
5054
5155 for (const value of Object.values(content)) {
52− const schema = asRecord(asRecord(value).schema);
56+ const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema);
5357 if (Object.keys(schema).length > 0) return schema;
5458 }
5559
@@ -57,12 +61,12 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 1 unmodified line
5761 }
5862
5963 export function getPreferredResponseSchema(responseValue: Record<string, unknown>): Record<string, unknown> {
60− const contentSchema = getPreferredContentSchema(asRecord(responseValue.content));
64+ const contentSchema = getPreferredContentSchema(toRecordOrEmpty(responseValue.content));
6165 if (Object.keys(contentSchema).length > 0) {
6266 return contentSchema;
6367 }
6468
65− const schema = asRecord(responseValue.schema);
69+ const schema = toRecordOrEmpty(responseValue.schema);
6670 if (Object.keys(schema).length > 0) {
6771 return schema;
6872 }
@@ -81,7 +85,7 @@ export function resolveSchemaRef( · 12 unmodified lines
8185 }
8286
8387 const key = ref.slice(prefix.length);
84− const resolved = asRecord(componentSchemas[key]);
88+ const resolved = toRecordOrEmpty(componentSchemas[key]);
8589 if (Object.keys(resolved).length === 0) {
8690 return schema;
8791 }
@@ -99,7 +103,7 @@ export function resolveRequestBodyRef( · 11 unmodified lines
99103 }
100104
101105 const key = ref.slice(prefix.length);
102− const resolved = asRecord(componentRequestBodies[key]);
106+ const resolved = toRecordOrEmpty(componentRequestBodies[key]);
103107 if (Object.keys(resolved).length === 0) {
104108 return requestBody;
105109 }
@@ -117,7 +121,7 @@ export function resolveResponseRef( · 11 unmodified lines
117121 }
118122
119123 const key = ref.slice(prefix.length);
120− const resolved = asRecord(componentResponses[key]);
124+ const resolved = toRecordOrEmpty(componentResponses[key]);
121125 if (Object.keys(resolved).length === 0) {
122126 return response;
123127 }
@@ -125,7 +129,7 @@ export function resolveResponseRef( · 1 unmodified line
125129 }
126130
127131 export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> {
128− const schema = asRecord(entry.schema);
132+ const schema = toRecordOrEmpty(entry.schema);
129133 if (Object.keys(schema).length > 0) {
130134 return schema;
131135 }
@@ -139,7 +143,7 @@ export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record · 7 unmodified lines
139143 if (Array.isArray(entry.enum) && entry.enum.length > 0) {
140144 fallback.enum = entry.enum;
141145 }
142− const items = asRecord(entry.items);
146+ const items = toRecordOrEmpty(entry.items);
143147 if (Object.keys(items).length > 0) {
144148 fallback.items = items;
145149 }
@@ -239,13 +243,13 @@ function joinUnion(parts: string[]): string { · 93 unmodified lines
239243 function isObjectSchema(schema: JsonSchema): boolean {
240244 const type = typeof schema.type === "string" ? schema.type : undefined;
241245 if (type === "object") return true;
242− return Object.keys(asRecord(schema.properties)).length > 0;
246+ return Object.keys(toRecordOrEmpty(schema.properties)).length > 0;
243247 }
244248
245249 function isEmptyObjectSchema(schema: JsonSchema): boolean {
246250 const type = typeof schema.type === "string" ? schema.type : undefined;
247251 if (type !== "object") return false;
248− return Object.keys(asRecord(schema.properties)).length === 0;
252+ return Object.keys(toRecordOrEmpty(schema.properties)).length === 0;
249253 }
250254
251255 function isPlainScalarSchema(schema: JsonSchema): boolean {
@@ -279,7 +283,7 @@ function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] { · 27 unmodified lines
279283 // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]`
280284 // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`.
281285 // These broad scalar/empty-object variants add noise without helping the UI/LLM.
282− const hasNonEmptyObject = variants.some((v) => isObjectSchema(v) && Object.keys(asRecord(v.properties)).length > 0);
286+ const hasNonEmptyObject = variants.some((v) => isObjectSchema(v) && Object.keys(toRecordOrEmpty(v.properties)).length > 0);
283287
284288 let filtered = variants;
285289 if (hasNonEmptyObject) {
@@ -324,7 +328,7 @@ function repairMissingRequiredProperties( · 38 unmodified lines
324328 const requiredLists = variants.map((v) => new Set(
325329 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),
326330 ));
327− const propsLists = variants.map((v) => asRecord(v.properties));
331+ const propsLists = variants.map((v) => toRecordOrEmpty(v.properties));
328332
329333 const keysToConsider = new Set<string>();
330334 for (const req of requiredLists) {
@@ -342,7 +346,7 @@ function repairMissingRequiredProperties( · 11 unmodified lines
342346 if (!schema || typeof schema !== "object") continue;
343347 const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs);
344348 if (!hint || hint === "unknown") continue;
345− candidates.push({ hint, schema: asRecord(schema) });
349+ candidates.push({ hint, schema: toRecordOrEmpty(schema) });
346350 }
347351
348352 if (candidates.length === 0) continue;
@@ -416,8 +420,8 @@ function mergeDiscriminatedObjectUnionVariants( · 67 unmodified lines
416420
417421 for (let i = 0; i < variants.length; i++) {
418422 const variant = variants[i]!;
419− const props = asRecord(variant.properties);
420− const discSchema = asRecord(props[discriminantKey]);
423+ const props = toRecordOrEmpty(variant.properties);
424+ const discSchema = toRecordOrEmpty(props[discriminantKey]);
421425 const disc = extractStringLiteralFromSchema(discSchema);
422426 if (!disc) {
423427 return variants;
@@ -479,8 +483,8 @@ function mergeDiscriminatedObjectUnionVariants( · 55 unmodified lines
479483 for (const member of group) used.add(member.idx);
480484
481485 const base = group[0]!.schema;
482− const baseProps = asRecord(base.properties);
483− const discSchema = asRecord(baseProps[discriminantKey]);
486+ const baseProps = toRecordOrEmpty(base.properties);
487+ const discSchema = toRecordOrEmpty(baseProps[discriminantKey]);
484488 const discType = typeof discSchema.type === "string" ? discSchema.type : "string";
485489
486490 const mergedDiscriminant: JsonSchema = {
@@ -521,8 +525,8 @@ function tryCollapseSimpleObjectUnion( · 34 unmodified lines
521525 const right = variants[1]!;
522526 if (!isObjectSchema(left) || !isObjectSchema(right)) return null;
523527
524− const leftProps = asRecord(left.properties);
525− const rightProps = asRecord(right.properties);
528+ const leftProps = toRecordOrEmpty(left.properties);
529+ const rightProps = toRecordOrEmpty(right.properties);
526530 const leftKeys = Object.keys(leftProps);
527531 const rightKeys = Object.keys(rightProps);
528532 if (leftKeys.length === 0 || rightKeys.length === 0) return null;
@@ -535,8 +539,8 @@ function tryCollapseSimpleObjectUnion( · 6 unmodified lines
535539
536540 const sup = leftSubRight ? right : left;
537541 const sub = leftSubRight ? left : right;
538− const supProps = asRecord(sup.properties);
539− const subProps = asRecord(sub.properties);
542+ const supProps = toRecordOrEmpty(sup.properties);
543+ const subProps = toRecordOrEmpty(sub.properties);
540544 const supKeys = Object.keys(supProps);
541545 const subKeys = Object.keys(subProps);
542546 const extraKeys = supKeys.filter((k) => !subKeys.includes(k));
@@ -591,13 +595,13 @@ function tryFactorCommonObjectFields( · 48 unmodified lines
591595 if (variants.length < 2) return null;
592596 if (!variants.every(isObjectSchema)) return null;
593597
594− const propsList = variants.map((v) => asRecord(v.properties));
598+ const propsList = variants.map((v) => toRecordOrEmpty(v.properties));
595599 const commonKeys = intersectKeys(propsList);
596600 if (commonKeys.length === 0) return null;
597601
598602 const commonEntries: Array<{ key: string; hint: string; required: boolean }> = [];
599603 for (const key of commonKeys) {
600− const hints = variants.map((v) => jsonSchemaTypeHintFallback(asRecord(v.properties)[key], depth + 1, componentSchemas, seenRefs));
604+ const hints = variants.map((v) => jsonSchemaTypeHintFallback(toRecordOrEmpty(v.properties)[key], depth + 1, componentSchemas, seenRefs));
601605 const firstHint = hints[0];
602606 if (!firstHint || hints.some((h) => h !== firstHint)) continue;
603607
@@ -613,7 +617,7 @@ function tryFactorCommonObjectFields( · 9 unmodified lines
613617 const commonProps = new Set(commonEntries.map((e) => e.key));
614618 const residualSchemas: JsonSchema[] = [];
615619 for (const variant of variants) {
616− const props = asRecord(variant.properties);
620+ const props = toRecordOrEmpty(variant.properties);
617621 const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key)));
618622 const req = Array.isArray(variant.required) ? variant.required : [];
619623 const residualRequired = req.filter((key) => typeof key === "string" && !commonProps.has(key));
@@ -625,7 +629,7 @@ function tryFactorCommonObjectFields( · 5 unmodified lines
625629 }
626630
627631 // If factoring doesn't reduce anything, bail.
628− const reduces = residualSchemas.some((s) => Object.keys(asRecord(s.properties)).length > 0);
632+ const reduces = residualSchemas.some((s) => Object.keys(toRecordOrEmpty(s.properties)).length > 0);
629633 if (!reduces) return null;
630634
631635 const commonInner = commonEntries
@@ -662,7 +666,7 @@ function tryFactorPartialCommonObjectFields( · 30 unmodified lines
662666 if (variants.length < 3) return null;
663667 if (!variants.every(isObjectSchema)) return null;
664668
665− const propsList = variants.map((v) => asRecord(v.properties));
669+ const propsList = variants.map((v) => toRecordOrEmpty(v.properties));
666670 const requiredList = variants.map((v) => new Set(
667671 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),
668672 ));
@@ -753,7 +757,7 @@ function tryFactorPartialCommonObjectFields( · 84 unmodified lines
753757
754758 // If factoring doesn't actually remove anything useful, bail.
755759 const anyResidualHasProps = residualSchemas.some(
756− (s) => Object.keys(asRecord(s.properties)).length > 0,
760+ (s) => Object.keys(toRecordOrEmpty(s.properties)).length > 0,
757761 );
758762 if (!anyResidualHasProps) return null;
759763
@@ -788,13 +792,13 @@ export function jsonSchemaTypeHintFallback( · 28 unmodified lines
788792 if (!schema || typeof schema !== "object") return "unknown";
789793 if (depth > 12) return "unknown";
790794
791− const shape = asRecord(schema);
795+ const shape = toRecordOrEmpty(schema);
792796 if (typeof shape.$ref === "string") {
793797 const ref = shape.$ref;
794798 const prefix = "#/components/schemas/";
795799 if (ref.startsWith(prefix)) {
796800 const key = ref.slice(prefix.length);
797− const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};
801+ const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {};
798802 const canInline = Object.keys(resolved).length > 0
799803 && !seenRefs.has(ref)
800804 && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved));
@@ -934,7 +938,7 @@ export function jsonSchemaTypeHintFallback( · 133 unmodified lines
934938 return `${maybeParenthesizeArrayElement(itemType)}[]`;
935939 }
936940
937− const props = asRecord(shape.properties);
941+ const props = toRecordOrEmpty(shape.properties);
938942 const additionalProperties = shape.additionalProperties;
939943 const requiredRaw = Array.isArray(shape.required) ? shape.required : [];
940944 const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string"));
@@ -973,12 +977,12 @@ function collectTopLevelSchemaKeys( · 32 unmodified lines
973977 ): string[] {
974978 if (!schema || typeof schema !== "object") return [];
975979
976− const record = asRecord(schema);
980+ const record = toRecordOrEmpty(schema);
977981 const ref = typeof record.$ref === "string" ? record.$ref : "";
978982 if (ref.startsWith("#/components/schemas/")) {
979983 if (seenRefs.has(ref)) return [];
980984 const key = ref.slice("#/components/schemas/".length);
981− const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};
985+ const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {};
982986 if (Object.keys(resolved).length === 0) return [];
983987 const nextSeen = new Set(seenRefs);
984988 nextSeen.add(ref);
@@ -988,7 +992,7 @@ function collectTopLevelSchemaKeys( · 3 unmodified lines
988992 const keys: string[] = [];
989993 const seen = new Set<string>();
990994
991− for (const key of Object.keys(asRecord(record.properties))) {
995+ for (const key of Object.keys(toRecordOrEmpty(record.properties))) {
992996 pushUnique(keys, seen, key);
993997 }
994998
executor/packages/core/src/openapi/tool-builder.ts+38−23
@@ -1,3 +1,4 @@
1+import { z } from "zod";
12 import {
23 buildOpenApiArgPreviewKeys,
34 buildOpenApiInputSchema,
@@ -6,7 +7,6 @@ import { · 2 unmodified lines
67 jsonSchemaTypeHintFallback,
78 responseTypeHintFromSchema,
89 } from "./schema-hints";
9−import { z } from "zod";
1010 import { buildOpenApiToolPath } from "./tool-path";
1111 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
1212 import { executeOpenApiRequest } from "../tool/source-execution";
@@ -14,7 +14,7 @@ import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/sourc · 1 unmodified line
1414 import type { ToolDefinition } from "../types";
1515 import type { ToolTypedRef } from "../types";
1616 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
17−import { asRecord } from "../utils";
17+import { toPlainObject } from "../utils";
1818 import type { SerializedTool } from "../tool/source-serialization";
1919
2020 type OpenApiOperationParameter = {
@@ -24,11 +24,29 @@ type OpenApiOperationParameter = { · 3 unmodified lines
2424 schema: Record<string, unknown>;
2525 };
2626
27−const recordArraySchema = z.array(z.record(z.unknown()));
27+const stringArraySchema = z.array(z.string());
28+const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0);
2829
29−function asRecordArray(value: unknown): Record<string, unknown>[] {
30− const parsed = recordArraySchema.safeParse(value);
31− return parsed.success ? parsed.data : [];
30+function toRecordOrEmpty(value: unknown): Record<string, unknown> {
31+ return toPlainObject(value) ?? {};
32+}
33+
34+function parseNonEmptyTrimmedString(value: unknown): string | undefined {
35+ const parsed = nonEmptyTrimmedStringSchema.safeParse(value);
36+ return parsed.success ? parsed.data : undefined;
37+}
38+
39+function toRecordArray(value: unknown): Array<Record<string, unknown>> {
40+ if (!Array.isArray(value)) return [];
41+
42+ const entries: Array<Record<string, unknown>> = [];
43+ for (const item of value) {
44+ const record = toPlainObject(item);
45+ if (record) {
46+ entries.push(record);
47+ }
48+ }
49+ return entries;
3250 }
3351
3452 function buildOpenApiOperationParameters(
@@ -37,12 +55,12 @@ function buildOpenApiOperationParameters( · 2 unmodified lines
3755 ): OpenApiOperationParameter[] {
3856 return [
3957 ...sharedParameters,
40− ...asRecordArray(operation.parameters),
58+ ...toRecordArray(operation.parameters),
4159 ].map((entry) => ({
4260 name: String(entry.name ?? ""),
4361 in: String(entry.in ?? "query"),
4462 required: Boolean(entry.required),
45− schema: asRecord(entry.schema),
63+ schema: toRecordOrEmpty(entry.schema),
4664 }));
4765 }
4866
@@ -60,7 +78,7 @@ export function buildOpenApiToolsFromPrepared( · 11 unmodified lines
6078 const sourceLabel = `openapi:${config.name}`;
6179 const credentialSourceKey = getCredentialSourceKey(config);
6280 const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth);
63− const paths = asRecord(prepared.paths);
81+ const paths = toRecordOrEmpty(prepared.paths);
6482 const tools: ToolDefinition[] = [];
6583
6684 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
@@ -68,11 +86,11 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line
6886 const usedToolPaths = new Set<string>();
6987
7088 for (const [pathTemplate, pathValue] of Object.entries(paths)) {
71− const pathObject = asRecord(pathValue);
72− const sharedParameters = asRecordArray(pathObject.parameters);
89+ const pathObject = toRecordOrEmpty(pathValue);
90+ const sharedParameters = toRecordArray(pathObject.parameters);
7391
7492 for (const method of methods) {
75− const operation = asRecord(pathObject[method]);
93+ const operation = toRecordOrEmpty(pathObject[method]);
7694 if (Object.keys(operation).length === 0) continue;
7795
7896 const tags = Array.isArray(operation.tags) ? operation.tags : [];
@@ -80,22 +98,19 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line
8098 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
8199 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);
82100
83− const inputSchema = asRecord(operation._inputSchema);
84− const outputSchema = asRecord(operation._outputSchema);
85− const inputHint = typeof operation._argsTypeHint === "string" && operation._argsTypeHint.trim().length > 0
86− ? operation._argsTypeHint.trim()
87− : undefined;
88− const outputHint = typeof operation._returnsTypeHint === "string" && operation._returnsTypeHint.trim().length > 0
89− ? operation._returnsTypeHint.trim()
90− : undefined;
101+ const inputSchema = toRecordOrEmpty(operation._inputSchema);
102+ const outputSchema = toRecordOrEmpty(operation._outputSchema);
103+ const inputHint = parseNonEmptyTrimmedString(operation._argsTypeHint);
104+ const outputHint = parseNonEmptyTrimmedString(operation._returnsTypeHint);
91105 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
92− const previewInputKeys = Array.isArray(operation._previewInputKeys)
93− ? operation._previewInputKeys.filter((value): value is string => typeof value === "string")
106+ const parsedPreviewInputKeys = stringArraySchema.safeParse(operation._previewInputKeys);
107+ const previewInputKeys = parsedPreviewInputKeys.success
108+ ? parsedPreviewInputKeys.data
94109 : buildPreviewKeys(inputSchema);
95110
96111 const typedRef: ToolTypedRef = {
97112 kind: "openapi_operation",
98− sourceKey: sourceLabel,
113+ sourceKey: config.sourceKey ?? sourceLabel,
99114 operationId: operationIdRaw,
100115 };
101116
executor/packages/core/src/postman-runtime.ts+9−3
@@ -1,6 +1,5 @@
11 import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils";
22 import { z } from "zod";
3−import { asRecord } from "./utils";
43 import type { PostmanRequestBody } from "./postman/collection-utils";
54
65 export interface PostmanSerializedRunSpec {
@@ -21,6 +20,13 @@ const postmanRuntimePayloadSchema = z.object({ · 14 unmodified lines
2120 body: z.unknown().optional(),
2221 }).passthrough();
2322
23+const recordSchema = z.record(z.unknown());
24+
25+function coerceRecord(value: unknown): Record<string, unknown> {
26+ const parsed = recordSchema.safeParse(value);
27+ return parsed.success ? parsed.data : {};
28+}
29+
2430 export async function executePostmanRequest(
2531 runSpec: PostmanSerializedRunSpec,
2632 payload: Record<string, unknown>,
@@ -55,7 +61,7 @@ export async function executePostmanRequest( · 28 unmodified lines
5561 }
5662 }
5763
58− const queryOverrides = asRecord(normalizedPayload.query);
64+ const queryOverrides = coerceRecord(normalizedPayload.query);
5965 for (const [key, value] of Object.entries(queryOverrides)) {
6066 if (!key || value === undefined || value === null) continue;
6167 url.searchParams.set(key, stringifyTemplateValue(value));
@@ -69,7 +75,7 @@ export async function executePostmanRequest( · 7 unmodified lines
6975 Object.assign(headers, runSpec.authHeaders);
7076 Object.assign(headers, credentialHeaders ?? {});
7177
72− const headerOverrides = asRecord(normalizedPayload.headers);
78+ const headerOverrides = coerceRecord(normalizedPayload.headers);
7379 for (const [name, value] of Object.entries(headerOverrides)) {
7480 if (!name || value === undefined || value === null) continue;
7581 headers[name] = stringifyTemplateValue(value);
executor/packages/core/src/postman-utils.ts+8−2
@@ -1,6 +1,12 @@
1−import { asRecord } from "./utils";
1+import { z } from "zod";
22
33 const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g;
4+const recordSchema = z.record(z.unknown());
5+
6+function coerceRecord(value: unknown): Record<string, unknown> {
7+ const parsed = recordSchema.safeParse(value);
8+ return parsed.success ? parsed.data : {};
9+}
410
511 export function stringifyTemplateValue(value: unknown): string {
612 if (value === null || value === undefined) return "";
@@ -29,7 +35,7 @@ export function findUnresolvedPostmanTemplateKeys(value: string): string[] { · 22 unmodified lines
2935
3036 export function asStringRecord(value: unknown): Record<string, string> {
3137 const result: Record<string, string> = {};
32− for (const [key, entry] of Object.entries(asRecord(value))) {
38+ for (const [key, entry] of Object.entries(coerceRecord(value))) {
3339 result[key] = stringifyTemplateValue(entry);
3440 }
3541 return result;
executor/packages/core/src/postman/collection-utils.ts+77−32
@@ -1,11 +1,50 @@
11 import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils";
22 import { stringifyTemplateValue } from "../postman-utils";
3−import { asRecord } from "../utils";
3+import { z } from "zod";
44
55 export type PostmanRequestBody =
66 | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> }
77 | { kind: "raw"; text: string };
88
9+const postmanKeyValueEntrySchema = z.object({
10+ key: z.coerce.string(),
11+ value: z.unknown().optional(),
12+ disabled: z.boolean().optional(),
13+ enabled: z.boolean().optional(),
14+});
15+
16+const postmanBodySchema = z.object({
17+ mode: z.string().optional(),
18+ urlencoded: z.array(postmanKeyValueEntrySchema).optional(),
19+ raw: z.string().optional(),
20+ dataMode: z.string().optional(),
21+ data: z.array(postmanKeyValueEntrySchema).optional(),
22+ rawModeData: z.string().optional(),
23+});
24+
25+function parseKeyValueEntries(value: unknown): Array<z.infer<typeof postmanKeyValueEntrySchema>> {
26+ const parsed = z.array(postmanKeyValueEntrySchema).safeParse(value);
27+ return parsed.success ? parsed.data : [];
28+}
29+
30+function isDisabled(entry: z.infer<typeof postmanKeyValueEntrySchema>): boolean {
31+ return entry.disabled === true || entry.enabled === false;
32+}
33+
34+function toNormalizedEntry(
35+ entry: z.infer<typeof postmanKeyValueEntrySchema>,
36+): { key: string; value: string } | null {
37+ const key = entry.key.trim();
38+ if (!key || isDisabled(entry)) {
39+ return null;
40+ }
41+
42+ return {
43+ key,
44+ value: stringifyTemplateValue(entry.value),
45+ };
46+}
47+
948 export function buildPostmanToolPath(
1049 sourceName: string,
1150 requestName: string,
@@ -48,57 +87,63 @@ export function resolvePostmanFolderPath( · 36 unmodified lines
4887 }
4988
5089 export function extractPostmanVariableMap(value: unknown): Record<string, string> {
51− if (!Array.isArray(value)) return {};
5290 const result: Record<string, string> = {};
53− for (const entry of value) {
54− const record = asRecord(entry);
55− const key = typeof record.key === "string" ? record.key.trim() : "";
56− if (!key) continue;
57− if (record.disabled === true) continue;
58− result[key] = stringifyTemplateValue(record.value);
91+ for (const entry of parseKeyValueEntries(value)) {
92+ const normalized = toNormalizedEntry(entry);
93+ if (!normalized) continue;
94+ result[normalized.key] = normalized.value;
5995 }
6096 return result;
6197 }
6298
6399 export function extractPostmanHeaderMap(value: unknown): Record<string, string> {
64− if (!Array.isArray(value)) return {};
65100 const result: Record<string, string> = {};
66− for (const entry of value) {
67− const record = asRecord(entry);
68− const key = typeof record.key === "string" ? record.key.trim() : "";
69− if (!key || record.disabled === true) continue;
70− result[key] = stringifyTemplateValue(record.value);
101+ for (const entry of parseKeyValueEntries(value)) {
102+ const normalized = toNormalizedEntry(entry);
103+ if (!normalized) continue;
104+ result[normalized.key] = normalized.value;
71105 }
72106 return result;
73107 }
74108
75109 export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> {
76− if (!Array.isArray(value)) return [];
77110 const entries: Array<{ key: string; value: string }> = [];
78− for (const entry of value) {
79− const record = asRecord(entry);
80− const key = typeof record.key === "string" ? record.key.trim() : "";
81− if (!key || record.disabled === true) continue;
82− entries.push({ key, value: stringifyTemplateValue(record.value) });
111+ for (const entry of parseKeyValueEntries(value)) {
112+ const normalized = toNormalizedEntry(entry);
113+ if (!normalized) continue;
114+ entries.push(normalized);
83115 }
84116 return entries;
85117 }
86118
87−export function extractPostmanBody(record: Record<string, unknown>): PostmanRequestBody | undefined {
88− const dataMode = typeof record.dataMode === "string" ? record.dataMode.toLowerCase() : "";
89− if (dataMode === "urlencoded" && Array.isArray(record.data)) {
90− const entries: Array<{ key: string; value: string }> = [];
91− for (const item of record.data) {
92− const entry = asRecord(item);
93− const key = typeof entry.key === "string" ? entry.key.trim() : "";
94− if (!key || entry.disabled === true) continue;
95− entries.push({ key, value: stringifyTemplateValue(entry.value) });
96− }
119+export function extractPostmanBody(value: unknown): PostmanRequestBody | undefined {
120+ const parsedBody = postmanBodySchema.safeParse(value);
121+ if (!parsedBody.success) return undefined;
122+
123+ const body = parsedBody.data;
124+ const mode = (body.mode ?? body.dataMode ?? "").trim().toLowerCase();
125+
126+ if (mode === "urlencoded") {
127+ const rawEntries = body.urlencoded ?? body.data ?? [];
128+ const entries = rawEntries
129+ .map((entry) => toNormalizedEntry(entry))
130+ .filter((entry): entry is { key: string; value: string } => Boolean(entry));
97131 return entries.length > 0 ? { kind: "urlencoded", entries } : undefined;
98132 }
99133
100− if (typeof record.rawModeData === "string" && record.rawModeData.length > 0) {
101− return { kind: "raw", text: record.rawModeData };
134+ if (mode === "raw") {
135+ const rawText = body.raw ?? body.rawModeData ?? "";
136+ if (rawText.trim().length > 0) {
137+ return { kind: "raw", text: rawText };
138+ }
139+ }
140+
141+ if ((body.rawModeData ?? "").trim().length > 0) {
142+ return { kind: "raw", text: body.rawModeData ?? "" };
143+ }
144+
145+ if ((body.raw ?? "").trim().length > 0) {
146+ return { kind: "raw", text: body.raw ?? "" };
102147 }
103148
104149 return undefined;
executor/packages/core/src/runtimes/runtime-core.ts+10−4
@@ -39,16 +39,22 @@ function sanitizeExecutionResult(value: unknown): unknown { · 38 unmodified lines
3939 return undefined;
4040 }
4141
42− const serialized = Result.try(() => JSON.stringify(value));
43− if (serialized.isErr()) {
42+ let serialized: string | undefined;
43+ try {
44+ serialized = JSON.stringify(value);
45+ } catch {
4446 return String(value);
4547 }
4648
47− if (serialized.value === undefined) {
49+ if (serialized === undefined) {
4850 return null;
4951 }
5052
51− return Result.try(() => JSON.parse(serialized.value)).unwrapOr(String(value));
53+ try {
54+ return JSON.parse(serialized);
55+ } catch {
56+ return String(value);
57+ }
5258 }
5359
5460 function createToolsProxy(
executor/packages/core/src/tool-discovery.ts+7−5
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 } from "./tool-discovery/formatting";
1111 import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing";
1212 import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";
13−import { asRecord } from "./utils";
13+const recordSchema = z.record(z.unknown());
1414
1515 const catalogToolsInputSchema = z.object({
1616 namespace: z.string().optional(),
@@ -87,8 +87,9 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 70 unmodified lines
8787 outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] },
8888 },
8989 run: async (input: unknown, context) => {
90− const payload = asRecord(input);
91− const parsedInput = catalogToolsInputSchema.safeParse(payload);
90+ const payload = recordSchema.safeParse(input);
91+ const normalizedPayload = payload.success ? payload.data : {};
92+ const parsedInput = catalogToolsInputSchema.safeParse(normalizedPayload);
9293 const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();
9394 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
9495 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
@@ -177,8 +178,9 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 82 unmodified lines
177178 },
178179 },
179180 run: async (input: unknown, context) => {
180− const payload = asRecord(input);
181− const parsedInput = discoverInputSchema.safeParse(payload);
181+ const payload = recordSchema.safeParse(input);
182+ const normalizedPayload = payload.success ? payload.data : {};
183+ const parsedInput = discoverInputSchema.safeParse(normalizedPayload);
182184 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
183185 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
184186 const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1)));
executor/packages/core/src/tool-discovery/indexing.ts+19−8
@@ -1,8 +1,8 @@
1+import { z } from "zod";
12 import type { ToolDefinition } from "../types";
23 import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
34 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
45 import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize";
5−import { asRecord } from "../utils";
66 import type { DiscoverIndexEntry } from "./types";
77
88 const GENERIC_NAMESPACE_SUFFIXES = new Set([
@@ -14,13 +14,21 @@ const GENERIC_NAMESPACE_SUFFIXES = new Set([ · 5 unmodified lines
1414 "services",
1515 ]);
1616
17+const recordSchema = z.record(z.unknown());
18+const stringArraySchema = z.array(z.string());
19+
20+function coerceRecord(value: unknown): Record<string, unknown> {
21+ const parsed = recordSchema.safeParse(value);
22+ return parsed.success ? parsed.data : {};
23+}
24+
1725 function normalizeHint(type?: string): string {
1826 return type && type.trim().length > 0 ? type : "unknown";
1927 }
2028
2129 function isEmptyObjectSchema(schema: Record<string, unknown>): boolean {
2230 if (Object.keys(schema).length === 0) return true;
23− const props = asRecord(schema.properties);
31+ const props = coerceRecord(schema.properties);
2432 const required = Array.isArray(schema.required) ? schema.required : [];
2533 return Object.keys(props).length === 0 && required.length === 0;
2634 }
@@ -104,18 +112,21 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 77 unmodified lines
104112 const preferredPath = preferredToolPath(tool.path);
105113 const aliases = getPathAliases(tool.path);
106114 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
115+ const typing = tool.typing;
107116
108− const inputSchema = asRecord(tool.typing?.inputSchema);
109− const outputSchema = asRecord(tool.typing?.outputSchema);
117+ const inputSchema = coerceRecord(typing?.inputSchema);
118+ const outputSchema = coerceRecord(typing?.outputSchema);
110119
111120 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);
112121 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);
113122
114− const requiredInputKeys = Array.isArray(tool.typing?.requiredInputKeys)
115− ? tool.typing!.requiredInputKeys!.filter((value): value is string => typeof value === "string")
123+ const parsedRequiredInputKeys = stringArraySchema.safeParse(typing?.requiredInputKeys);
124+ const requiredInputKeys = parsedRequiredInputKeys.success
125+ ? parsedRequiredInputKeys.data
116126 : extractTopLevelRequiredKeys(inputSchema);
117− const previewInputKeys = Array.isArray(tool.typing?.previewInputKeys)
118− ? tool.typing!.previewInputKeys!.filter((value): value is string => typeof value === "string")
127+ const parsedPreviewInputKeys = stringArraySchema.safeParse(typing?.previewInputKeys);
128+ const previewInputKeys = parsedPreviewInputKeys.success
129+ ? parsedPreviewInputKeys.data
119130 : buildPreviewKeys(inputSchema);
120131
121132 const displayInputHint = normalizeHint(
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+69−20
@@ -19,7 +19,6 @@ import { sanitizeSegment } from "../tool/path-utils"; · 18 unmodified lines
1919 import type { GraphqlToolSourceConfig } from "../tool/source-types";
2020 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
2121 import type { ToolDefinition } from "../types";
22−import { asRecord } from "../utils";
2322
2423 const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({
2524 kind: z.string(),
@@ -53,7 +52,7 @@ const gqlEnumValueSchema = z.object({ · 27 unmodified lines
5352 description: z.string().nullable().optional().transform((value) => value ?? null),
5453 });
5554
56−const gqlTypeSchema: z.ZodType<GqlType> = z.object({
55+const gqlTypeSchema = z.object({
5756 kind: z.string(),
5857 name: z.string(),
5958 fields: z.array(gqlFieldSchema).nullable().optional().transform((value) => value ?? null),
@@ -61,7 +60,7 @@ const gqlTypeSchema: z.ZodType<GqlType> = z.object({ · 1 unmodified line
6160 enumValues: z.array(gqlEnumValueSchema).nullable().optional().transform((value) => value ?? null),
6261 });
6362
64−const gqlSchemaSchema: z.ZodType<GqlSchema> = z.object({
63+const gqlSchemaSchema = z.object({
6564 queryType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),
6665 mutationType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),
6766 types: z.array(gqlTypeSchema),
@@ -72,6 +71,59 @@ const gqlIntrospectionResponseSchema = z.object({ · 4 unmodified lines
7271 errors: z.array(z.unknown()).optional(),
7372 });
7473
74+const graphqlObjectInputSchema = z.object({
75+ query: z.string().optional(),
76+ variables: z.unknown().optional(),
77+}).catchall(z.unknown());
78+
79+const graphqlToolInputSchema = z.union([
80+ z.string(),
81+ graphqlObjectInputSchema,
82+]);
83+
84+const recordSchema = z.record(z.unknown());
85+
86+function coerceRecord(value: unknown): Record<string, unknown> {
87+ const parsed = recordSchema.safeParse(value);
88+ return parsed.success ? parsed.data : {};
89+}
90+
91+function normalizeGraphqlToolInput(value: unknown): {
92+ payload: Record<string, unknown>;
93+ query: string;
94+ variables: unknown;
95+ hasExplicitQuery: boolean;
96+} {
97+ const parsedInput = graphqlToolInputSchema.safeParse(value);
98+ if (!parsedInput.success) {
99+ const payload = coerceRecord(value);
100+ return {
101+ payload,
102+ query: "",
103+ variables: payload.variables,
104+ hasExplicitQuery: false,
105+ };
106+ }
107+
108+ if (typeof parsedInput.data === "string") {
109+ const query = parsedInput.data.trim();
110+ return {
111+ payload: { query: parsedInput.data },
112+ query,
113+ variables: undefined,
114+ hasExplicitQuery: query.length > 0,
115+ };
116+ }
117+
118+ const query = (parsedInput.data.query ?? "").trim();
119+ return {
120+ payload: parsedInput.data,
121+ query,
122+ variables: parsedInput.data.variables,
123+ hasExplicitQuery: query.length > 0,
124+ };
125+}
126+
75127 const INTROSPECTION_QUERY = `
76128 query IntrospectionQuery {
77129 __schema {
@@ -220,7 +272,7 @@ function parseGraphqlIntrospectionSchema(payload: unknown): Result<GqlSchema, Er · 142 unmodified lines
220272 }
221273
222274 function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope {
223− const payload = asRecord(value);
275+ const payload = coerceRecord(value);
224276 return {
225277 data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null,
226278 errors: Array.isArray(payload.errors) ? payload.errors : [],
@@ -248,15 +300,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 21 unmodified lines
248300 throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);
249301 }
250302
251− const introspectionJsonResult = await Result.tryPromise(() => introspectionResult.json());
252− if (introspectionJsonResult.isErr()) {
253− const message = introspectionJsonResult.error.cause instanceof Error
254− ? introspectionJsonResult.error.cause.message
255− : String(introspectionJsonResult.error.cause);
303+ let introspectionJson: unknown;
304+ try {
305+ introspectionJson = await introspectionResult.json();
306+ } catch (error) {
307+ const message = error instanceof Error ? error.message : String(error);
256308 throw new Error(`GraphQL introspection response parse failed: ${message}`);
257309 }
258310
259− const schemaResult = parseGraphqlIntrospectionSchema(introspectionJsonResult.value);
311+ const schemaResult = parseGraphqlIntrospectionSchema(introspectionJson);
260312 if (schemaResult.isErr()) {
261313 throw schemaResult.error;
262314 }
@@ -306,18 +358,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 43 unmodified lines
306358 authHeaders,
307359 },
308360 run: async (input: unknown, context) => {
309− const payload = asRecord(input);
310− const query = String(payload.query ?? "");
311− const variables = payload.variables ?? undefined;
312−
313− if (!query.trim()) {
361+ const normalized = normalizeGraphqlToolInput(input);
362+ if (!normalized.hasExplicitQuery) {
314363 throw new Error("GraphQL query string is required");
315364 }
316365 return await executeGraphqlRequest(
317366 config.endpoint,
318367 authHeaders,
319− query,
320− variables,
368+ normalized.query,
369+ normalized.variables,
321370 context.credential?.headers,
322371 );
323372 },
@@ -389,9 +438,9 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 65 unmodified lines
389438 _pseudoTool: true,
390439 run: async (input: unknown, context) => {
391440 // If someone calls this directly, delegate to the main graphql tool
392− const payload = asRecord(input);
393− const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;
394− if (!hasExplicitQuery) {
441+ const normalized = normalizeGraphqlToolInput(input);
442+ const payload = normalized.payload;
443+ if (!normalized.hasExplicitQuery) {
395444 // Auto-build the query from the variables
396445 payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);
397446 if (payload.variables === undefined) {
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+10−4
@@ -8,13 +8,19 @@ import { sanitizeSegment } from "../tool/path-utils"; · 7 unmodified lines
88 import type { McpToolSourceConfig } from "../tool/source-types";
99 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
1010 import type { ToolDefinition } from "../types";
11−import { asRecord } from "../utils";
1211 import type { SerializedTool } from "../tool/source-serialization";
1312
1413 const listedToolsResponseSchema = z.object({
1514 tools: z.array(z.record(z.unknown())).optional(),
1615 });
1716
17+const recordSchema = z.record(z.unknown());
18+
19+function coerceRecord(value: unknown): Record<string, unknown> {
20+ const parsed = recordSchema.safeParse(value);
21+ return parsed.success ? parsed.data : {};
22+}
23+
1824 function extractListedTools(value: unknown): Record<string, unknown>[] {
1925 const parsed = listedToolsResponseSchema.safeParse(value);
2026 if (!parsed.success) {
@@ -68,8 +74,8 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 47 unmodified lines
6874
6975 return tools.map((tool) => {
7076 const toolName = String(tool.name ?? "tool");
71− const inputSchema = asRecord(tool.inputSchema);
72− const outputSchema = asRecord(tool.outputSchema);
77+ const inputSchema = coerceRecord(tool.inputSchema);
78+ const outputSchema = coerceRecord(tool.outputSchema);
7379 const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0);
7480 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
7581 return {
@@ -93,7 +99,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines
9399 toolName,
94100 },
95101 run: async (input: unknown, context) => {
96− const payload = asRecord(input);
102+ const payload = coerceRecord(input);
97103 const result = await callToolWithReconnect(toolName, payload, context.credential?.headers);
98104 return extractMcpResult(result);
99105 },
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+54−25
@@ -1,6 +1,5 @@
11 "use node";
22
3−import { Result } from "better-result";
43 import { z } from "zod";
54 import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";
65 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
@@ -16,7 +15,7 @@ import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman · 9 unmodified lines
1615 import { prepareOpenApiSpec } from "../openapi-prepare";
1716 import type { OpenApiToolSourceConfig } from "../tool/source-types";
1817 import type { ToolDefinition } from "../types";
19−import { asRecord } from "../utils";
18+import { toPlainObject } from "../utils";
2019 import type { SerializedTool } from "../tool/source-serialization";
2120
2221 const POSTMAN_SPEC_PREFIX = "postman:";
@@ -30,6 +29,29 @@ const postmanCollectionResponseSchema = z.object({ · 7 unmodified lines
3029 }).optional(),
3130 });
3231
32+const postmanFolderSchema = z.object({
33+ id: z.string(),
34+ name: z.string().optional(),
35+ folder: z.string().optional(),
36+});
37+
38+const postmanRequestSchema = z.object({
39+ id: z.string().optional(),
40+ name: z.string().optional(),
41+ method: z.string().optional(),
42+ url: z.string(),
43+ folder: z.string().optional(),
44+ pathVariableData: z.unknown().optional(),
45+ pathVariables: z.unknown().optional(),
46+ headerData: z.unknown().optional(),
47+ headers: z.unknown().optional(),
48+ queryParams: z.unknown().optional(),
49+ queryParam: z.unknown().optional(),
50+ bodyData: z.unknown().optional(),
51+ body: z.unknown().optional(),
52+ description: z.string().optional(),
53+});
54+
3355 function parsePostmanCollectionUid(spec: string): string | null {
3456 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {
3557 return null;
@@ -67,14 +89,15 @@ async function loadPostmanCollectionTools( · 31 unmodified lines
6789 throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`);
6890 }
6991
70− const responseJsonResult = await Result.tryPromise(() => response.json());
71− if (responseJsonResult.isErr()) {
72− const cause = responseJsonResult.error.cause;
73− const message = cause instanceof Error ? cause.message : String(cause);
92+ let responseJson: unknown;
93+ try {
94+ responseJson = await response.json();
95+ } catch (error) {
96+ const message = error instanceof Error ? error.message : String(error);
7497 throw new Error(`Failed to parse API collection ${collectionUid}: ${message}`);
7598 }
7699
77− const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJsonResult.value);
100+ const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJson);
78101 if (!parsedCollection.success) {
79102 throw new Error(`Invalid API collection response for ${collectionUid}: ${parsedCollection.error.message}`);
80103 }
@@ -87,11 +110,14 @@ async function loadPostmanCollectionTools( · 6 unmodified lines
87110
88111 const folderById = new Map<string, { name: string; parentId?: string }>();
89112 for (const folder of folders) {
90− const id = typeof folder.id === "string" ? folder.id : "";
91− if (!id) continue;
92− const name = typeof folder.name === "string" && folder.name.trim().length > 0 ? folder.name : "folder";
93− const parentId = typeof folder.folder === "string" ? folder.folder : undefined;
94− folderById.set(id, { name, parentId });
113+ const parsedFolder = postmanFolderSchema.safeParse(folder);
114+ if (!parsedFolder.success) continue;
115+
116+ const id = parsedFolder.data.id;
117+ const name = parsedFolder.data.name && parsedFolder.data.name.trim().length > 0
118+ ? parsedFolder.data.name
119+ : "folder";
120+ folderById.set(id, { name, parentId: parsedFolder.data.folder });
95121 }
96122
97123 const sourceLabel = `catalog:${config.name}`;
@@ -115,29 +141,32 @@ async function loadPostmanCollectionTools( · 17 unmodified lines
115141 const tools: ToolDefinition[] = [];
116142
117143 for (const request of requests) {
118− const methodRaw = typeof request.method === "string" ? request.method.toLowerCase() : "get";
144+ const parsedRequest = postmanRequestSchema.safeParse(request);
145+ if (!parsedRequest.success) continue;
146+
147+ const methodRaw = (parsedRequest.data.method ?? "get").toLowerCase();
119148 const method = methodRaw.length > 0 ? methodRaw : "get";
120− const url = typeof request.url === "string" ? request.url : "";
149+ const url = parsedRequest.data.url;
121150 if (!url) continue;
122151
123− const requestId = typeof request.id === "string" ? request.id : "";
124− const requestName = typeof request.name === "string" && request.name.trim().length > 0
125− ? request.name.trim()
152+ const requestId = parsedRequest.data.id ?? "";
153+ const requestName = parsedRequest.data.name && parsedRequest.data.name.trim().length > 0
154+ ? parsedRequest.data.name.trim()
126155 : requestId || `${method.toUpperCase()} request`;
127− const folderId = typeof request.folder === "string" ? request.folder : undefined;
156+ const folderId = parsedRequest.data.folder;
128157 const folderPath = resolvePostmanFolderPath(folderId, folderById);
129158 const requestVariables = {
130159 ...collectionVariables,
131− ...extractPostmanVariableMap(request.pathVariableData),
160+ ...extractPostmanVariableMap(parsedRequest.data.pathVariableData ?? parsedRequest.data.pathVariables),
132161 };
133162
134163 const runSpec: PostmanSerializedRunSpec = {
135164 kind: "postman",
136165 method,
137166 url,
138− headers: extractPostmanHeaderMap(request.headerData),
139− queryParams: extractPostmanQueryEntries(request.queryParams),
140− body: extractPostmanBody(request),
167+ headers: extractPostmanHeaderMap(parsedRequest.data.headerData ?? parsedRequest.data.headers),
168+ queryParams: extractPostmanQueryEntries(parsedRequest.data.queryParams ?? parsedRequest.data.queryParam),
169+ body: extractPostmanBody(parsedRequest.data.bodyData ?? parsedRequest.data.body),
141170 variables: requestVariables,
142171 authHeaders,
143172 };
@@ -152,8 +181,8 @@ async function loadPostmanCollectionTools( · 8 unmodified lines
152181 path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths),
153182 source: sourceLabel,
154183 approval,
155− description: typeof request.description === "string" && request.description.trim().length > 0
156− ? request.description
184+ description: parsedRequest.data.description && parsedRequest.data.description.trim().length > 0
185+ ? parsedRequest.data.description
157186 : `${method.toUpperCase()} ${url}`,
158187 typing: {
159188 inputSchema,
@@ -163,7 +192,7 @@ async function loadPostmanCollectionTools( · 3 unmodified lines
163192 credential: credentialSpec,
164193 _runSpec: runSpec,
165194 run: async (input: unknown, context) => {
166− const payloadRecord = asRecord(input);
195+ const payloadRecord = toPlainObject(input) ?? {};
167196 return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers);
168197 },
169198 };
executor/packages/core/src/tool-sources.test.ts+91−0
@@ -448,6 +448,87 @@ test("postman collection specs load and execute request tools", async () => { · 447 unmodified lines
448448 }
449449 });
450450
451+test("postman collection supports v2 body modes", async () => {
452+ const upstream = Bun.serve({
453+ port: 0,
454+ fetch: async (req) => {
455+ const url = new URL(req.url);
456+ if (req.method === "POST" && url.pathname === "/submit") {
457+ return Response.json({
458+ ok: true,
459+ body: await req.text(),
460+ contentType: req.headers.get("content-type"),
461+ token: req.headers.get("x-token"),
462+ });
463+ }
464+ return new Response("not found", { status: 404 });
465+ },
466+ });
467+
468+ const proxy = Bun.serve({
469+ port: 0,
470+ fetch: async (req) => {
471+ const body = (await req.json()) as { service?: string; method?: string; path?: string };
472+ if (body.service === "sync" && body.method === "GET" && body.path === "/collection/123-v2-body?populate=true") {
473+ return Response.json({
474+ data: {
475+ name: "Demo v2 body collection",
476+ variables: [{ key: "token", value: "Bearer v2-token" }],
477+ requests: [
478+ {
479+ id: "request_submit",
480+ name: "Submit payload",
481+ method: "POST",
482+ url: `http://127.0.0.1:${upstream.port}/submit`,
483+ headerData: [{ key: "x-token", value: "{{token}}" }],
484+ body: {
485+ mode: "urlencoded",
486+ urlencoded: [
487+ { key: "itemId", value: "{{itemId}}" },
488+ { key: "count", value: 2 },
489+ ],
490+ },
491+ },
492+ ],
493+ },
494+ });
495+ }
496+ return Response.json({ error: "unknown request" }, { status: 400 });
497+ },
498+ });
499+
500+ try {
501+ const { tools, warnings } = await loadExternalTools([
502+ {
503+ type: "openapi",
504+ name: "postman-v2-body",
505+ spec: "postman:123-v2-body",
506+ postmanProxyUrl: `http://127.0.0.1:${proxy.port}`,
507+ },
508+ ]);
509+
510+ expect(warnings).toHaveLength(0);
511+ expect(tools).toHaveLength(1);
512+
513+ const result = await tools[0]!.run(
514+ {
515+ variables: { itemId: "xyz" },
516+ },
517+ { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
518+ );
519+
520+ expect(result).toEqual({
521+ ok: true,
522+ body: "itemId=xyz&count=2",
523+ contentType: "application/x-www-form-urlencoded",
524+ token: "Bearer v2-token",
525+ });
526+ } finally {
527+ proxy.stop(true);
528+ upstream.stop(true);
529+ }
530+});
531+
451532 test("parseGraphqlOperationPaths handles aliases and fragments", async () => {
452533 const parsed = parseGraphqlOperationPaths(
453534 "linear",
@@ -708,6 +789,7 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 254 unmodified lines
708789 const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context);
709790 const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context);
710791 const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context);
792+ const rawStringResult = await rawTool!.run("query { teams { totalCount } }", context);
711793
712794 expect(teamsResult).toEqual({
713795 data: {
@@ -739,6 +821,15 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 25 unmodified lines
739821 },
740822 errors: [],
741823 });
824+ expect(rawStringResult).toEqual({
825+ data: {
826+ teams: {
827+ totalCount: 1,
828+ nodes: [{ id: "team_1", name: "Core" }],
829+ },
830+ },
831+ errors: [],
832+ });
742833
743834 const helperQueries = capturedQueries.filter((query) => !query.includes("__schema"));
744835 const helperVariables = capturedVariables.slice(1);
executor/packages/core/src/tool-typing/convex-sanitize.ts+6−2
@@ -1,5 +1,9 @@
11 import type { JsonSchema } from "../types";
2−import { asRecord } from "../utils";
2+import { isPlainObject } from "../utils";
3+
4+function toRecord(value: unknown): Record<string, unknown> {
5+ return isPlainObject(value) ? value : {};
6+}
37
48 function stripDollarKeysDeep(value: unknown): unknown {
59 if (Array.isArray(value)) {
@@ -8,7 +12,7 @@ function stripDollarKeysDeep(value: unknown): unknown { · 2 unmodified lines
812
913 if (value && typeof value === "object") {
1014 const out: Record<string, unknown> = {};
11− for (const [key, child] of Object.entries(asRecord(value))) {
15+ for (const [key, child] of Object.entries(toRecord(value))) {
1216 if (key.startsWith("$")) continue;
1317 out[key] = stripDollarKeysDeep(child);
1418 }
executor/packages/core/src/tool-typing/schema-utils.ts+8−4
@@ -1,5 +1,9 @@
11 import type { JsonSchema } from "../types";
2−import { asRecord } from "../utils";
2+import { isPlainObject } from "../utils";
3+
4+function toRecord(value: unknown): Record<string, unknown> {
5+ return isPlainObject(value) ? value : {};
6+}
37
48 function uniq(values: string[]): string[] {
59 return [...new Set(values.filter((v) => v.trim().length > 0))];
@@ -16,19 +20,19 @@ function collectTopLevelRequiredKeys(schema: Record<string, unknown>, out: strin · 10 unmodified lines
1620
1721 const allOf = asUnknownArray(schema.allOf);
1822 for (const entry of allOf) {
19− const nested = asRecord(entry);
23+ const nested = toRecord(entry);
2024 if (Object.keys(nested).length === 0) continue;
2125 collectTopLevelRequiredKeys(nested, out);
2226 }
2327 }
2428
2529 function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void {
26− const props = asRecord(schema.properties);
30+ const props = toRecord(schema.properties);
2731 out.push(...Object.keys(props));
2832
2933 const allOf = asUnknownArray(schema.allOf);
3034 for (const entry of allOf) {
31− const nested = asRecord(entry);
35+ const nested = toRecord(entry);
3236 if (Object.keys(nested).length === 0) continue;
3337 collectTopLevelPropertyKeys(nested, out);
3438 }
executor/packages/core/src/tool-typing/typebundle.ts+6−2
@@ -1,9 +1,13 @@
11 import type { ToolDefinition } from "../types";
22 import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
33 import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
4−import { asRecord } from "../utils";
4+import { isPlainObject } from "../utils";
55 import { BASE_ENVIRONMENT_DTS } from "./env-types";
66
7+function toRecord(value: unknown): Record<string, unknown> {
8+ return isPlainObject(value) ? value : {};
9+}
10+
711 const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
812
913 function emitMemberName(name: string): string {
@@ -31,7 +35,7 @@ function wrapDtsInNamespace(namespace: string, rawDts: string): string { · 21 unmodified lines
3135 function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {
3236 if (!schema || Object.keys(schema).length === 0) return fallback;
3337 // Special-case empty object input to keep signatures tidy.
34− const props = asRecord(schema.properties);
38+ const props = toRecord(schema.properties);
3539 const required = Array.isArray(schema.required) ? schema.required : [];
3640 if (Object.keys(props).length === 0 && required.length === 0) {
3741 return fallback === "{}" ? "{}" : fallback;
executor/packages/core/src/tool/source-auth.test.ts+77−0
@@ -0,0 +1,77 @@
1+import { expect, test } from "bun:test";
2+import {
3+ buildCredentialAuthHeaders,
4+ buildCredentialSpec,
5+ buildStaticAuthHeaders,
6+ readCredentialOverrideHeaders,
7+} from "./source-auth";
8+
9+test("buildStaticAuthHeaders returns static bearer header", () => {
10+ const headers = buildStaticAuthHeaders({
11+ type: "bearer",
12+ mode: "static",
13+ token: "secret-token",
14+ });
15+
16+ expect(headers).toEqual({ authorization: "Bearer secret-token" });
17+});
18+
19+test("buildCredentialSpec omits static auth and preserves actor mode", () => {
20+ expect(buildCredentialSpec("source:test", { type: "none" })).toBeUndefined();
21+ expect(
22+ buildCredentialSpec("source:test", {
23+ type: "apiKey",
24+ mode: "actor",
25+ header: "x-api-key",
26+ }),
27+ ).toEqual({
28+ sourceKey: "source:test",
29+ mode: "actor",
30+ authType: "apiKey",
31+ headerName: "x-api-key",
32+ });
33+});
34+
35+test("buildCredentialAuthHeaders supports bearer token aliases", () => {
36+ const headers = buildCredentialAuthHeaders(
37+ { authType: "bearer" },
38+ { accessToken: " token-123 " },
39+ );
40+
41+ expect(headers).toEqual({ authorization: "Bearer token-123" });
42+});
43+
44+test("buildCredentialAuthHeaders supports apiKey aliases and explicit header", () => {
45+ const headers = buildCredentialAuthHeaders(
46+ { authType: "apiKey", headerName: "x-custom-key" },
47+ { apiKey: "api-value", headerName: "x-ignored" },
48+ );
49+
50+ expect(headers).toEqual({ "x-custom-key": "api-value" });
51+});
52+
53+test("buildCredentialAuthHeaders supports basic auth aliases", () => {
54+ const headers = buildCredentialAuthHeaders(
55+ { authType: "basic" },
56+ { user: "alice", pass: "hunter2" },
57+ );
58+
59+ expect(headers).toEqual({
60+ authorization: `Basic ${Buffer.from("alice:hunter2", "utf8").toString("base64")}`,
61+ });
62+});
63+
64+test("readCredentialOverrideHeaders trims keys and coerces values", () => {
65+ const headers = readCredentialOverrideHeaders({
66+ headers: {
67+ " x-trace-id ": "trace-1",
68+ "": "ignored",
69+ "x-retry": 2,
70+ },
71+ });
72+
73+ expect(headers).toEqual({
74+ "x-trace-id": "trace-1",
75+ "x-retry": "2",
76+ });
77+});
executor/packages/core/src/tool/source-auth.ts+83−0
@@ -1,5 +1,88 @@
11 import type { OpenApiAuth } from "./source-types";
22 import type { ToolCredentialSpec } from "../types";
3+import { z } from "zod";
4+
5+export type CredentialHeaderAuthSpec = {
6+ authType: "bearer" | "apiKey" | "basic";
7+ headerName?: string;
8+};
9+
10+const secretRecordSchema = z.record(z.unknown());
11+const credentialOverrideHeadersSchema = z.object({
12+ headers: z.record(z.coerce.string()).optional(),
13+});
14+
15+function toRecord(value: unknown): Record<string, unknown> {
16+ const parsed = secretRecordSchema.safeParse(value);
17+ return parsed.success ? parsed.data : {};
18+}
19+
20+function getTrimmedString(value: unknown): string {
21+ if (typeof value !== "string") {
22+ return "";
23+ }
24+
25+ return value.trim();
26+}
27+
28+function readSecretValue(record: Record<string, unknown>, aliases: string[]): string {
29+ const entries = new Map<string, unknown>();
30+ for (const [key, value] of Object.entries(record)) {
31+ entries.set(key.toLowerCase(), value);
32+ }
33+
34+ for (const alias of aliases) {
35+ const value = entries.get(alias.toLowerCase());
36+ const trimmed = getTrimmedString(value);
37+ if (trimmed) {
38+ return trimmed;
39+ }
40+ }
41+
42+ return "";
43+}
44+
45+export function buildCredentialAuthHeaders(
46+ auth: CredentialHeaderAuthSpec,
47+ secret: unknown,
48+): Record<string, string> {
49+ const payload = toRecord(secret);
50+
51+ if (auth.authType === "bearer") {
52+ const token = readSecretValue(payload, ["token", "accessToken", "bearerToken", "value"]);
53+ return token ? { authorization: `Bearer ${token}` } : {};
54+ }
55+
56+ if (auth.authType === "apiKey") {
57+ const discoveredHeader = readSecretValue(payload, ["headerName", "header", "keyName"]);
58+ const headerName = (auth.headerName ?? discoveredHeader) || "x-api-key";
59+ const value = readSecretValue(payload, ["value", "token", "apiKey", "key", "accessToken"]);
60+ return value ? { [headerName]: value } : {};
61+ }
62+
63+ const username = readSecretValue(payload, ["username", "user"]);
64+ const password = readSecretValue(payload, ["password", "pass"]);
65+ if (!username && !password) {
66+ return {};
67+ }
68+
69+ const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
70+ return { authorization: `Basic ${encoded}` };
71+}
72+
73+export function readCredentialOverrideHeaders(value: unknown): Record<string, string> {
74+ const parsed = credentialOverrideHeadersSchema.safeParse(value);
75+ const rawHeaders = parsed.success ? (parsed.data.headers ?? {}) : {};
76+
77+ const normalized: Record<string, string> = {};
78+ for (const [rawKey, rawValue] of Object.entries(rawHeaders)) {
79+ const key = rawKey.trim();
80+ if (!key) continue;
81+ normalized[key] = rawValue;
82+ }
83+
84+ return normalized;
85+}
386
487 export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> {
588 if (!auth || auth.type === "none") return {};
executor/packages/core/src/tool/source-execution.ts+10−3
@@ -1,5 +1,12 @@
11 import { Result, TaggedError } from "better-result";
2−import { asRecord } from "../utils";
2+import { z } from "zod";
3+
4+const recordSchema = z.record(z.unknown());
5+
6+function toRecord(value: unknown): Record<string, unknown> {
7+ const parsed = recordSchema.safeParse(value);
8+ return parsed.success ? parsed.data : {};
9+}
310
411 type OpenApiRequestErrorArgs = {
512 status: number | null;
@@ -88,7 +95,7 @@ export async function executeOpenApiRequest( · 82 unmodified lines
8895 input: unknown,
8996 credentialHeaders?: Record<string, string>,
9097 ): Promise<Result<unknown, OpenApiRequestError>> {
91− const payload = asRecord(input);
98+ const payload = toRecord(input);
9299 const readMethods = new Set(["get", "head", "options"]);
93100 const { url, bodyInput } = buildOpenApiUrl(
94101 runSpec.baseUrl,
@@ -154,7 +161,7 @@ export async function executeOpenApiRequest( · 59 unmodified lines
154161 function hasGraphqlData(data: unknown): boolean {
155162 if (data === null || data === undefined) return false;
156163 if (Array.isArray(data)) return data.length > 0;
157− if (typeof data === "object") return Object.keys(asRecord(data)).length > 0;
164+ if (typeof data === "object") return Object.keys(toRecord(data)).length > 0;
158165 return true;
159166 }
160167
executor/packages/core/src/tool/source-serialization.ts+76−29
@@ -5,7 +5,13 @@ import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest · 4 unmodified lines
55 import { Result } from "better-result";
66 import { z } from "zod";
77 import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";
8−import { asRecord } from "../utils";
8+
9+const recordSchema = z.record(z.unknown());
10+
11+function toRecord(value: unknown): Record<string, unknown> {
12+ const parsed = recordSchema.safeParse(value);
13+ return parsed.success ? parsed.data : {};
14+}
915
1016 export interface SerializedTool {
1117 path: string;
@@ -124,21 +130,62 @@ const toolTypingSchema: z.ZodType<ToolTyping> = z.object({ · 112 unmodified lines
124130
125131 const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({
126132 sourceKey: z.string(),
127− mode: z.enum(["static", "workspace", "actor"]),
133+ mode: z.enum(["workspace", "actor"]),
128134 authType: z.enum(["bearer", "apiKey", "basic"]),
129135 headerName: z.string().optional(),
130136 staticSecretJson: z.record(z.unknown()).optional(),
131137 });
132138
133−const graphqlRawInputSchema = z.object({
134− query: z.string(),
139+const graphqlObjectInputSchema = z.object({
140+ query: z.string().optional(),
135141 variables: z.unknown().optional(),
142+}).catchall(z.unknown());
143+
144+const graphqlInvocationInputSchema = z.union([
145+ z.string(),
146+ graphqlObjectInputSchema,
147+]);
148+
149+const invalidSerializedToolFallbackSchema = z.object({
150+ path: z.string().optional(),
151+ source: z.string().optional(),
136152 });
137153
138−const graphqlFieldInputSchema = z.object({
139− query: z.string().optional(),
140− variables: z.unknown().optional(),
141−}).passthrough();
154+function normalizeGraphqlInvocationInput(input: unknown): {
155+ payload: Record<string, unknown>;
156+ query: string;
157+ variables: unknown;
158+ hasExplicitQuery: boolean;
159+} {
160+ const parsedInput = graphqlInvocationInputSchema.safeParse(input);
161+ if (!parsedInput.success) {
162+ const payload = toRecord(input);
163+ return {
164+ payload,
165+ query: "",
166+ variables: payload.variables,
167+ hasExplicitQuery: false,
168+ };
169+ }
170+
171+ if (typeof parsedInput.data === "string") {
172+ const query = parsedInput.data.trim();
173+ return {
174+ payload: { query: parsedInput.data },
175+ query,
176+ variables: undefined,
177+ hasExplicitQuery: query.length > 0,
178+ };
179+ }
180+
181+ const query = (parsedInput.data.query ?? "").trim();
182+ return {
183+ payload: parsedInput.data,
184+ query,
185+ variables: parsedInput.data.variables,
186+ hasExplicitQuery: query.length > 0,
187+ };
188+}
142189
143190 const serializedRunSpecSchema = z.union([
144191 openApiRunSpecSchema,
@@ -229,16 +276,16 @@ export function rehydrateTools( · 84 unmodified lines
229276 return serialized.map((candidate, index) => {
230277 const parsed = parseSerializedTool(candidate);
231278 if (parsed.isErr()) {
232− const raw = asRecord(candidate);
233− const path = typeof raw.path === "string" && raw.path.trim().length > 0
234− ? raw.path
279+ const fallback = invalidSerializedToolFallbackSchema.safeParse(candidate);
280+ const path = fallback.success && fallback.data.path && fallback.data.path.trim().length > 0
281+ ? fallback.data.path
235282 : `invalid_serialized_tool_${index + 1}`;
236283
237284 return {
238285 path,
239286 description: "Invalid serialized tool definition",
240287 approval: "required",
241− source: typeof raw.source === "string" ? raw.source : undefined,
288+ source: fallback.success ? fallback.data.source : undefined,
242289 run: async () => {
243290 throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`);
244291 },
@@ -282,7 +329,7 @@ export function rehydrateTools( · 37 unmodified lines
282329 return {
283330 ...base,
284331 run: async (input: unknown, context) => {
285− const payload = asRecord(input);
332+ const payload = toRecord(input);
286333 return await executePostmanRequest(runSpec, payload, context.credential?.headers);
287334 },
288335 };
@@ -305,7 +352,7 @@ export function rehydrateTools( · 16 unmodified lines
305352 () => connectMcp(url, queryParams, transport, mergedHeaders),
306353 );
307354
308− const payload = asRecord(input);
355+ const payload = toRecord(input);
309356 const result = await callMcpToolWithReconnect(
310357 () => conn.client.callTool({ name: toolName, arguments: payload }),
311358 async () => {
@@ -330,14 +377,17 @@ export function rehydrateTools( · 18 unmodified lines
330377 return {
331378 ...base,
332379 run: async (input: unknown, context) => {
333− const payload = asRecord(input);
334− const parsedInput = graphqlRawInputSchema.safeParse(payload);
335− const query = parsedInput.success ? parsedInput.data.query : "";
336− if (!query.trim()) {
380+ const normalized = normalizeGraphqlInvocationInput(input);
381+ if (!normalized.hasExplicitQuery) {
337382 throw new Error("GraphQL query string is required");
338383 }
339− const variables = parsedInput.success ? parsedInput.data.variables : undefined;
340− const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);
384+ const response = await executeGraphqlRequest(
385+ endpoint,
386+ authHeaders,
387+ normalized.query,
388+ normalized.variables,
389+ context.credential?.headers,
390+ );
341391 if (response.isErr()) {
342392 throw new Error(response.error.message);
343393 }
@@ -351,15 +401,12 @@ export function rehydrateTools( · 7 unmodified lines
351401 return {
352402 ...base,
353403 run: async (input: unknown, context) => {
354− const payload = asRecord(input);
355− const parsedInput = graphqlFieldInputSchema.safeParse(payload);
356− const normalizedInput = parsedInput.success ? parsedInput.data : {};
357− const hasExplicitQuery = typeof normalizedInput.query === "string" && normalizedInput.query.trim().length > 0;
358− const query = hasExplicitQuery ? normalizedInput.query : queryTemplate;
359−
360− let variables = normalizedInput.variables;
361− if (variables === undefined && !hasExplicitQuery) {
362− variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);
404+ const normalized = normalizeGraphqlInvocationInput(input);
405+ const query = normalized.hasExplicitQuery ? normalized.query : queryTemplate;
406+
407+ let variables = normalized.variables;
408+ if (variables === undefined && !normalized.hasExplicitQuery) {
409+ variables = normalizeGraphqlFieldVariables(argNames ?? [], normalized.payload);
363410 }
364411
365412 const envelopeResult = await executeGraphqlRequest(
executor/packages/core/src/types.ts+26−6
@@ -6,6 +6,11 @@ export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_ · 5 unmodified lines
66 export type ApprovalStatus = "pending" | "approved" | "denied";
77 export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";
88 export type PolicyDecision = "allow" | "require_approval" | "deny";
9+export type PolicyScopeType = "organization" | "workspace";
10+export type PolicyMatchType = "glob" | "exact";
11+export type PolicyEffect = "allow" | "deny";
12+export type PolicyApprovalMode = "inherit" | "auto" | "required";
13+export type OwnerScopeType = "organization" | "workspace";
914 export type CredentialScope = "workspace" | "actor";
1015 export type CredentialProvider = "local-convex" | "workos-vault";
1116 export type ToolApprovalMode = "auto" | "required";
@@ -87,20 +92,31 @@ export interface ToolCallRecord { · 75 unmodified lines
8792
8893 export interface AccessPolicyRecord {
8994 id: string;
90− workspaceId: Id<"workspaces">;
91− actorId?: string;
95+ scopeType?: PolicyScopeType;
96+ organizationId?: Id<"organizations">;
97+ workspaceId?: Id<"workspaces">;
98+ targetActorId?: string;
9299 clientId?: string;
93− toolPathPattern: string;
94− decision: PolicyDecision;
100+ resourceType?: "tool_path";
101+ resourcePattern?: string;
102+ matchType?: PolicyMatchType;
103+ effect?: PolicyEffect;
104+ approvalMode?: PolicyApprovalMode;
95105 priority: number;
96106 createdAt: number;
97107 updatedAt: number;
108+ // Legacy aliases retained for callers that still expect v1 fields.
109+ actorId?: string;
110+ toolPathPattern: string;
111+ decision: PolicyDecision;
98112 }
99113
100114 export interface CredentialRecord {
101115 id: string;
102116 bindingId?: string;
103− workspaceId: Id<"workspaces">;
117+ ownerScopeType?: OwnerScopeType;
118+ organizationId?: Id<"organizations">;
119+ workspaceId?: Id<"workspaces">;
104120 sourceKey: string;
105121 scope: CredentialScope;
106122 actorId?: string;
@@ -114,10 +130,14 @@ export interface CredentialRecord { · 7 unmodified lines
114130
115131 export interface ToolSourceRecord {
116132 id: string;
117− workspaceId: Id<"workspaces">;
133+ ownerScopeType?: OwnerScopeType;
134+ organizationId?: Id<"organizations">;
135+ workspaceId?: Id<"workspaces">;
118136 name: string;
119137 type: ToolSourceType;
120138 config: Record<string, unknown>;
139+ specHash?: string;
140+ authFingerprint?: string;
121141 enabled: boolean;
122142 createdAt: number;
123143 updatedAt: number;
executor/packages/core/src/utils.ts+13−7
@@ -1,12 +1,18 @@
11 /**
2− * Safely cast an unknown value to a Record<string, unknown>.
3− * Returns an empty object for nullish, non-object, or array values.
2+ * Runtime guard for plain object payloads.
43 */
5−export function asRecord(value: unknown): Record<string, unknown> {
6− if (value && typeof value === "object" && !Array.isArray(value)) {
7− return value as Record<string, unknown>;
8− }
9− return {};
4+export function isPlainObject(value: unknown): value is Record<string, unknown> {
5+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
6+
7+ const prototype = Object.getPrototypeOf(value);
8+ return prototype === Object.prototype || prototype === null;
9+}
10+
11+/**
12+ * Convert a runtime value into a plain object record, or return undefined.
13+ */
14+export function toPlainObject(value: unknown): Record<string, unknown> | undefined {
15+ return isPlainObject(value) ? value : undefined;
1016 }
1117
1218 /**
executor/packages/core/src/workspace/tool-cache.test.ts+17−3
@@ -5,6 +5,7 @@ · 4 unmodified lines
55 * round-trip with functional `run` methods.
66 */
77 import { test, expect, describe } from "bun:test";
8+import { z } from "zod";
89 import type { Id } from "../../../database/convex/_generated/dataModel";
910 import {
1011 prepareOpenApiSpec,
@@ -20,6 +21,12 @@ import type { ToolDefinition } from "../types"; · 9 unmodified lines
2021
2122 const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
2223
24+const unknownRecordSchema = z.record(z.unknown());
25+const graphqlBodySchema = z.object({
26+ query: z.string().optional(),
27+ variables: z.unknown().optional(),
28+});
29+
2330 function makeBaseTools(): Map<string, ToolDefinition> {
2431 return new Map([
2532 [
@@ -36,7 +43,8 @@ function makeBaseTools(): Map<string, ToolDefinition> { · 10 unmodified lines
3643 previewInputKeys: ["message"],
3744 },
3845 run: async (input: unknown) => {
39− const payload = input as Record<string, unknown>;
46+ const parsedPayload = unknownRecordSchema.safeParse(input);
47+ const payload = parsedPayload.success ? parsedPayload.data : {};
4048 return payload.message;
4149 },
4250 },
@@ -338,7 +346,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 295 unmodified lines
338346 const originalFetch = globalThis.fetch;
339347 const calls: Array<{ query: string; variables: unknown }> = [];
340348 globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
341− const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: unknown };
349+ const parsedBody = graphqlBodySchema.safeParse(JSON.parse(String(init?.body ?? "{}")));
350+ const body = parsedBody.success ? parsedBody.data : {};
342351 calls.push({ query: body.query ?? "", variables: body.variables });
343352
344353 if ((body.query ?? "").includes("teams")) {
@@ -359,14 +368,19 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 14 unmodified lines
359368 { query: "query viewer { viewer { id } }" },
360369 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
361370 );
371+ const rawStringResult = await rawTool!.run(
372+ "query viewer { viewer { id } }",
373+ { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
374+ );
362375 const teamsResult = await teamsTool!.run(
363376 {},
364377 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
365378 );
366379
367380 expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
381+ expect(rawStringResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
368382 expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] });
369− expect(calls.length).toBe(2);
383+ expect(calls.length).toBe(3);
370384 } finally {
371385 globalThis.fetch = originalFetch;
372386 }
executor/packages/database/convex/_generated/api.d.ts+79−25
@@ -88,9 +88,10 @@ export declare const api: { · 87 unmodified lines
8888 {
8989 actorId?: string;
9090 id?: string;
91+ ownerScopeType?: "organization" | "workspace";
9192 provider?: "local-convex" | "workos-vault";
9293 scope: "workspace" | "actor";
93− secretJson: any;
94+ secretJson: Record<string, any>;
9495 sessionId?: string;
9596 sourceKey: string;
9697 workspaceId: Id<"workspaces">;
@@ -106,7 +107,7 @@ export declare const api: { · 9 unmodified lines
106107 actorId?: string;
107108 clientId?: string;
108109 code: string;
109− metadata?: any;
110+ metadata?: Record<string, any>;
110111 runtimeId?: string;
111112 sessionId?: string;
112113 timeoutMs?: number;
@@ -268,7 +269,7 @@ export declare const api: { · 155 unmodified lines
268269 "public",
269270 {
270271 callId: string;
271− input?: any;
272+ input?: Record<string, any>;
272273 internalSecret: string;
273274 runId: string;
274275 toolPath: string;
@@ -336,10 +337,14 @@ export declare const api: { · 61 unmodified lines
336337 "public",
337338 {
338339 actorId?: string;
340+ approvalMode?: "inherit" | "auto" | "required";
339341 clientId?: string;
340− decision: "allow" | "require_approval" | "deny";
342+ decision?: "allow" | "require_approval" | "deny";
343+ effect?: "allow" | "deny";
341344 id?: string;
345+ matchType?: "glob" | "exact";
342346 priority?: number;
347+ scopeType?: "organization" | "workspace";
343348 sessionId?: string;
344349 toolPathPattern: string;
345350 workspaceId: Id<"workspaces">;
@@ -352,10 +357,11 @@ export declare const api: { · 6 unmodified lines
352357 {
353358 actorId?: string;
354359 id?: string;
355− overridesJson?: any;
360+ overridesJson?: Record<string, any>;
361+ ownerScopeType?: "organization" | "workspace";
356362 provider?: "local-convex" | "workos-vault";
357363 scope: "workspace" | "actor";
358− secretJson: any;
364+ secretJson: Record<string, any>;
359365 sessionId?: string;
360366 sourceKey: string;
361367 workspaceId: Id<"workspaces">;
@@ -366,10 +372,11 @@ export declare const api: { · 4 unmodified lines
366372 "mutation",
367373 "public",
368374 {
369− config: any;
375+ config: Record<string, any>;
370376 enabled?: boolean;
371377 id?: string;
372378 name: string;
379+ ownerScopeType?: "organization" | "workspace";
373380 sessionId?: string;
374381 type: "mcp" | "openapi" | "graphql";
375382 workspaceId: Id<"workspaces">;
@@ -486,7 +493,12 @@ export declare const internal: { · 110 unmodified lines
486493 createApproval: FunctionReference<
487494 "mutation",
488495 "internal",
489− { id: string; input?: any; taskId: string; toolPath: string },
496+ {
497+ id: string;
498+ input?: Record<string, any>;
499+ taskId: string;
500+ toolPath: string;
501+ },
490502 any
491503 >;
492504 getApproval: FunctionReference<
@@ -537,7 +549,12 @@ export declare const internal: { · 44 unmodified lines
537549 createApproval: FunctionReference<
538550 "mutation",
539551 "internal",
540− { id: string; input?: any; taskId: string; toolPath: string },
552+ {
553+ id: string;
554+ input?: Record<string, any>;
555+ taskId: string;
556+ toolPath: string;
557+ },
541558 any
542559 >;
543560 createTask: FunctionReference<
@@ -548,7 +565,7 @@ export declare const internal: { · 4 unmodified lines
548565 clientId?: string;
549566 code: string;
550567 id: string;
551− metadata?: any;
568+ metadata?: Record<string, any>;
552569 runtimeId: string;
553570 timeoutMs?: number;
554571 workspaceId: Id<"workspaces">;
@@ -558,7 +575,12 @@ export declare const internal: { · 3 unmodified lines
558575 createTaskEvent: FunctionReference<
559576 "mutation",
560577 "internal",
561− { eventName: string; payload: any; taskId: string; type: string },
578+ {
579+ eventName: string;
580+ payload: Record<string, any>;
581+ taskId: string;
582+ type: string;
583+ },
562584 any
563585 >;
564586 credentials: {
@@ -586,10 +608,11 @@ export declare const internal: { · 21 unmodified lines
586608 {
587609 actorId?: string;
588610 id?: string;
589− overridesJson?: any;
611+ overridesJson?: Record<string, any>;
612+ ownerScopeType?: "organization" | "workspace";
590613 provider?: "local-convex" | "workos-vault";
591614 scope: "workspace" | "actor";
592− secretJson: any;
615+ secretJson: Record<string, any>;
593616 sourceKey: string;
594617 workspaceId: Id<"workspaces">;
595618 },
@@ -733,11 +756,17 @@ export declare const internal: { · 137 unmodified lines
733756 "internal",
734757 {
735758 actorId?: string;
759+ approvalMode?: "inherit" | "auto" | "required";
736760 clientId?: string;
737− decision: "allow" | "require_approval" | "deny";
761+ decision?: "allow" | "require_approval" | "deny";
762+ effect?: "allow" | "deny";
738763 id?: string;
764+ matchType?: "glob" | "exact";
739765 priority?: number;
740− toolPathPattern: string;
766+ resourcePattern?: string;
767+ scopeType?: "organization" | "workspace";
768+ targetActorId?: string;
769+ toolPathPattern?: string;
741770 workspaceId: Id<"workspaces">;
742771 },
743772 any
@@ -775,7 +804,12 @@ export declare const internal: { · 31 unmodified lines
775804 createTaskEvent: FunctionReference<
776805 "mutation",
777806 "internal",
778− { eventName: string; payload: any; taskId: string; type: string },
807+ {
808+ eventName: string;
809+ payload: Record<string, any>;
810+ taskId: string;
811+ type: string;
812+ },
779813 any
780814 >;
781815 listTaskEvents: FunctionReference<
@@ -794,7 +828,7 @@ export declare const internal: { · 12 unmodified lines
794828 clientId?: string;
795829 code: string;
796830 id: string;
797− metadata?: any;
831+ metadata?: Record<string, any>;
798832 runtimeId: string;
799833 timeoutMs?: number;
800834 workspaceId: Id<"workspaces">;
@@ -897,10 +931,11 @@ export declare const internal: { · 96 unmodified lines
897931 "mutation",
898932 "internal",
899933 {
900− config: any;
934+ config: Record<string, any>;
901935 enabled?: boolean;
902936 id?: string;
903937 name: string;
938+ ownerScopeType?: "organization" | "workspace";
904939 type: "mcp" | "openapi" | "graphql";
905940 workspaceId: Id<"workspaces">;
906941 },
@@ -912,11 +947,17 @@ export declare const internal: { · 5 unmodified lines
912947 "internal",
913948 {
914949 actorId?: string;
950+ approvalMode?: "inherit" | "auto" | "required";
915951 clientId?: string;
916− decision: "allow" | "require_approval" | "deny";
952+ decision?: "allow" | "require_approval" | "deny";
953+ effect?: "allow" | "deny";
917954 id?: string;
955+ matchType?: "glob" | "exact";
918956 priority?: number;
919− toolPathPattern: string;
957+ resourcePattern?: string;
958+ scopeType?: "organization" | "workspace";
959+ targetActorId?: string;
960+ toolPathPattern?: string;
920961 workspaceId: Id<"workspaces">;
921962 },
922963 any
@@ -927,10 +968,11 @@ export declare const internal: { · 4 unmodified lines
927968 {
928969 actorId?: string;
929970 id?: string;
930− overridesJson?: any;
971+ overridesJson?: Record<string, any>;
972+ ownerScopeType?: "organization" | "workspace";
931973 provider?: "local-convex" | "workos-vault";
932974 scope: "workspace" | "actor";
933− secretJson: any;
975+ secretJson: Record<string, any>;
934976 sourceKey: string;
935977 workspaceId: Id<"workspaces">;
936978 },
@@ -951,10 +993,11 @@ export declare const internal: { · 14 unmodified lines
951993 "mutation",
952994 "internal",
953995 {
954− config: any;
996+ config: Record<string, any>;
955997 enabled?: boolean;
956998 id?: string;
957999 name: string;
1000+ ownerScopeType?: "organization" | "workspace";
9581001 type: "mcp" | "openapi" | "graphql";
9591002 workspaceId: Id<"workspaces">;
9601003 },
@@ -981,7 +1024,7 @@ export declare const internal: { · 20 unmodified lines
9811024 actorId: string;
9821025 clientId?: string;
9831026 code: string;
984− metadata?: any;
1027+ metadata?: Record<string, any>;
9851028 runtimeId?: string;
9861029 scheduleAfterCreate?: boolean;
9871030 timeoutMs?: number;
@@ -1006,7 +1049,12 @@ export declare const internal: { · 18 unmodified lines
10061049 handleExternalToolCall: FunctionReference<
10071050 "action",
10081051 "internal",
1009− { callId: string; input?: any; runId: string; toolPath: string },
1052+ {
1053+ callId: string;
1054+ input?: Record<string, any>;
1055+ runId: string;
1056+ toolPath: string;
1057+ },
10101058 any
10111059 >;
10121060 listToolsInternal: FunctionReference<
@@ -1258,6 +1306,12 @@ export declare const internal: { · 245 unmodified lines
12581306 },
12591307 any
12601308 >;
1309+ pruneBuilds: FunctionReference<
1310+ "mutation",
1311+ "internal",
1312+ { maxRetainedBuilds?: number; workspaceId: Id<"workspaces"> },
1313+ any
1314+ >;
12611315 putNamespacesBatch: FunctionReference<
12621316 "mutation",
12631317 "internal",
executor/packages/database/convex/credentialsNode.ts+2−0
@@ -8,12 +8,14 @@ import { jsonObjectValidator } from "../src/database/validators"; · 7 unmodified lines
88
99 const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
1010 const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
11+const ownerScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace"));
1112
1213 export const upsertCredential = action({
1314 args: {
1415 id: v.optional(v.string()),
1516 workspaceId: v.id("workspaces"),
1617 sessionId: v.optional(v.string()),
18+ ownerScopeType: v.optional(ownerScopeTypeValidator),
1719 sourceKey: v.string(),
1820 scope: credentialScopeValidator,
1921 actorId: v.optional(v.string()),
executor/packages/database/convex/database/credentials.ts+156−55
@@ -1,4 +1,5 @@
11 import { v } from "convex/values";
2+import { z } from "zod";
23 import { internalMutation, internalQuery } from "../_generated/server";
34 import { mapCredential } from "../../src/database/mappers";
45 import { computeBoundAuthFingerprint } from "../../src/database/readers";
@@ -6,8 +7,15 @@ import { · 1 unmodified line
67 credentialProviderValidator,
78 credentialScopeValidator,
89 jsonObjectValidator,
10+ ownerScopeTypeValidator,
911 } from "../../src/database/validators";
10−import { asRecord } from "../../src/lib/object";
12+
13+const recordSchema = z.record(z.unknown());
14+
15+function toRecordValue(value: unknown): Record<string, unknown> {
16+ const parsed = recordSchema.safeParse(value);
17+ return parsed.success ? parsed.data : {};
18+}
1119
1220 function scopeKeyForCredential(scope: "workspace" | "actor", actorId?: string): string {
1321 if (scope === "workspace") {
@@ -26,6 +34,7 @@ export const upsertCredential = internalMutation({ · 12 unmodified lines
2634 args: {
2735 id: v.optional(v.string()),
2836 workspaceId: v.id("workspaces"),
37+ ownerScopeType: v.optional(ownerScopeTypeValidator),
2938 sourceKey: v.string(),
3039 scope: credentialScopeValidator,
3140 actorId: v.optional(v.string()),
@@ -35,20 +44,39 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines
3544 },
3645 handler: async (ctx, args) => {
3746 const now = Date.now();
47+ const ownerScopeType = args.ownerScopeType ?? "workspace";
48+ const workspace = await ctx.db.get(args.workspaceId);
49+ if (!workspace) {
50+ throw new Error(`Workspace not found: ${args.workspaceId}`);
51+ }
52+ const organizationId = workspace.organizationId;
53+ const ownerWorkspaceId = ownerScopeType === "workspace" ? args.workspaceId : undefined;
54+
3855 const actorId = args.scope === "actor" ? args.actorId?.trim() : undefined;
3956 const scopeKey = scopeKeyForCredential(args.scope, actorId);
40− const submittedSecret = asRecord(args.secretJson);
57+ const submittedSecret = toRecordValue(args.secretJson);
4158 const hasSubmittedSecret = Object.keys(submittedSecret).length > 0;
4259
43− const existing = await ctx.db
44− .query("sourceCredentials")
45− .withIndex("by_workspace_source_scope_key", (q) =>
46− q
47− .eq("workspaceId", args.workspaceId)
48− .eq("sourceKey", args.sourceKey)
49− .eq("scopeKey", scopeKey),
50− )
51− .unique();
60+ const existing = ownerScopeType === "workspace"
61+ ? await ctx.db
62+ .query("sourceCredentials")
63+ .withIndex("by_workspace_source_scope_key", (q) =>
64+ q
65+ .eq("workspaceId", args.workspaceId)
66+ .eq("sourceKey", args.sourceKey)
67+ .eq("scopeKey", scopeKey),
68+ )
69+ .unique()
70+ : await ctx.db
71+ .query("sourceCredentials")
72+ .withIndex("by_organization_owner_source_scope_key", (q) =>
73+ q
74+ .eq("organizationId", organizationId)
75+ .eq("ownerScopeType", "organization")
76+ .eq("sourceKey", args.sourceKey)
77+ .eq("scopeKey", scopeKey),
78+ )
79+ .unique();
5280
5381 let requestedId = args.id?.trim() || "";
5482 if (requestedId.startsWith("bind_")) {
@@ -56,31 +84,42 @@ export const upsertCredential = internalMutation({ · 1 unmodified line
5684 .query("sourceCredentials")
5785 .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId))
5886 .unique();
59− if (binding && binding.workspaceId === args.workspaceId) {
87+ const sameOwner = binding
88+ && binding.organizationId === organizationId
89+ && binding.ownerScopeType === ownerScopeType
90+ && (ownerScopeType === "organization" || binding.workspaceId === args.workspaceId);
91+ if (sameOwner) {
6092 requestedId = binding.credentialId;
6193 }
6294 }
6395
6496 const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`;
6597
66− const linkedRows = await ctx.db
67− .query("sourceCredentials")
68− .withIndex("by_workspace_credential", (q) =>
69− q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId),
70− )
71− .collect();
98+ const linkedRows = ownerScopeType === "workspace"
99+ ? await ctx.db
100+ .query("sourceCredentials")
101+ .withIndex("by_workspace_credential", (q) =>
102+ q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId),
103+ )
104+ .collect()
105+ : await ctx.db
106+ .query("sourceCredentials")
107+ .withIndex("by_organization_owner_credential", (q) =>
108+ q.eq("organizationId", organizationId).eq("ownerScopeType", "organization").eq("credentialId", connectionId),
109+ )
110+ .collect();
72111 const exemplar = linkedRows[0] ?? existing ?? null;
73112
74113 const provider = args.provider ?? exemplar?.provider ?? "local-convex";
75− const fallbackSecret = asRecord(exemplar?.secretJson);
114+ const fallbackSecret = toRecordValue(exemplar?.secretJson);
76115 const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;
77116 if (Object.keys(finalSecret).length === 0) {
78117 throw new Error("Credential values are required");
79118 }
80119
81120 const overridesJson = args.overridesJson === undefined
82− ? asRecord(existing?.overridesJson)
83− : asRecord(args.overridesJson);
121+ ? toRecordValue(existing?.overridesJson)
122+ : toRecordValue(args.overridesJson);
84123
85124 const boundAuthFingerprint = await computeBoundAuthFingerprint(
86125 ctx,
@@ -100,6 +139,9 @@ export const upsertCredential = internalMutation({ · 13 unmodified lines
100139
101140 if (existing) {
102141 await ctx.db.patch(existing._id, {
142+ ownerScopeType,
143+ organizationId,
144+ workspaceId: ownerWorkspaceId,
103145 credentialId: connectionId,
104146 provider,
105147 secretJson: finalSecret,
@@ -113,7 +155,9 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines
113155 await ctx.db.insert("sourceCredentials", {
114156 bindingId: `bind_${crypto.randomUUID()}`,
115157 credentialId: connectionId,
116− workspaceId: args.workspaceId,
158+ ownerScopeType,
159+ organizationId,
160+ workspaceId: ownerWorkspaceId,
117161 sourceKey: args.sourceKey,
118162 scope: args.scope,
119163 scopeKey,
@@ -127,15 +171,26 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines
127171 });
128172 }
129173
130− const updated = await ctx.db
131− .query("sourceCredentials")
132− .withIndex("by_workspace_source_scope_key", (q) =>
133− q
134− .eq("workspaceId", args.workspaceId)
135− .eq("sourceKey", args.sourceKey)
136− .eq("scopeKey", scopeKey),
137− )
138− .unique();
174+ const updated = ownerScopeType === "workspace"
175+ ? await ctx.db
176+ .query("sourceCredentials")
177+ .withIndex("by_workspace_source_scope_key", (q) =>
178+ q
179+ .eq("workspaceId", args.workspaceId)
180+ .eq("sourceKey", args.sourceKey)
181+ .eq("scopeKey", scopeKey),
182+ )
183+ .unique()
184+ : await ctx.db
185+ .query("sourceCredentials")
186+ .withIndex("by_organization_owner_source_scope_key", (q) =>
187+ q
188+ .eq("organizationId", organizationId)
189+ .eq("ownerScopeType", "organization")
190+ .eq("sourceKey", args.sourceKey)
191+ .eq("scopeKey", scopeKey),
192+ )
193+ .unique();
139194
140195 if (!updated) {
141196 throw new Error("Failed to read upserted credential");
@@ -148,11 +203,30 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines
148203 export const listCredentials = internalQuery({
149204 args: { workspaceId: v.id("workspaces") },
150205 handler: async (ctx, args) => {
151− const docs = await ctx.db
152− .query("sourceCredentials")
153− .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
154− .order("desc")
155− .collect();
206+ const workspace = await ctx.db.get(args.workspaceId);
207+ if (!workspace) {
208+ return [];
209+ }
210+
211+ const [workspaceDocs, organizationDocs] = await Promise.all([
212+ ctx.db
213+ .query("sourceCredentials")
214+ .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
215+ .order("desc")
216+ .collect(),
217+ ctx.db
218+ .query("sourceCredentials")
219+ .withIndex("by_organization_owner_created", (q) =>
220+ q.eq("organizationId", workspace.organizationId).eq("ownerScopeType", "organization"),
221+ )
222+ .order("desc")
223+ .collect(),
224+ ]);
225+
226+ const docs = [...workspaceDocs, ...organizationDocs]
227+ .filter((doc, index, entries) => entries.findIndex((candidate) => candidate.bindingId === doc.bindingId) === index)
228+ .sort((a, b) => b.createdAt - a.createdAt);
229+
156230 return docs.map(mapCredential);
157231 },
158232 });
@@ -181,35 +255,62 @@ export const resolveCredential = internalQuery({ · 22 unmodified lines
181255 actorId: v.optional(v.string()),
182256 },
183257 handler: async (ctx, args) => {
184− if (args.scope === "actor") {
185− const actorId = args.actorId?.trim() || "";
186− if (!actorId) {
187− return null;
188− }
258+ const workspace = await ctx.db.get(args.workspaceId);
259+ if (!workspace) {
260+ return null;
261+ }
262+
263+ const workspaceScopeKey = scopeKeyForCredential("workspace");
264+ const actorId = args.actorId?.trim() || "";
265+ const actorScopeKey = actorId ? scopeKeyForCredential("actor", actorId) : null;
189266
190− const actorDoc = await ctx.db
267+ const tryWorkspaceOwned = async (scopeKey: string) => {
268+ return await ctx.db
191269 .query("sourceCredentials")
192270 .withIndex("by_workspace_source_scope_key", (q) =>
193271 q
194272 .eq("workspaceId", args.workspaceId)
195273 .eq("sourceKey", args.sourceKey)
196− .eq("scopeKey", scopeKeyForCredential("actor", actorId)),
274+ .eq("scopeKey", scopeKey),
197275 )
198276 .unique();
277+ };
278+
279+ const tryOrganizationOwned = async (scopeKey: string) => {
280+ return await ctx.db
281+ .query("sourceCredentials")
282+ .withIndex("by_organization_owner_source_scope_key", (q) =>
283+ q
284+ .eq("organizationId", workspace.organizationId)
285+ .eq("ownerScopeType", "organization")
286+ .eq("sourceKey", args.sourceKey)
287+ .eq("scopeKey", scopeKey),
288+ )
289+ .unique();
290+ };
291+
292+ if (args.scope === "actor" && actorScopeKey) {
293+ const workspaceActorDoc = await tryWorkspaceOwned(actorScopeKey);
294+ if (workspaceActorDoc) {
295+ return mapCredential(workspaceActorDoc as never);
296+ }
297+
298+ const organizationActorDoc = await tryOrganizationOwned(actorScopeKey);
299+ if (organizationActorDoc) {
300+ return mapCredential(organizationActorDoc as never);
301+ }
302+ }
303+
304+ const workspaceDoc = await tryWorkspaceOwned(workspaceScopeKey);
305+ if (workspaceDoc) {
306+ return mapCredential(workspaceDoc as never);
307+ }
199308
200− return actorDoc ? mapCredential(actorDoc) : null;
309+ const organizationDoc = await tryOrganizationOwned(workspaceScopeKey);
310+ if (organizationDoc) {
311+ return mapCredential(organizationDoc as never);
201312 }
202313
203− const workspaceDoc = await ctx.db
204− .query("sourceCredentials")
205− .withIndex("by_workspace_source_scope_key", (q) =>
206− q
207− .eq("workspaceId", args.workspaceId)
208− .eq("sourceKey", args.sourceKey)
209− .eq("scopeKey", scopeKeyForCredential("workspace")),
210− )
211− .unique();
212−
213− return workspaceDoc ? mapCredential(workspaceDoc) : null;
314+ return null;
214315 },
215316 });
executor/packages/database/convex/database/policies.ts+75−13
@@ -2,7 +2,26 @@ import { v } from "convex/values"; · 1 unmodified line
22 import { internalMutation, internalQuery } from "../_generated/server";
33 import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog";
44 import { mapPolicy } from "../../src/database/mappers";
5−import { policyDecisionValidator } from "../../src/database/validators";
5+import {
6+ policyApprovalModeValidator,
7+ policyDecisionValidator,
8+ policyEffectValidator,
9+ policyMatchTypeValidator,
10+ policyScopeTypeValidator,
11+} from "../../src/database/validators";
12+
13+function parseDecision(value: "allow" | "require_approval" | "deny" | undefined): {
14+ effect: "allow" | "deny";
15+ approvalMode: "inherit" | "auto" | "required";
16+} {
17+ if (value === "deny") {
18+ return { effect: "deny", approvalMode: "inherit" };
19+ }
20+ if (value === "require_approval") {
21+ return { effect: "allow", approvalMode: "required" };
22+ }
23+ return { effect: "allow", approvalMode: "auto" };
24+}
625
726 export const listRuntimeTargets = internalQuery({
827 args: {},
@@ -15,15 +34,34 @@ export const upsertAccessPolicy = internalMutation({ · 6 unmodified lines
1534 args: {
1635 id: v.optional(v.string()),
1736 workspaceId: v.id("workspaces"),
37+ scopeType: v.optional(policyScopeTypeValidator),
1838 actorId: v.optional(v.string()),
39+ targetActorId: v.optional(v.string()),
1940 clientId: v.optional(v.string()),
20− toolPathPattern: v.string(),
21− decision: policyDecisionValidator,
41+ toolPathPattern: v.optional(v.string()),
42+ resourcePattern: v.optional(v.string()),
43+ matchType: v.optional(policyMatchTypeValidator),
44+ decision: v.optional(policyDecisionValidator),
45+ effect: v.optional(policyEffectValidator),
46+ approvalMode: v.optional(policyApprovalModeValidator),
2247 priority: v.optional(v.number()),
2348 },
2449 handler: async (ctx, args) => {
2550 const now = Date.now();
2651 const policyId = args.id ?? `policy_${crypto.randomUUID()}`;
52+ const workspace = await ctx.db.get(args.workspaceId);
53+ if (!workspace) {
54+ throw new Error(`Workspace not found: ${args.workspaceId}`);
55+ }
56+
57+ const scopeType = args.scopeType ?? "workspace";
58+ const targetActorId = (args.targetActorId ?? args.actorId)?.trim() || undefined;
59+ const resourcePattern = (args.resourcePattern ?? args.toolPathPattern ?? "*").trim() || "*";
60+ const matchType = args.matchType ?? "glob";
61+ const decisionFields = parseDecision(args.decision);
62+ const effect = args.effect ?? decisionFields.effect;
63+ const approvalMode = args.approvalMode ?? decisionFields.approvalMode;
64+
2765 const existing = await ctx.db
2866 .query("accessPolicies")
2967 .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))
@@ -31,22 +69,32 @@ export const upsertAccessPolicy = internalMutation({ · 1 unmodified line
3169
3270 if (existing) {
3371 await ctx.db.patch(existing._id, {
34− workspaceId: args.workspaceId,
35− actorId: args.actorId?.trim() || undefined,
72+ scopeType,
73+ organizationId: workspace.organizationId,
74+ workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
75+ targetActorId,
3676 clientId: args.clientId?.trim() || undefined,
37− toolPathPattern: args.toolPathPattern,
38− decision: args.decision,
77+ resourceType: "tool_path",
78+ resourcePattern,
79+ matchType,
80+ effect,
81+ approvalMode,
3982 priority: args.priority ?? 100,
4083 updatedAt: now,
4184 });
4285 } else {
4386 await ctx.db.insert("accessPolicies", {
4487 policyId,
45− workspaceId: args.workspaceId,
46− actorId: args.actorId?.trim() || undefined,
88+ scopeType,
89+ organizationId: workspace.organizationId,
90+ workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
91+ targetActorId,
4792 clientId: args.clientId?.trim() || undefined,
48− toolPathPattern: args.toolPathPattern,
49− decision: args.decision,
93+ resourceType: "tool_path",
94+ resourcePattern,
95+ matchType,
96+ effect,
97+ approvalMode,
5098 priority: args.priority ?? 100,
5199 createdAt: now,
52100 updatedAt: now,
@@ -67,12 +115,26 @@ export const upsertAccessPolicy = internalMutation({ · 14 unmodified lines
67115 export const listAccessPolicies = internalQuery({
68116 args: { workspaceId: v.id("workspaces") },
69117 handler: async (ctx, args) => {
70− const docs = await ctx.db
118+ const workspace = await ctx.db.get(args.workspaceId);
119+ if (!workspace) {
120+ return [];
121+ }
122+
123+ const workspaceDocs = await ctx.db
71124 .query("accessPolicies")
72125 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
73126 .collect();
74127
75− return docs
128+ const organizationDocs = await ctx.db
129+ .query("accessPolicies")
130+ .withIndex("by_organization_created", (q) => q.eq("organizationId", workspace.organizationId))
131+ .collect();
132+
133+ const all = [...workspaceDocs, ...organizationDocs.filter((doc) => doc.scopeType === "organization")].filter((doc, index, entries) => {
134+ return entries.findIndex((candidate) => candidate.policyId === doc.policyId) === index;
135+ });
136+
137+ return all
76138 .sort((a, b) => {
77139 if (a.priority !== b.priority) {
78140 return b.priority - a.priority;
executor/packages/database/convex/database/task_events.ts+1−2
@@ -2,7 +2,6 @@ import { v } from "convex/values"; · 1 unmodified line
22 import { internalMutation, internalQuery } from "../_generated/server";
33 import { getTaskDoc, mapTaskEvent } from "../../src/database/readers";
44 import { jsonObjectValidator } from "../../src/database/validators";
5−import { asRecord } from "../../src/lib/object";
65
76 export const createTaskEvent = internalMutation({
87 args: {
@@ -30,7 +29,7 @@ export const createTaskEvent = internalMutation({ · 21 unmodified lines
3029 taskId: args.taskId,
3130 eventName: args.eventName,
3231 type: args.type,
33− payload: asRecord(args.payload),
32+ payload: args.payload,
3433 createdAt,
3534 });
3635
executor/packages/database/convex/database/tasks.ts+4−2
@@ -3,7 +3,6 @@ import { internalMutation, internalQuery } from "../_generated/server"; · 2 unmodified lines
33 import { mapTask } from "../../src/database/mappers";
44 import { getTaskDoc } from "../../src/database/readers";
55 import { completedTaskStatusValidator, jsonObjectValidator } from "../../src/database/validators";
6−import { asRecord } from "../../src/lib/object";
76 import { DEFAULT_TASK_TIMEOUT_MS } from "../../src/task/constants";
87 import { isTerminalTaskStatus } from "../../src/task/status";
98
@@ -25,6 +24,9 @@ export const createTask = internalMutation({ · 15 unmodified lines
2524 }
2625
2726 const now = Date.now();
27+ const metadata = args.metadata === undefined
28+ ? {}
29+ : args.metadata;
2830 await ctx.db.insert("tasks", {
2931 taskId: args.id,
3032 code: args.code,
@@ -34,7 +36,7 @@ export const createTask = internalMutation({ · 3 unmodified lines
3436 clientId: args.clientId?.trim() || undefined,
3537 status: "queued",
3638 timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS,
37− metadata: asRecord(args.metadata),
39+ metadata,
3840 createdAt: now,
3941 updatedAt: now,
4042 });
executor/packages/database/convex/database/tool_sources.ts+64−16
@@ -6,12 +6,13 @@ import { · 5 unmodified lines
66 normalizeSourceAuthFingerprint,
77 } from "../../src/database/mappers";
88 import { normalizeToolSourceConfig } from "../../src/database/tool_source_config";
9−import { jsonObjectValidator, toolSourceTypeValidator } from "../../src/database/validators";
9+import { jsonObjectValidator, ownerScopeTypeValidator, toolSourceTypeValidator } from "../../src/database/validators";
1010
1111 export const upsertToolSource = internalMutation({
1212 args: {
1313 id: v.optional(v.string()),
1414 workspaceId: v.id("workspaces"),
15+ ownerScopeType: v.optional(ownerScopeTypeValidator),
1516 name: v.string(),
1617 type: toolSourceTypeValidator,
1718 config: jsonObjectValidator,
@@ -20,6 +21,14 @@ export const upsertToolSource = internalMutation({ · 2 unmodified lines
2021 handler: async (ctx, args) => {
2122 const now = Date.now();
2223 const sourceId = args.id ?? `src_${crypto.randomUUID()}`;
24+ const ownerScopeType = args.ownerScopeType ?? "workspace";
25+ const workspace = await ctx.db.get(args.workspaceId);
26+ if (!workspace) {
27+ throw new Error(`Workspace not found: ${args.workspaceId}`);
28+ }
29+ const organizationId = workspace.organizationId;
30+ const ownerWorkspaceId = ownerScopeType === "workspace" ? args.workspaceId : undefined;
31+
2332 const configResult = normalizeToolSourceConfig(args.type, args.config);
2433 if (configResult.isErr()) {
2534 throw new Error(configResult.error.message);
@@ -32,19 +41,28 @@ export const upsertToolSource = internalMutation({ · 6 unmodified lines
3241 .query("toolSources")
3342 .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
3443 .unique(),
35− ctx.db
36− .query("toolSources")
37− .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name))
38− .unique(),
44+ ownerScopeType === "workspace"
45+ ? ctx.db
46+ .query("toolSources")
47+ .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name))
48+ .unique()
49+ : ctx.db
50+ .query("toolSources")
51+ .withIndex("by_organization_owner_name", (q) =>
52+ q.eq("organizationId", organizationId).eq("ownerScopeType", "organization").eq("name", args.name),
53+ )
54+ .unique(),
3955 ]);
4056
4157 if (conflict && conflict.sourceId !== sourceId) {
42− throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`);
58+ throw new Error(`Tool source name '${args.name}' already exists in this scope`);
4359 }
4460
4561 if (existing) {
4662 await ctx.db.patch(existing._id, {
47− workspaceId: args.workspaceId,
63+ ownerScopeType,
64+ organizationId,
65+ workspaceId: ownerWorkspaceId,
4866 name: args.name,
4967 type: args.type,
5068 config,
@@ -56,7 +74,9 @@ export const upsertToolSource = internalMutation({ · 5 unmodified lines
5674 } else {
5775 await ctx.db.insert("toolSources", {
5876 sourceId,
59− workspaceId: args.workspaceId,
77+ ownerScopeType,
78+ organizationId,
79+ workspaceId: ownerWorkspaceId,
6080 name: args.name,
6181 type: args.type,
6282 config,
@@ -82,11 +102,30 @@ export const upsertToolSource = internalMutation({ · 19 unmodified lines
82102 export const listToolSources = internalQuery({
83103 args: { workspaceId: v.id("workspaces") },
84104 handler: async (ctx, args) => {
85− const docs = await ctx.db
86− .query("toolSources")
87− .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))
88− .order("desc")
89− .collect();
105+ const workspace = await ctx.db.get(args.workspaceId);
106+ if (!workspace) {
107+ return [];
108+ }
109+
110+ const [workspaceDocs, organizationDocs] = await Promise.all([
111+ ctx.db
112+ .query("toolSources")
113+ .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))
114+ .order("desc")
115+ .collect(),
116+ ctx.db
117+ .query("toolSources")
118+ .withIndex("by_organization_owner_updated", (q) =>
119+ q.eq("organizationId", workspace.organizationId).eq("ownerScopeType", "organization"),
120+ )
121+ .order("desc")
122+ .collect(),
123+ ]);
124+
125+ const docs = [...workspaceDocs, ...organizationDocs]
126+ .filter((doc, index, entries) => entries.findIndex((candidate) => candidate.sourceId === doc.sourceId) === index)
127+ .sort((a, b) => b.updatedAt - a.updatedAt);
128+
90129 return docs.map(mapSource);
91130 },
92131 });
@@ -94,20 +133,29 @@ export const listToolSources = internalQuery({ · 1 unmodified line
94133 export const deleteToolSource = internalMutation({
95134 args: { workspaceId: v.id("workspaces"), sourceId: v.string() },
96135 handler: async (ctx, args) => {
136+ const workspace = await ctx.db.get(args.workspaceId);
137+ if (!workspace) {
138+ return false;
139+ }
140+
97141 const doc = await ctx.db
98142 .query("toolSources")
99143 .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId))
100144 .unique();
101145
102− if (!doc || doc.workspaceId !== args.workspaceId) {
146+ if (!doc || doc.organizationId !== workspace.organizationId) {
147+ return false;
148+ }
149+
150+ if (doc.ownerScopeType === "workspace" && doc.workspaceId !== args.workspaceId) {
103151 return false;
104152 }
105153
106154 const sourceKey = `source:${args.sourceId}`;
107155 const bindings = await ctx.db
108156 .query("sourceCredentials")
109− .withIndex("by_workspace_source_scope_key", (q) =>
110− q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey),
157+ .withIndex("by_organization_source", (q) =>
158+ q.eq("organizationId", workspace.organizationId).eq("sourceKey", sourceKey),
111159 )
112160 .collect();
113161
executor/packages/database/convex/migrations.ts+4−2
@@ -127,9 +127,11 @@ export const cleanupTaskEmptyStringSentinels = migrations.define({ · 126 unmodified lines
127127 export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({
128128 table: "accessPolicies",
129129 migrateOne: async (_ctx, policy) => {
130+ const policyRecord = policy as Record<string, unknown>;
130131 const patch: Record<string, undefined> = {};
131− if (policy.actorId === "") patch.actorId = undefined;
132− if (policy.clientId === "") patch.clientId = undefined;
132+ if (Reflect.get(policyRecord, "actorId") === "") patch["actorId"] = undefined;
133+ if (Reflect.get(policyRecord, "targetActorId") === "") patch["targetActorId"] = undefined;
134+ if (policy.clientId === "") patch["clientId"] = undefined;
133135 if (Object.keys(patch).length > 0) return patch;
134136 },
135137 });
executor/packages/database/convex/schema.ts+46−18
@@ -56,7 +56,12 @@ const toolCallStatus = v.union( · 55 unmodified lines
5656 v.literal("denied"),
5757 );
5858 const toolApprovalMode = v.union(v.literal("auto"), v.literal("required"));
59−const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
59+const policyScopeType = v.union(v.literal("organization"), v.literal("workspace"));
60+const policyResourceType = v.literal("tool_path");
61+const policyMatchType = v.union(v.literal("glob"), v.literal("exact"));
62+const policyEffect = v.union(v.literal("allow"), v.literal("deny"));
63+const policyApprovalMode = v.union(v.literal("inherit"), v.literal("auto"), v.literal("required"));
64+const ownerScopeType = v.union(v.literal("organization"), v.literal("workspace"));
6065 const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
6166 const credentialProvider = v.union(
6267 v.literal("local-convex"),
@@ -319,36 +324,46 @@ export default defineSchema({ · 256 unmodified lines
319324 })
320325 .index("by_task_sequence", ["taskId", "sequence"]),
321326
322− // Workspace access policy rules used by the approval / tool firewall.
323− // `toolPathPattern` is matched against tool paths and combined with actor/client selectors.
324− // Higher `priority` wins when multiple policies match.
327+ // Workspace + organization access policy rules used by the approval / tool firewall.
328+ // Rules can target specific actors and clients, and can match tool paths either exactly
329+ // or with a glob pattern.
325330 accessPolicies: defineTable({
326331 policyId: v.string(), // domain ID: policy_<uuid>
327− workspaceId: v.id("workspaces"),
328− actorId: v.optional(v.string()), // account._id or anon_<uuid>
332+ scopeType: policyScopeType,
333+ organizationId: v.id("organizations"),
334+ workspaceId: v.optional(v.id("workspaces")),
335+ targetActorId: v.optional(v.string()), // account._id or anon_<uuid>
329336 clientId: v.optional(v.string()), // client label: "web", "mcp", etc.
330− toolPathPattern: v.string(),
331− decision: policyDecision,
337+ resourceType: policyResourceType,
338+ resourcePattern: v.string(),
339+ matchType: policyMatchType,
340+ effect: policyEffect,
341+ approvalMode: policyApprovalMode,
332342 priority: v.number(),
333343 createdAt: v.number(),
334344 updatedAt: v.number(),
335345 })
336346 .index("by_policy_id", ["policyId"])
337− .index("by_workspace_created", ["workspaceId", "createdAt"]),
347+ .index("by_workspace_created", ["workspaceId", "createdAt"])
348+ .index("by_organization_created", ["organizationId", "createdAt"]),
338349
339350 // Stored credentials for tool sources.
340351 //
341352 // A single credential "connection" (credentialId) can have multiple rows to support
342− // different bindings (workspace-wide and per-actor). `bindingId` exists as a stable handle
353+ // different bindings (workspace-wide and per-actor), and can be owned by either a
354+ // workspace or an organization.
355+ // `bindingId` exists as a stable handle
343356 // for UI/API operations that need an id before the connection id is known.
344357 //
345358 // Primary access patterns:
346− // - Resolve by (workspaceId, sourceKey, scopeKey).
347− // - List all credentials in workspace by createdAt.
359+ // - Resolve by (workspaceId|organizationId, sourceKey, scopeKey).
360+ // - List effective credentials for a workspace by createdAt.
348361 sourceCredentials: defineTable({
349362 bindingId: v.string(), // domain ID: bind_<uuid>
350363 credentialId: v.string(), // domain ID: conn_<uuid>
351− workspaceId: v.id("workspaces"),
364+ ownerScopeType: ownerScopeType,
365+ organizationId: v.id("organizations"),
366+ workspaceId: v.optional(v.id("workspaces")),
352367 sourceKey: v.string(),
353368 scope: credentialScope,
354369 scopeKey: v.string(), // "workspace" or actor:<actorId>
@@ -361,21 +376,30 @@ export default defineSchema({ · 6 unmodified lines
361376 updatedAt: v.number(),
362377 })
363378 .index("by_workspace_created", ["workspaceId", "createdAt"])
379+ .index("by_organization_created", ["organizationId", "createdAt"])
380+ .index("by_organization_owner_created", ["organizationId", "ownerScopeType", "createdAt"])
364381 .index("by_workspace_source_scope_key", ["workspaceId", "sourceKey", "scopeKey"])
382+ .index("by_organization_source_scope_key", ["organizationId", "sourceKey", "scopeKey"])
383+ .index("by_organization_owner_source_scope_key", ["organizationId", "ownerScopeType", "sourceKey", "scopeKey"])
365384 .index("by_workspace_credential", ["workspaceId", "credentialId"])
385+ .index("by_organization_credential", ["organizationId", "credentialId"])
386+ .index("by_organization_owner_credential", ["organizationId", "ownerScopeType", "credentialId"])
387+ .index("by_organization_source", ["organizationId", "sourceKey"])
366388 .index("by_binding_id", ["bindingId"]),
367389
368− // Configured tool sources for a workspace (MCP servers, OpenAPI sources, GraphQL sources).
390+ // Configured tool sources for a workspace/organization (MCP servers, OpenAPI sources, GraphQL sources).
369391 // `specHash` enables cache invalidation when the definition changes.
370392 // `authFingerprint` is used to determine whether cached tool materialization is still valid.
371393 //
372394 // Primary access patterns:
373395 // - Resolve by domain source id.
374− // - List sources by workspace, sorted by updatedAt.
375− // - Enforce name uniqueness per workspace.
396+ // - List effective sources by workspace, sorted by updatedAt.
397+ // - Enforce name uniqueness per owner scope.
376398 toolSources: defineTable({
377399 sourceId: v.string(), // domain ID: src_<uuid>
378− workspaceId: v.id("workspaces"),
400+ ownerScopeType: ownerScopeType,
401+ organizationId: v.id("organizations"),
402+ workspaceId: v.optional(v.id("workspaces")),
379403 name: v.string(),
380404 type: toolSourceType,
381405 config: jsonObject,
@@ -387,7 +411,11 @@ export default defineSchema({ · 5 unmodified lines
387411 })
388412 .index("by_source_id", ["sourceId"])
389413 .index("by_workspace_updated", ["workspaceId", "updatedAt"])
390− .index("by_workspace_name", ["workspaceId", "name"]),
414+ .index("by_organization_updated", ["organizationId", "updatedAt"])
415+ .index("by_organization_owner_updated", ["organizationId", "ownerScopeType", "updatedAt"])
416+ .index("by_workspace_name", ["workspaceId", "name"])
417+ .index("by_organization_name", ["organizationId", "name"])
418+ .index("by_organization_owner_name", ["organizationId", "ownerScopeType", "name"]),
391419
392420 // Cached OpenAPI spec blobs stored in Convex storage.
393421 // (specUrl, version) uniquely identifies a stored spec payload.
executor/packages/database/convex/toolRegistry.ts+62−0
@@ -176,6 +176,68 @@ export const finishBuild = internalMutation({ · 175 unmodified lines
176176 },
177177 });
178178
179+export const pruneBuilds = internalMutation({
180+ args: {
181+ workspaceId: v.id("workspaces"),
182+ maxRetainedBuilds: v.optional(v.number()),
183+ },
184+ handler: async (ctx, args) => {
185+ const maxRetainedBuilds = Math.max(1, Math.min(10, Math.floor(args.maxRetainedBuilds ?? 2)));
186+ const state = await ctx.db
187+ .query("workspaceToolRegistryState")
188+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
189+ .unique();
190+
191+ const protectedBuildIds = new Set<string>();
192+ if (state?.readyBuildId) protectedBuildIds.add(state.readyBuildId);
193+ if (state?.buildingBuildId) protectedBuildIds.add(state.buildingBuildId);
194+
195+ const toolEntries = await ctx.db
196+ .query("workspaceToolRegistry")
197+ .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId))
198+ .collect();
199+
200+ const namespaceEntries = await ctx.db
201+ .query("workspaceToolNamespaces")
202+ .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId))
203+ .collect();
204+
205+ const latestCreatedAtByBuild = new Map<string, number>();
206+ for (const entry of toolEntries) {
207+ const current = latestCreatedAtByBuild.get(entry.buildId) ?? 0;
208+ if (entry.createdAt > current) {
209+ latestCreatedAtByBuild.set(entry.buildId, entry.createdAt);
210+ }
211+ }
212+ for (const entry of namespaceEntries) {
213+ const current = latestCreatedAtByBuild.get(entry.buildId) ?? 0;
214+ if (entry.createdAt > current) {
215+ latestCreatedAtByBuild.set(entry.buildId, entry.createdAt);
216+ }
217+ }
218+
219+ const buildIdsByRecency = [...latestCreatedAtByBuild.entries()]
220+ .sort((a, b) => b[1] - a[1])
221+ .map(([buildId]) => buildId);
222+
223+ const retained = new Set<string>(buildIdsByRecency.slice(0, maxRetainedBuilds));
224+ for (const protectedBuildId of protectedBuildIds) {
225+ retained.add(protectedBuildId);
226+ }
227+
228+ for (const entry of toolEntries) {
229+ if (!retained.has(entry.buildId)) {
230+ await ctx.db.delete(entry._id);
231+ }
232+ }
233+ for (const entry of namespaceEntries) {
234+ if (!retained.has(entry.buildId)) {
235+ await ctx.db.delete(entry._id);
236+ }
237+ }
238+ },
239+});
240+
179241 export const getToolByPath = internalQuery({
180242 args: {
181243 workspaceId: v.id("workspaces"),
executor/packages/database/convex/workspace.ts+51−1
@@ -2,11 +2,17 @@ import { v } from "convex/values"; · 1 unmodified line
22 import { internal } from "./_generated/api";
33 import { mutation } from "./_generated/server";
44 import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders";
5+import { getOrganizationMembership, isAdminRole } from "../../core/src/identity";
56 import {
67 credentialProviderValidator,
78 credentialScopeValidator,
89 jsonObjectValidator,
10+ ownerScopeTypeValidator,
11+ policyApprovalModeValidator,
912 policyDecisionValidator,
13+ policyEffectValidator,
14+ policyMatchTypeValidator,
15+ policyScopeTypeValidator,
1016 toolSourceTypeValidator,
1117 } from "../src/database/validators";
1218
@@ -43,13 +49,24 @@ export const upsertAccessPolicy = workspaceMutation({ · 30 unmodified lines
4349 requireAdmin: true,
4450 args: {
4551 id: v.optional(v.string()),
52+ scopeType: v.optional(policyScopeTypeValidator),
4653 actorId: v.optional(v.string()),
4754 clientId: v.optional(v.string()),
4855 toolPathPattern: v.string(),
49− decision: policyDecisionValidator,
56+ decision: v.optional(policyDecisionValidator),
57+ matchType: v.optional(policyMatchTypeValidator),
58+ effect: v.optional(policyEffectValidator),
59+ approvalMode: v.optional(policyApprovalModeValidator),
5060 priority: v.optional(v.number()),
5161 },
5262 handler: async (ctx, args) => {
63+ if (args.scopeType === "organization") {
64+ const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
65+ if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
66+ throw new Error("Only organization admins can create organization-level policies");
67+ }
68+ }
69+
5370 return await ctx.runMutation(internal.database.upsertAccessPolicy, {
5471 ...args,
5572 workspaceId: ctx.workspaceId,
@@ -70,6 +87,7 @@ export const upsertCredential = workspaceMutation({ · 14 unmodified lines
7087 requireAdmin: true,
7188 args: {
7289 id: v.optional(v.string()),
90+ ownerScopeType: v.optional(ownerScopeTypeValidator),
7391 sourceKey: v.string(),
7492 scope: credentialScopeValidator,
7593 actorId: v.optional(v.string()),
@@ -78,6 +96,13 @@ export const upsertCredential = workspaceMutation({ · 2 unmodified lines
7896 overridesJson: v.optional(jsonObjectValidator),
7997 },
8098 handler: async (ctx, args) => {
99+ if (args.ownerScopeType === "organization") {
100+ const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
101+ if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
102+ throw new Error("Only organization admins can manage organization-level credentials");
103+ }
104+ }
105+
81106 return await ctx.runMutation(internal.database.upsertCredential, {
82107 ...args,
83108 workspaceId: ctx.workspaceId,
@@ -122,12 +147,20 @@ export const upsertToolSource = workspaceMutation({ · 38 unmodified lines
122147 requireAdmin: true,
123148 args: {
124149 id: v.optional(v.string()),
150+ ownerScopeType: v.optional(ownerScopeTypeValidator),
125151 name: v.string(),
126152 type: toolSourceTypeValidator,
127153 config: jsonObjectValidator,
128154 enabled: v.optional(v.boolean()),
129155 },
130156 handler: async (ctx, args) => {
157+ if (args.ownerScopeType === "organization") {
158+ const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
159+ if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
160+ throw new Error("Only organization admins can manage organization-level tool sources");
161+ }
162+ }
163+
131164 return await ctx.runMutation(internal.database.upsertToolSource, {
132165 ...args,
133166 workspaceId: ctx.workspaceId,
@@ -150,6 +183,23 @@ export const deleteToolSource = workspaceMutation({ · 16 unmodified lines
150183 sourceId: v.string(),
151184 },
152185 handler: async (ctx, args) => {
186+ const sources = await ctx.runQuery(internal.database.listToolSources, {
187+ workspaceId: ctx.workspaceId,
188+ });
189+ let source: { id: string; ownerScopeType?: "organization" | "workspace" } | undefined;
190+ for (const entry of sources as Array<{ id: string; ownerScopeType?: "organization" | "workspace" }>) {
191+ if (entry.id === args.sourceId) {
192+ source = entry;
193+ break;
194+ }
195+ }
196+ if (source?.ownerScopeType === "organization") {
197+ const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
198+ if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
199+ throw new Error("Only organization admins can delete organization-level tool sources");
200+ }
201+ }
202+
153203 return await ctx.runMutation(internal.database.deleteToolSource, {
154204 ...args,
155205 workspaceId: ctx.workspaceId,
executor/packages/database/src/accounts/delete-current-account.ts+24−0
@@ -135,6 +135,30 @@ async function deleteOrganizationData( · 134 unmodified lines
135135 await ctx.db.delete(seatState._id);
136136 }
137137
138+ const policyDocs = await ctx.db
139+ .query("accessPolicies")
140+ .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
141+ .collect();
142+ for (const policy of policyDocs) {
143+ await ctx.db.delete(policy._id);
144+ }
145+
146+ const credentialDocs = await ctx.db
147+ .query("sourceCredentials")
148+ .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
149+ .collect();
150+ for (const credential of credentialDocs) {
151+ await ctx.db.delete(credential._id);
152+ }
153+
154+ const toolSources = await ctx.db
155+ .query("toolSources")
156+ .withIndex("by_organization_updated", (q) => q.eq("organizationId", organizationId))
157+ .collect();
158+ for (const source of toolSources) {
159+ await ctx.db.delete(source._id);
160+ }
161+
138162 const workspaces = await ctx.db
139163 .query("workspaces")
140164 .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
executor/packages/database/src/auth/identity.ts+7−3
@@ -1,4 +1,5 @@
11 import { components } from "../../convex/_generated/api";
2+import { z } from "zod";
23 import type { RunQueryCtx } from "./types";
34
45 type WorkosProfile = {
@@ -8,17 +9,20 @@ type WorkosProfile = { · 3 unmodified lines
89 profilePictureUrl?: string | null;
910 };
1011
12+const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0);
13+
1114 function deriveFallbackUserLabel(workosUserId: string): string {
1215 return `User ${workosUserId.slice(-6)}`;
1316 }
1417
1518 function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {
1619 for (const key of keys) {
17− const value = identity[key];
18− if (typeof value === "string" && value.trim().length > 0) {
19− return value.trim();
20+ const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]);
21+ if (parsedValue.success) {
22+ return parsedValue.data;
2023 }
2124 }
25+
2226 return undefined;
2327 }
2428
executor/packages/database/src/credentials-node/upsert-credential.ts+100−85
@@ -1,16 +1,43 @@
1−import { WorkOS } from "@workos-inc/node";
1+import { Result } from "better-result";
2+import { z } from "zod";
23 import type { Id } from "../../convex/_generated/dataModel.d.ts";
34 import type { ActionCtx } from "../../convex/_generated/server";
5+import {
6+ createWorkosClient,
7+ extractWorkosVaultObjectId,
8+ withWorkosVaultRetryResult,
9+} from "../../../core/src/credentials/workos-vault";
410 import {
511 assertMatchesCanonicalActorId,
612 canonicalActorIdForWorkspaceAccess,
713 } from "../auth/actor_identity";
8−import { asRecord } from "../lib/object";
914
1015 type Internal = typeof import("../../convex/_generated/api").internal;
1116
1217 type SecretBackend = "local-convex" | "workos-vault";
1318
19+const recordSchema = z.record(z.unknown());
20+
21+const secretPayloadSchema = z.object({
22+ __headers: z.record(z.unknown()).optional(),
23+}).catchall(z.unknown());
24+
25+const listedCredentialSchema = z.object({
26+ id: z.string().optional(),
27+ bindingId: z.string().optional(),
28+ ownerScopeType: z.enum(["organization", "workspace"]).optional(),
29+ scope: z.enum(["workspace", "actor"]).optional(),
30+ actorId: z.string().optional(),
31+ secretJson: z.record(z.unknown()).optional(),
32+});
33+
34+type ListedCredential = z.infer<typeof listedCredentialSchema>;
35+
36+function toRecordValue(value: unknown): Record<string, unknown> {
37+ const parsed = recordSchema.safeParse(value);
38+ return parsed.success ? parsed.data : {};
39+}
40+
1441 function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {
1542 if (scope !== "actor") return "";
1643 if (typeof actorId !== "string") return "";
@@ -28,34 +55,40 @@ function configuredSecretBackend(): SecretBackend { · 11 unmodified lines
2855 return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex";
2956 }
3057
31−function extractObjectId(secretJson: Record<string, unknown>): string | null {
32− const candidate =
33− (typeof secretJson.objectId === "string" ? secretJson.objectId : "") ||
34− (typeof secretJson.id === "string" ? secretJson.id : "");
35− const trimmed = candidate.trim();
36− return trimmed.length > 0 ? trimmed : null;
37−}
38−
3958 function extractHeaderOverrides(secretJson: Record<string, unknown>): {
4059 cleanSecret: Record<string, unknown>;
4160 overridesJson: Record<string, unknown>;
4261 } {
43− const rawHeaders = asRecord(secretJson.__headers);
62+ const parsedSecret = secretPayloadSchema.safeParse(secretJson);
63+ const normalizedSecret = parsedSecret.success ? parsedSecret.data : {};
64+ const rawHeaders = toRecordValue(normalizedSecret.__headers);
4465 const headers: Record<string, string> = {};
4566 for (const [name, value] of Object.entries(rawHeaders)) {
46− if (!name) continue;
67+ const headerName = name.trim();
68+ if (!headerName) continue;
4769 const text = typeof value === "string" ? value.trim() : String(value ?? "").trim();
4870 if (!text) continue;
49− headers[name.trim()] = text;
71+ headers[headerName] = text;
5072 }
5173
52− const { __headers: _headers, ...rest } = secretJson;
74+ const { __headers: _headers, ...rest } = normalizedSecret;
5375 return {
54− cleanSecret: asRecord(rest),
76+ cleanSecret: toRecordValue(rest),
5577 overridesJson: Object.keys(headers).length > 0 ? { headers } : {},
5678 };
5779 }
5880
81+function parseListedCredentials(value: unknown): ListedCredential[] {
82+ if (!Array.isArray(value)) {
83+ return [];
84+ }
85+
86+ return value.flatMap((entry) => {
87+ const parsed = listedCredentialSchema.safeParse(entry);
88+ return parsed.success ? [parsed.data] : [];
89+ });
90+}
91+
5992 function buildVaultObjectName(args: {
6093 workspaceId: string;
6194 sourceKey: string;
@@ -71,56 +104,6 @@ function buildVaultObjectName(args: { · 9 unmodified lines
71104 return `executor-conn-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`;
72105 }
73106
74−function workosClient(): WorkOS {
75− const key = process.env.WORKOS_API_KEY?.trim();
76− if (!key) {
77− throw new Error("Encrypted storage requires WORKOS_API_KEY");
78− }
79− return new WorkOS(key);
80−}
81−
82−function isRetryableVaultError(error: unknown): boolean {
83− if (!(error instanceof Error)) {
84− return false;
85− }
86−
87− const message = error.message.toLowerCase();
88− return (
89− message.includes("not yet ready") ||
90− message.includes("can be retried") ||
91− (message.includes("kek") && message.includes("ready"))
92− );
93−}
94−
95−async function sleep(ms: number): Promise<void> {
96− await new Promise((resolve) => setTimeout(resolve, ms));
97−}
98−
99−async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> {
100− const maxAttempts = 10;
101− const maxDelayMs = 10_000;
102− let delayMs = 500;
103−
104− for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
105− try {
106− return await operation();
107− } catch (error) {
108− if (!isRetryableVaultError(error)) {
109− throw error;
110− }
111− if (attempt === maxAttempts) {
112− throw new Error(
113− "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.",
114− );
115− }
116− await sleep(delayMs);
117− delayMs = Math.min(delayMs * 2, maxDelayMs);
118− }
119− }
120−
121− throw new Error("Unreachable retry state");
122−}
123−
124107 async function upsertVaultObject(args: {
125108 workspaceId: string;
126109 sourceKey: string;
@@ -128,22 +111,36 @@ async function upsertVaultObject(args: { · 1 unmodified line
128111 actorId: string;
129112 existingObjectId: string | null;
130113 payload: Record<string, unknown>;
131−}): Promise<string> {
132− const workos = workosClient();
114+}): Promise<Result<string, Error>> {
115+ const workosResult = createWorkosClient();
116+ if (workosResult.isErr()) {
117+ return Result.err(workosResult.error);
118+ }
119+
120+ const workos = workosResult.value;
133121 const value = JSON.stringify(args.payload);
134122
135123 if (args.existingObjectId) {
136124 const objectId: string = args.existingObjectId;
137− const updated = await withVaultRetry(async () => {
125+ const updatedResult = await withWorkosVaultRetryResult(async () => {
138126 return await workos.vault.updateObject({
139127 id: objectId,
140128 value,
141129 });
130+ }, {
131+ maxAttempts: 10,
132+ initialDelayMs: 500,
133+ maxDelayMs: 10_000,
134+ exhaustionErrorMessage: "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.",
142135 });
143− return updated.id;
136+ if (updatedResult.isErr()) {
137+ return Result.err(updatedResult.error);
138+ }
139+
140+ return Result.ok(updatedResult.value.id);
144141 }
145142
146− const created = await withVaultRetry(async () => {
143+ const createdResult = await withWorkosVaultRetryResult(async () => {
147144 return await workos.vault.createObject({
148145 name: buildVaultObjectName(args),
149146 value,
@@ -151,9 +148,17 @@ async function upsertVaultObject(args: { · 1 unmodified line
151148 workspace_id: args.workspaceId,
152149 },
153150 });
151+ }, {
152+ maxAttempts: 10,
153+ initialDelayMs: 500,
154+ maxDelayMs: 10_000,
155+ exhaustionErrorMessage: "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.",
154156 });
157+ if (createdResult.isErr()) {
158+ return Result.err(createdResult.error);
159+ }
155160
156− return created.id;
161+ return Result.ok(createdResult.value.id);
157162 }
158163
159164 export async function upsertCredentialHandler(
@@ -163,6 +168,7 @@ export async function upsertCredentialHandler( · 3 unmodified lines
163168 id?: string;
164169 workspaceId: Id<"workspaces">;
165170 sessionId?: string;
171+ ownerScopeType?: "organization" | "workspace";
166172 sourceKey: string;
167173 scope: "workspace" | "actor";
168174 actorId?: string;
@@ -180,7 +186,8 @@ export async function upsertCredentialHandler( · 11 unmodified lines
180186 }
181187
182188 const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId);
183− const rawSubmittedSecret = asRecord(args.secretJson);
189+ const ownerScopeType = args.ownerScopeType ?? "workspace";
190+ const rawSubmittedSecret = toRecordValue(args.secretJson);
184191 const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret);
185192
186193 const existingBinding = await ctx.runQuery(internal.database.resolveCredential, {
@@ -190,34 +197,36 @@ export async function upsertCredentialHandler( · 3 unmodified lines
190197 ...(args.scope === "actor" ? { actorId } : {}),
191198 });
192199
193− const allCredentials: Array<Record<string, unknown>> = await ctx.runQuery(internal.database.listCredentials, {
200+ const allCredentialsRaw = await ctx.runQuery(internal.database.listCredentials, {
194201 workspaceId: args.workspaceId,
195202 });
203+ const allCredentials = parseListedCredentials(allCredentialsRaw);
196204 const requestedId = args.id?.trim();
197205 const existingConnection = requestedId
198206 ? allCredentials.find((credential) => {
199− const id = String(credential.id ?? "");
200− const bindingId = String(credential.bindingId ?? "");
207+ const id = (credential.id ?? "").trim();
208+ const bindingId = (credential.bindingId ?? "").trim();
201209 if (id !== requestedId && bindingId !== requestedId) return false;
202− if (String(credential.scope ?? "") !== args.scope) return false;
210+ if ((credential.ownerScopeType ?? "workspace") !== ownerScopeType) return false;
211+ if (credential.scope !== args.scope) return false;
203212 if (args.scope === "actor") {
204− return String(credential.actorId ?? "") === actorId;
213+ return (credential.actorId ?? "").trim() === actorId;
205214 }
206215 return true;
207216 }) ?? allCredentials.find((credential) => {
208− const id = String(credential.id ?? "");
209− const bindingId = String(credential.bindingId ?? "");
217+ const id = (credential.id ?? "").trim();
218+ const bindingId = (credential.bindingId ?? "").trim();
210219 return id === requestedId || bindingId === requestedId;
211220 })
212221 : null;
213− const connectionId = String(existingConnection?.id ?? requestedId ?? "").trim() || undefined;
222+ const connectionId = (existingConnection?.id ?? requestedId ?? "").trim() || undefined;
214223
215224 const backend = configuredSecretBackend();
216225
217226 if (backend === "local-convex") {
218227 const finalSecret = Object.keys(submittedSecret).length > 0
219228 ? submittedSecret
220− : asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson);
229+ : toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson);
221230 if (Object.keys(finalSecret).length === 0) {
222231 throw new Error("Credential values are required");
223232 }
@@ -225,6 +234,7 @@ export async function upsertCredentialHandler( · 1 unmodified line
225234 return await ctx.runMutation(internal.database.upsertCredential, {
226235 id: connectionId,
227236 workspaceId: args.workspaceId,
237+ ownerScopeType,
228238 sourceKey: args.sourceKey,
229239 scope: args.scope,
230240 ...(args.scope === "actor" ? { actorId } : {}),
@@ -234,18 +244,18 @@ export async function upsertCredentialHandler( · 3 unmodified lines
234244 });
235245 }
236246
237− const submittedObjectId = extractObjectId(submittedSecret);
247+ const submittedObjectId = extractWorkosVaultObjectId(submittedSecret);
238248 if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) {
239249 throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field.");
240250 }
241251
242− const existingObjectId = extractObjectId(
243− asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson),
252+ const existingObjectId = extractWorkosVaultObjectId(
253+ toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson),
244254 );
245255
246256 let finalObjectId = submittedObjectId;
247257 if (!finalObjectId && Object.keys(submittedSecret).length > 0) {
248− finalObjectId = await upsertVaultObject({
258+ const upsertResult = await upsertVaultObject({
249259 workspaceId: args.workspaceId,
250260 sourceKey: args.sourceKey,
251261 scope: args.scope,
@@ -253,6 +263,10 @@ export async function upsertCredentialHandler( · 1 unmodified line
253263 existingObjectId,
254264 payload: submittedSecret,
255265 });
266+ if (upsertResult.isErr()) {
267+ throw upsertResult.error;
268+ }
269+ finalObjectId = upsertResult.value;
256270 }
257271
258272 if (!finalObjectId && existingObjectId) {
@@ -266,6 +280,7 @@ export async function upsertCredentialHandler( · 7 unmodified lines
266280 return await ctx.runMutation(internal.database.upsertCredential, {
267281 id: connectionId,
268282 workspaceId: args.workspaceId,
283+ ownerScopeType,
269284 sourceKey: args.sourceKey,
270285 scope: args.scope,
271286 ...(args.scope === "actor" ? { actorId } : {}),
executor/packages/database/src/database/mappers.ts+59−13
@@ -1,13 +1,20 @@
11 import type { Doc } from "../../convex/_generated/dataModel.d.ts";
2−import { asRecord } from "../lib/object";
2+import { z } from "zod";
3+import { isRecord } from "../lib/object";
34 import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
45
6+const sourceAuthSchema = z.object({
7+ type: z.string().optional(),
8+ mode: z.enum(["workspace", "actor"]).optional(),
9+ header: z.string().optional(),
10+});
11+
512 function stableStringify(value: unknown): string {
613 if (Array.isArray(value)) {
714 return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
815 }
9− if (value && typeof value === "object") {
10− const record = asRecord(value);
16+ if (isRecord(value)) {
17+ const record = value;
1118 const keys = Object.keys(record).sort();
1219 return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
1320 }
@@ -15,10 +22,11 @@ function stableStringify(value: unknown): string { · 1 unmodified line
1522 }
1623
1724 export function normalizeSourceAuthFingerprint(value: unknown): string {
18− const auth = asRecord(value);
19− const type = typeof auth.type === "string" ? auth.type.trim() : "none";
25+ const parsedAuth = sourceAuthSchema.safeParse(value);
26+ const auth = parsedAuth.success ? parsedAuth.data : {};
27+ const type = (auth.type ?? "none").trim();
2028 const mode = auth.mode === "actor" ? "actor" : "workspace";
21− const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : "";
29+ const header = (auth.header ?? "").trim().toLowerCase();
2230 return stableStringify({
2331 type: type || "none",
2432 mode,
@@ -43,13 +51,14 @@ export function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", confi · 18 unmodified lines
4351 }
4452
4553 export function mapTask(doc: Doc<"tasks">) {
54+ const metadata = isRecord(doc.metadata) ? doc.metadata : {};
4655 return {
4756 id: doc.taskId,
4857 code: doc.code,
4958 runtimeId: doc.runtimeId,
5059 status: doc.status,
5160 timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS,
52− metadata: asRecord(doc.metadata),
61+ metadata,
5362 workspaceId: doc.workspaceId,
5463 actorId: doc.actorId,
5564 clientId: doc.clientId,
@@ -92,30 +101,64 @@ export function mapToolCall(doc: Doc<"toolCalls">) { · 36 unmodified lines
92101 }
93102
94103 export function mapPolicy(doc: Doc<"accessPolicies">) {
104+ const legacyDecision = typeof Reflect.get(doc as object, "decision") === "string"
105+ ? String(Reflect.get(doc as object, "decision"))
106+ : undefined;
107+ const resourcePattern = doc.resourcePattern
108+ || (typeof Reflect.get(doc as object, "toolPathPattern") === "string"
109+ ? String(Reflect.get(doc as object, "toolPathPattern"))
110+ : "*");
111+ const scopeType = doc.scopeType ?? (doc.workspaceId ? "workspace" : "organization");
112+ const targetActorId = doc.targetActorId
113+ ?? (typeof Reflect.get(doc as object, "actorId") === "string"
114+ ? String(Reflect.get(doc as object, "actorId"))
115+ : undefined);
116+ const effect = doc.effect ?? (legacyDecision === "deny" ? "deny" : "allow");
117+ const approvalMode = doc.approvalMode
118+ ?? (legacyDecision === "require_approval" ? "required" : legacyDecision === "allow" ? "auto" : "inherit");
119+ const decision = effect === "deny"
120+ ? "deny"
121+ : approvalMode === "required"
122+ ? "require_approval"
123+ : "allow";
124+
95125 return {
96126 id: doc.policyId,
127+ scopeType,
128+ organizationId: doc.organizationId,
97129 workspaceId: doc.workspaceId,
98− actorId: doc.actorId,
130+ targetActorId,
99131 clientId: doc.clientId,
100− toolPathPattern: doc.toolPathPattern,
101− decision: doc.decision,
132+ resourceType: doc.resourceType ?? "tool_path",
133+ resourcePattern,
134+ matchType: doc.matchType ?? "glob",
135+ effect,
136+ approvalMode,
102137 priority: doc.priority,
103138 createdAt: doc.createdAt,
104139 updatedAt: doc.updatedAt,
140+ // Legacy aliases retained during transition.
141+ actorId: targetActorId,
142+ toolPathPattern: resourcePattern,
143+ decision,
105144 };
106145 }
107146
108147 export function mapCredential(doc: Doc<"sourceCredentials">) {
148+ const secretJson = isRecord(doc.secretJson) ? doc.secretJson : {};
149+ const overridesJson = isRecord(doc.overridesJson) ? doc.overridesJson : {};
109150 return {
110151 id: doc.credentialId,
111152 bindingId: doc.bindingId,
153+ ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"),
154+ organizationId: doc.organizationId,
112155 workspaceId: doc.workspaceId,
113156 sourceKey: doc.sourceKey,
114157 scope: doc.scope,
115158 actorId: doc.actorId || undefined,
116159 provider: doc.provider,
117− secretJson: asRecord(doc.secretJson),
118− overridesJson: asRecord(doc.overridesJson),
160+ secretJson,
161+ overridesJson,
119162 boundAuthFingerprint: doc.boundAuthFingerprint,
120163 createdAt: doc.createdAt,
121164 updatedAt: doc.updatedAt,
@@ -123,12 +166,15 @@ export function mapCredential(doc: Doc<"sourceCredentials">) { · 1 unmodified line
123166 }
124167
125168 export function mapSource(doc: Doc<"toolSources">) {
169+ const config = isRecord(doc.config) ? doc.config : {};
126170 return {
127171 id: doc.sourceId,
172+ ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"),
173+ organizationId: doc.organizationId,
128174 workspaceId: doc.workspaceId,
129175 name: doc.name,
130176 type: doc.type,
131− config: asRecord(doc.config),
177+ config,
132178 specHash: doc.specHash,
133179 authFingerprint: doc.authFingerprint,
134180 enabled: Boolean(doc.enabled),
executor/packages/database/src/database/readers.ts+18−3
@@ -1,7 +1,11 @@
11 import type { Doc } from "../../convex/_generated/dataModel.d.ts";
22 import type { QueryCtx } from "../../convex/_generated/server";
3+import { z } from "zod";
34 import { normalizeSourceAuthFingerprint } from "./mappers";
4−import { asRecord } from "../lib/object";
5+
6+const toolSourceConfigSchema = z.object({
7+ auth: z.unknown().optional(),
8+});
59
610 export function mapAnonymousContext(doc: Doc<"anonymousSessions">) {
711 return {
@@ -69,9 +73,20 @@ export async function computeBoundAuthFingerprint( · 61 unmodified lines
6973 .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
7074 .unique();
7175
72− if (!source || source.workspaceId !== workspaceId) {
76+ const workspace = await ctx.db.get(workspaceId);
77+ if (!source || !workspace) {
78+ return normalizeSourceAuthFingerprint({ type: "none" });
79+ }
80+
81+ if (source.organizationId && source.organizationId !== workspace.organizationId) {
82+ return normalizeSourceAuthFingerprint({ type: "none" });
83+ }
84+
85+ const workspaceOwned = source.ownerScopeType === "workspace" || (!source.ownerScopeType && Boolean(source.workspaceId));
86+ if (workspaceOwned && source.workspaceId !== workspaceId) {
7387 return normalizeSourceAuthFingerprint({ type: "none" });
7488 }
7589
76− return normalizeSourceAuthFingerprint(asRecord(source.config).auth);
90+ const parsedConfig = toolSourceConfigSchema.safeParse(source.config);
91+ return normalizeSourceAuthFingerprint(parsedConfig.success ? parsedConfig.data.auth : undefined);
7792 }
executor/packages/database/src/database/tool_source_config.ts+20−14
@@ -7,7 +7,6 @@ import type { · 6 unmodified lines
77 OpenApiToolSourceConfig,
88 } from "../../../core/src/tool/source-types";
99 import type { ToolApprovalMode } from "../../../core/src/types";
10−import { asRecord } from "../lib/object";
1110
1211 export type ToolSourceType = "mcp" | "openapi" | "graphql";
1312
@@ -39,6 +38,8 @@ const overrideEntrySchema = z.object({ · 25 unmodified lines
3938 });
4039 const unknownRecordSchema = z.record(z.unknown());
4140 const stringMapSchema = z.record(z.string());
41+const trimmedStringSchema = z.string().transform((value) => value.trim());
42+const nonEmptyTrimmedStringSchema = trimmedStringSchema.refine((value) => value.length > 0);
4243 const basicAuthSchema = z.object({
4344 mode: authModeSchema.optional(),
4445 username: z.string().optional(),
@@ -55,20 +56,24 @@ const apiKeyAuthSchema = z.object({ · 10 unmodified lines
5556 });
5657
5758 function optionalTrimmedString(value: unknown): string | undefined {
58− if (typeof value !== "string") return undefined;
59− const trimmed = value.trim();
60− return trimmed.length > 0 ? trimmed : undefined;
59+ const parsed = trimmedStringSchema.safeParse(value);
60+ if (!parsed.success || parsed.data.length === 0) {
61+ return undefined;
62+ }
63+
64+ return parsed.data;
6165 }
6266
6367 function requiredTrimmedString(
6468 value: unknown,
6569 fieldName: string,
6670 ): Result<string, Error> {
67− const trimmed = optionalTrimmedString(value);
68− if (!trimmed) {
71+ const parsed = nonEmptyTrimmedStringSchema.safeParse(value);
72+ if (!parsed.success) {
6973 return Result.err(new Error(`Tool source ${fieldName} is required`));
7074 }
71− return Result.ok(trimmed);
75+
76+ return Result.ok(parsed.data);
7277 }
7378
7479 function normalizeStringMap(
@@ -151,7 +156,12 @@ function normalizeOverrides( · 76 unmodified lines
151156 function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> {
152157 if (value === undefined) return Result.ok(undefined);
153158
154− const auth = asRecord(value);
159+ const authRecord = unknownRecordSchema.safeParse(value);
160+ if (!authRecord.success) {
161+ return Result.err(new Error("Tool source auth.type is required when auth is provided"));
162+ }
163+
164+ const auth = authRecord.data;
155165 const authType = optionalTrimmedString(auth.type);
156166 if (!authType) {
157167 return Result.err(new Error("Tool source auth.type is required when auth is provided"));
@@ -217,13 +227,9 @@ function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { · 59 unmodified lines
217227 function normalizeSpec(
218228 value: unknown,
219229 ): Result<string | Record<string, unknown>, Error> {
220− const stringSpec = z.string().safeParse(value);
230+ const stringSpec = nonEmptyTrimmedStringSchema.safeParse(value);
221231 if (stringSpec.success) {
222− const trimmed = stringSpec.data.trim();
223− if (!trimmed) {
224− return Result.err(new Error("Tool source spec is required"));
225− }
226− return Result.ok(trimmed);
232+ return Result.ok(stringSpec.data);
227233 }
228234
229235 const specObjectResult = unknownRecordSchema.safeParse(value);
executor/packages/database/src/database/validators.ts+5−0
@@ -18,6 +18,11 @@ export const terminalToolCallStatusValidator = v.union( · 17 unmodified lines
1818 );
1919
2020 export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
21+export const policyScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace"));
22+export const policyMatchTypeValidator = v.union(v.literal("glob"), v.literal("exact"));
23+export const policyEffectValidator = v.union(v.literal("allow"), v.literal("deny"));
24+export const policyApprovalModeValidator = v.union(v.literal("inherit"), v.literal("auto"), v.literal("required"));
25+export const ownerScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace"));
2126
2227 export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
2328
executor/packages/database/src/lib/object.ts+9−10
@@ -1,13 +1,12 @@
1−export function asRecord(value: unknown): Record<string, unknown> {
2− if (value && typeof value === "object" && !Array.isArray(value)) {
3− return value as Record<string, unknown>;
4− }
5− return {};
1+import { z } from "zod";
2+
3+const recordSchema = z.record(z.unknown());
4+
5+export function isRecord(value: unknown): value is Record<string, unknown> {
6+ return recordSchema.safeParse(value).success;
67 }
78
8−export function asPayload(value: unknown): Record<string, unknown> {
9− if (value && typeof value === "object") {
10− return value as Record<string, unknown>;
11− }
12− return { value };
9+export function toRecord(value: unknown): Record<string, unknown> {
10+ const parsed = recordSchema.safeParse(value);
11+ return parsed.success ? parsed.data : {};
1312 }
executor/packages/database/src/runtime/policy.ts+51−10
@@ -7,21 +7,55 @@ import type { · 6 unmodified lines
77 ToolDefinition,
88 } from "../../../core/src/types";
99
10−function matchesToolPath(pattern: string, toolPath: string): boolean {
10+function matchesToolPath(pattern: string, toolPath: string, matchType: "glob" | "exact" = "glob"): boolean {
11+ if (matchType === "exact") {
12+ return pattern === toolPath;
13+ }
14+
1115 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1216 const regex = new RegExp(`^${escaped}$`);
1317 return regex.test(toolPath);
1418 }
1519
16−function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number {
20+function policySpecificity(
21+ policy: AccessPolicyRecord,
22+ context: { workspaceId: string; actorId?: string; clientId?: string },
23+): number {
24+ const scopeType = policy.scopeType ?? (policy.workspaceId ? "workspace" : "organization");
25+ const targetActorId = policy.targetActorId ?? policy.actorId;
26+ const resourcePattern = policy.resourcePattern ?? policy.toolPathPattern ?? "*";
27+ const matchType = policy.matchType ?? "glob";
28+
1729 let score = 0;
18− if (policy.actorId && actorId && policy.actorId === actorId) score += 4;
19− if (policy.clientId && clientId && policy.clientId === clientId) score += 2;
20− score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length);
30+ if (scopeType === "workspace" && policy.workspaceId === context.workspaceId) score += 16;
31+ if (targetActorId && context.actorId && targetActorId === context.actorId) score += 64;
32+ if (policy.clientId && context.clientId && policy.clientId === context.clientId) score += 4;
33+ if (matchType === "exact") score += 3;
34+ score += Math.max(1, resourcePattern.replace(/\*/g, "").length);
2135 score += policy.priority;
2236 return score;
2337 }
2438
39+function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision {
40+ const effect = policy.effect ?? (policy.decision === "deny" ? "deny" : "allow");
41+ const approvalMode = policy.approvalMode
42+ ?? (policy.decision === "require_approval" ? "required" : policy.decision === "allow" ? "auto" : "inherit");
43+
44+ if (effect === "deny") {
45+ return "deny";
46+ }
47+
48+ if (approvalMode === "required") {
49+ return "require_approval";
50+ }
51+
52+ if (approvalMode === "auto") {
53+ return "allow";
54+ }
55+
56+ return defaultDecision;
57+}
58+
2559 export function getDecisionForContext(
2660 tool: ToolDefinition,
2761 context: { workspaceId: string; actorId?: string; clientId?: string },
@@ -34,17 +68,24 @@ export function getDecisionForContext( · 6 unmodified lines
3468 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
3569 const candidates = policies
3670 .filter((policy) => {
37− if (policy.actorId && policy.actorId !== context.actorId) return false;
71+ const scopeType = policy.scopeType ?? (policy.workspaceId ? "workspace" : "organization");
72+ const targetActorId = policy.targetActorId ?? policy.actorId;
73+ const resourcePattern = policy.resourcePattern ?? policy.toolPathPattern;
74+ const matchType = policy.matchType ?? "glob";
75+
76+ if (!resourcePattern) return false;
77+ if (scopeType === "workspace" && policy.workspaceId !== context.workspaceId) return false;
78+ if (targetActorId && targetActorId !== context.actorId) return false;
3879 if (policy.clientId && policy.clientId !== context.clientId) return false;
39− return matchesToolPath(policy.toolPathPattern, tool.path);
80+ return matchesToolPath(resourcePattern, tool.path, matchType);
4081 })
4182 .sort(
4283 (a, b) =>
43− policySpecificity(b, context.actorId, context.clientId)
44− - policySpecificity(a, context.actorId, context.clientId),
84+ policySpecificity(b, context)
85+ - policySpecificity(a, context),
4586 );
4687
47− return candidates[0]?.decision ?? defaultDecision;
88+ return candidates[0] ? resolvePolicyDecision(candidates[0], defaultDecision) : defaultDecision;
4889 }
4990
5091 export function getToolDecision(
executor/packages/database/src/runtime/tool_call_credentials.ts+43−58
@@ -1,38 +1,21 @@
11 "use node";
22
33 import { Result } from "better-result";
4−import { z } from "zod";
54 import type { ActionCtx } from "../../convex/_generated/server";
65 import { internal } from "../../convex/_generated/api";
7−import { resolveCredentialPayload } from "../../../core/src/credential-providers";
6+import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers";
7+import {
8+ buildCredentialAuthHeaders,
9+ readCredentialOverrideHeaders,
10+} from "../../../core/src/tool/source-auth";
811 import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
912 import { ToolCallControlError } from "../../../core/src/tool-call-control";
10−import { asPayload } from "../lib/object";
1113
12−const bearerSecretSchema = z.object({
13− token: z.coerce.string().optional(),
14−});
15−
16−const apiKeySecretSchema = z.object({
17− headerName: z.coerce.string().optional(),
18− value: z.coerce.string().optional(),
19− token: z.coerce.string().optional(),
20−});
21−
22−const basicSecretSchema = z.object({
23− username: z.coerce.string().optional(),
24− password: z.coerce.string().optional(),
25−});
26−
27−const credentialOverrideHeadersSchema = z.object({
28− headers: z.record(z.coerce.string()).optional(),
29−});
30−
31−export async function resolveCredentialHeaders(
32− ctx: ActionCtx,
14+export async function resolveCredentialHeadersResult(
15+ ctx: Pick<ActionCtx, "runQuery">,
3316 spec: ToolCredentialSpec,
3417 task: TaskRecord,
35−): Promise<ResolvedToolCredential | null> {
18+): Promise<Result<ResolvedToolCredential | null, Error>> {
3619 const record = await ctx.runQuery(internal.database.resolveCredential, {
3720 workspaceId: task.workspaceId,
3821 sourceKey: spec.sourceKey,
@@ -40,53 +23,55 @@ export async function resolveCredentialHeaders( · 1 unmodified line
4023 actorId: task.actorId,
4124 });
4225
43− const source = record
44− ? await resolveCredentialPayload(record)
45− : spec.staticSecretJson ?? null;
46− if (!source) {
47− return null;
26+ const sourceResult = record
27+ ? await resolveCredentialPayloadResult(record)
28+ : Result.ok(spec.staticSecretJson ?? null);
29+ if (sourceResult.isErr()) {
30+ return Result.err(
31+ new Error(`Failed to resolve credential payload for '${spec.sourceKey}': ${sourceResult.error.message}`),
32+ );
4833 }
49− const sourcePayload = asPayload(source);
5034
51− const headers: Record<string, string> = {};
52− if (spec.authType === "bearer") {
53− const parsedSecret = bearerSecretSchema.safeParse(sourcePayload);
54− const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : "";
55− if (token) headers.authorization = `Bearer ${token}`;
56− } else if (spec.authType === "apiKey") {
57− const parsedSecret = apiKeySecretSchema.safeParse(sourcePayload);
58− const headerName = spec.headerName
59− ?? (parsedSecret.success ? (parsedSecret.data.headerName ?? "x-api-key") : "x-api-key");
60− const value = parsedSecret.success
61− ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim()
62− : "";
63− if (value) headers[headerName] = value;
64− } else if (spec.authType === "basic") {
65− const parsedSecret = basicSecretSchema.safeParse(sourcePayload);
66− const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : "";
67− const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : "";
68− if (username || password) {
69− const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
70− headers.authorization = `Basic ${encoded}`;
71− }
35+ const source = sourceResult.value;
36+ if (!source) {
37+ return Result.ok(null);
7238 }
7339
74− const bindingOverrides = credentialOverrideHeadersSchema.safeParse(record?.overridesJson ?? {});
75− const overrideHeaders = bindingOverrides.success ? (bindingOverrides.data.headers ?? {}) : {};
40+ const headers = buildCredentialAuthHeaders(
41+ {
42+ authType: spec.authType,
43+ headerName: spec.headerName,
44+ },
45+ source,
46+ );
47+
48+ const overrideHeaders = readCredentialOverrideHeaders(record?.overridesJson);
7649 for (const [key, value] of Object.entries(overrideHeaders)) {
77− if (!key) continue;
7850 headers[key] = value;
7951 }
8052
8153 if (Object.keys(headers).length === 0) {
82− return null;
54+ return Result.ok(null);
8355 }
8456
85− return {
57+ return Result.ok({
8658 sourceKey: spec.sourceKey,
8759 mode: spec.mode,
8860 headers,
89− };
61+ });
62+}
63+
64+export async function resolveCredentialHeaders(
65+ ctx: Pick<ActionCtx, "runQuery">,
66+ spec: ToolCredentialSpec,
67+ task: TaskRecord,
68+): Promise<ResolvedToolCredential | null> {
69+ const result = await resolveCredentialHeadersResult(ctx, spec, task);
70+ if (result.isErr()) {
71+ throw result.error;
72+ }
73+
74+ return result.value;
9075 }
9176
9277 export function validatePersistedCallRunnable(
executor/packages/database/src/runtime/tool_call_resolution.test.ts+54−0
@@ -0,0 +1,54 @@
1+import { describe, expect, test } from "bun:test";
2+import type { TaskRecord, ToolDefinition } from "../../../core/src/types";
3+import { getGraphqlDecision } from "./tool_call_resolution";
4+
5+const task: TaskRecord = {
6+ id: "task_1",
7+ code: "",
8+ runtimeId: "runtime_1",
9+ status: "running",
10+ timeoutMs: 30_000,
11+ metadata: {},
12+ workspaceId: "workspace_1" as TaskRecord["workspaceId"],
13+ actorId: "actor_1",
14+ clientId: "client_1",
15+ createdAt: 1,
16+ updatedAt: 1,
17+};
18+
19+const graphqlTool: ToolDefinition = {
20+ path: "linear.graphql",
21+ source: "graphql:linear",
22+ approval: "auto",
23+ description: "Raw GraphQL tool",
24+ _graphqlSource: "linear",
25+ run: async () => ({ data: null, errors: [] }),
26+};
27+
28+describe("getGraphqlDecision", () => {
29+ test("supports raw string GraphQL query input", () => {
30+ const decision = getGraphqlDecision(
31+ task,
32+ graphqlTool,
33+ "query { teams { nodes { id } } }",
34+ undefined,
35+ [],
36+ );
37+
38+ expect(decision.decision).toBe("allow");
39+ expect(decision.effectivePaths).toEqual(["linear.query.teams"]);
40+ });
41+
42+ test("derives require_approval for raw string mutation input", () => {
43+ const decision = getGraphqlDecision(
44+ task,
45+ graphqlTool,
46+ "mutation { issueBatchCreate(input: { issues: [] }) { success } }",
47+ undefined,
48+ [],
49+ );
50+
51+ expect(decision.decision).toBe("require_approval");
52+ expect(decision.effectivePaths).toEqual(["linear.mutation.issuebatchcreate"]);
53+ });
54+});
executor/packages/database/src/runtime/tool_call_resolution.ts+43−14
@@ -7,9 +7,8 @@ import { internal } from "../../convex/_generated/api"; · 6 unmodified lines
77 import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
88 import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
99 import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization";
10−import { asPayload } from "../lib/object";
1110 import { getDecisionForContext, getToolDecision } from "./policy";
12−import { getReadyRegistryBuildId } from "./tool_registry_state";
11+import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
1312 import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
1413 import { baseTools } from "./workspace_tools";
1514
@@ -27,6 +26,24 @@ const registrySerializedToolEntrySchema = z.object({ · 11 unmodified lines
2726 serializedToolJson: z.string(),
2827 });
2928
29+const graphqlDecisionInputSchema = z.union([
30+ z.string(),
31+ z.object({ query: z.string().optional() }).catchall(z.unknown()),
32+]);
33+
34+function getGraphqlQueryFromInput(input: unknown): string {
35+ const parsedInput = graphqlDecisionInputSchema.safeParse(input);
36+ if (!parsedInput.success) {
37+ return "";
38+ }
39+
40+ if (typeof parsedInput.data === "string") {
41+ return parsedInput.data;
42+ }
43+
44+ return parsedInput.data.query ?? "";
45+}
46+
3047 async function searchRegistryEntries(
3148 ctx: ActionCtx,
3249 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
@@ -66,9 +83,12 @@ export function getGraphqlDecision( · 33 unmodified lines
6683 workspaceTools: Map<string, ToolDefinition> | undefined,
6784 policies: AccessPolicyRecord[],
6885 ): { decision: PolicyDecision; effectivePaths: string[] } {
69− const sourceName = tool._graphqlSource!;
70− const payload = asPayload(input);
71− const queryString = typeof payload.query === "string" ? payload.query : "";
86+ const sourceName = tool._graphqlSource;
87+ if (!sourceName) {
88+ return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
89+ }
90+
91+ const queryString = getGraphqlQueryFromInput(input);
7292
7393 if (!queryString.trim()) {
7494 return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
@@ -153,12 +173,16 @@ export async function resolveToolForCall( · 78 unmodified lines
153173 return Result.ok({ tool: builtin, resolvedToolPath: toolPath });
154174 }
155175
156− const buildId = await getReadyRegistryBuildId(ctx, {
176+ const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
157177 workspaceId: task.workspaceId,
158178 actorId: task.actorId,
159179 clientId: task.clientId,
160180 refreshOnStale: true,
161181 });
182+ if (buildIdResult.isErr()) {
183+ return Result.err(buildIdResult.error);
184+ }
185+ const buildId = buildIdResult.value;
162186
163187 let resolvedToolPath = toolPath;
164188 let entry = await getRegistryToolByPath(ctx, {
@@ -179,8 +203,12 @@ export async function resolveToolForCall( · 14 unmodified lines
179203 if (hits.length > 0) {
180204 // Prefer exact match on preferred path formatting, otherwise shortest canonical path.
181205 const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath));
182− entry = exact ?? hits.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0]!;
183− resolvedToolPath = entry.path;
206+ const shortest = [...hits]
207+ .sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0];
208+ entry = exact ?? shortest ?? null;
209+ if (entry) {
210+ resolvedToolPath = entry.path;
211+ }
184212 }
185213 }
186214
@@ -189,15 +217,16 @@ export async function resolveToolForCall( · 2 unmodified lines
189217 return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions)));
190218 }
191219
192− const serializedResult = Result.try(() => {
193− return JSON.parse(entry.serializedToolJson);
194− });
195− if (serializedResult.isErr()) {
220+ let parsedSerialized: unknown;
221+ try {
222+ parsedSerialized = JSON.parse(entry.serializedToolJson);
223+ } catch (error) {
224+ const message = error instanceof Error ? error.message : String(error);
196225 return Result.err(
197− new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`),
226+ new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${message}`),
198227 );
199228 }
200− const serialized = parseSerializedTool(serializedResult.value);
229+ const serialized = parseSerializedTool(parsedSerialized);
201230 if (serialized.isErr()) {
202231 return Result.err(
203232 new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serialized.error.message}`),
executor/packages/database/src/runtime/tool_invocation.ts+27−21
@@ -18,14 +18,15 @@ import { · 17 unmodified lines
1818 decodeToolCallControlSignal,
1919 ToolCallControlError,
2020 } from "../../../core/src/tool-call-control";
21−import { asPayload } from "../lib/object";
2221 import { getToolDecision, getDecisionForContext } from "./policy";
2322 import { baseTools } from "./workspace_tools";
2423 import { publishTaskEvent } from "./events";
2524 import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
26−import { resolveCredentialHeaders, validatePersistedCallRunnable } from "./tool_call_credentials";
25+import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials";
2726 import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
28−import { getReadyRegistryBuildId } from "./tool_registry_state";
27+import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
28+
29+const payloadRecordSchema = z.record(z.unknown());
2930
3031 function createApprovalId(): string {
3132 return `approval_${crypto.randomUUID()}`;
@@ -41,18 +42,6 @@ type RegistryToolEntry = { · 9 unmodified lines
4142 displayOutput?: string;
4243 };
4344
44−const accessPolicyRecordSchema = z.object({
45− id: z.string(),
46− workspaceId: z.custom<TaskRecord["workspaceId"]>(),
47− actorId: z.string().optional(),
48− clientId: z.string().optional(),
49− toolPathPattern: z.string(),
50− decision: z.enum(["allow", "require_approval", "deny"]),
51− priority: z.number(),
52− createdAt: z.number(),
53− updatedAt: z.number(),
54−});
55−
5645 const registryNamespaceSchema = z.object({
5746 namespace: z.string(),
5847 toolCount: z.number(),
@@ -85,6 +74,15 @@ const discoverInputSchema = z.object({ · 26 unmodified lines
8574 compact: z.boolean().optional(),
8675 });
8776
77+function toInputPayload(value: unknown): Record<string, unknown> {
78+ const parsed = payloadRecordSchema.safeParse(value);
79+ if (parsed.success) {
80+ return parsed.data;
81+ }
82+
83+ return value === undefined ? {} : { value };
84+}
85+
8886 async function upsertRequestedToolCall(
8987 ctx: ActionCtx,
9088 args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },
@@ -98,8 +96,7 @@ async function listWorkspaceAccessPolicies( · 7 unmodified lines
9896 workspaceId: TaskRecord["workspaceId"],
9997 ): Promise<AccessPolicyRecord[]> {
10098 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });
101− const parsed = z.array(accessPolicyRecordSchema).safeParse(policies);
102− return parsed.success ? parsed.data : [];
99+ return policies as AccessPolicyRecord[];
103100 }
104101
105102 async function listRegistryNamespaces(
@@ -183,16 +180,20 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 77 unmodified lines
183180
184181 // Fast system tools are handled server-side from the registry.
185182 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
186− const buildId = await getReadyRegistryBuildId(ctx, {
183+ const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
187184 workspaceId: task.workspaceId,
188185 actorId: task.actorId,
189186 clientId: task.clientId,
190187 refreshOnStale: true,
191188 });
189+ if (buildIdResult.isErr()) {
190+ throw buildIdResult.error;
191+ }
192+ const buildId = buildIdResult.value;
192193
193194 const payload = typeof input === "string"
194195 ? { query: input }
195− : asPayload(input);
196+ : toInputPayload(input);
196197 const isAllowed = (path: string, approval: ToolDefinition["approval"]) => {
197198 const policyProbeTool: ToolDefinition = {
198199 path,
@@ -339,7 +340,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 140 unmodified lines
339340
340341 let credential: ResolvedToolCredential | undefined;
341342 if (tool.credential) {
342− const resolved = await resolveCredentialHeaders(ctx, tool.credential, task);
343+ const credentialResult = await resolveCredentialHeadersResult(ctx, tool.credential, task);
344+ if (credentialResult.isErr()) {
345+ throw credentialResult.error;
346+ }
347+
348+ const resolved = credentialResult.value;
343349 if (!resolved) {
344350 throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
345351 }
@@ -402,7 +408,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 56 unmodified lines
402408 taskId: task.id,
403409 callId,
404410 toolPath: approval.toolPath,
405− input: asPayload(approval.input),
411+ input: toInputPayload(approval.input),
406412 createdAt: approval.createdAt,
407413 });
408414 }
executor/packages/database/src/runtime/tool_registry_state.ts+48−27
@@ -1,5 +1,6 @@
11 "use node";
22
3+import { Result } from "better-result";
34 import { z } from "zod";
45 import type { ActionCtx } from "../../convex/_generated/server";
56 import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -21,12 +22,6 @@ type RegistryState = { · 15 unmodified lines
2122 readyBuildId?: string;
2223 } | null;
2324
24−type ToolSourceState = {
25− id: string;
26− updatedAt: number;
27− enabled: boolean;
28−};
29−
3025 const registryStateSchema = z.object({
3126 signature: z.string(),
3227 readyBuildId: z.string().optional(),
@@ -34,9 +29,29 @@ const registryStateSchema = z.object({ · 1 unmodified line
3429
3530 const toolSourceStateSchema = z.object({
3631 id: z.string(),
32+ type: z.string().optional(),
33+ ownerScopeType: z.string().optional(),
34+ organizationId: z.string().optional(),
35+ workspaceId: z.string().optional(),
36+ specHash: z.string().optional(),
37+ authFingerprint: z.string().optional(),
3738 updatedAt: z.number(),
3839 enabled: z.boolean().optional(),
39−});
40+}).transform((source) => ({
41+ id: source.id,
42+ type: source.type,
43+ ownerScopeType: source.ownerScopeType,
44+ organizationId: source.organizationId,
45+ workspaceId: source.workspaceId,
46+ specHash: source.specHash,
47+ authFingerprint: source.authFingerprint,
48+ updatedAt: source.updatedAt,
49+ enabled: source.enabled !== false,
50+}));
51+
52+type ToolSourceState = z.infer<typeof toolSourceStateSchema>;
53+
54+const toolSourceStateListSchema = z.array(toolSourceStateSchema);
4055
4156 function toRegistryState(value: unknown): RegistryState {
4257 const parsed = registryStateSchema.safeParse(value);
@@ -51,23 +66,12 @@ function toRegistryState(value: unknown): RegistryState { · 8 unmodified lines
5166 }
5267
5368 function toToolSourceStateList(value: unknown): ToolSourceState[] {
54− if (!Array.isArray(value)) {
69+ const parsed = toolSourceStateListSchema.safeParse(value);
70+ if (!parsed.success) {
5571 return [];
5672 }
5773
58− const normalized: ToolSourceState[] = [];
59− for (const entry of value) {
60− const parsed = toolSourceStateSchema.safeParse(entry);
61− if (!parsed.success) continue;
62−
63− normalized.push({
64− id: parsed.data.id,
65− updatedAt: parsed.data.updatedAt,
66− enabled: parsed.data.enabled !== false,
67− });
68− }
69−
70− return normalized;
74+ return parsed.data;
7175 }
7276
7377 async function readRegistryState(
@@ -90,7 +94,7 @@ async function readRegistryState( · 16 unmodified lines
9094 };
9195 }
9296
93−export async function getReadyRegistryBuildId(
97+export async function getReadyRegistryBuildIdResult(
9498 ctx: Pick<ActionCtx, "runQuery" | "runAction">,
9599 args: {
96100 workspaceId: Id<"workspaces">;
@@ -98,10 +102,10 @@ export async function getReadyRegistryBuildId( · 1 unmodified line
98102 clientId?: string;
99103 refreshOnStale?: boolean;
100104 },
101−): Promise<string> {
105+): Promise<Result<string, Error>> {
102106 const initial = await readRegistryState(ctx, args.workspaceId);
103107 if (initial.isReady && initial.buildId) {
104− return initial.buildId;
108+ return Result.ok(initial.buildId);
105109 }
106110
107111 if (args.refreshOnStale) {
@@ -113,11 +117,28 @@ export async function getReadyRegistryBuildId( · 5 unmodified lines
113117
114118 const refreshed = await readRegistryState(ctx, args.workspaceId);
115119 if (refreshed.isReady && refreshed.buildId) {
116− return refreshed.buildId;
120+ return Result.ok(refreshed.buildId);
117121 }
118122 }
119123
120− throw new Error(
121− "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
124+ return Result.err(
125+ new Error("Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild."),
122126 );
123127 }
128+
129+export async function getReadyRegistryBuildId(
130+ ctx: Pick<ActionCtx, "runQuery" | "runAction">,
131+ args: {
132+ workspaceId: Id<"workspaces">;
133+ actorId?: string;
134+ clientId?: string;
135+ refreshOnStale?: boolean;
136+ },
137+): Promise<string> {
138+ const buildIdResult = await getReadyRegistryBuildIdResult(ctx, args);
139+ if (buildIdResult.isErr()) {
140+ throw buildIdResult.error;
141+ }
142+
143+ return buildIdResult.value;
144+}
executor/packages/database/src/runtime/tool_source_loading.ts+89−74
@@ -11,8 +11,12 @@ import { · 10 unmodified lines
1111 prepareOpenApiSpec,
1212 type CompiledToolSourceArtifact,
1313 } from "../../../core/src/tool-sources";
14−import { resolveCredentialPayload } from "../../../core/src/credential-providers";
15−import { buildStaticAuthHeaders } from "../../../core/src/tool/source-auth";
14+import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers";
15+import {
16+ buildCredentialAuthHeaders,
17+ buildStaticAuthHeaders,
18+ readCredentialOverrideHeaders,
19+} from "../../../core/src/tool/source-auth";
1620 import type {
1721 ExternalToolSourceConfig,
1822 GraphqlToolSourceConfig,
@@ -28,6 +32,7 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 9 unmodified lines
2832
2933 /** Cache version - bump when tool snapshot/registry/type-hint semantics change. */
3034 const TOOL_SOURCE_CACHE_VERSION = "v25";
35+const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v1";
3136
3237 const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]);
3338
@@ -52,19 +57,6 @@ const openApiAuthSchema = z.union([ · 18 unmodified lines
5257 }),
5358 ]);
5459
55−const bearerSecretSchema = z.object({ token: z.coerce.string().optional() });
56−const apiKeySecretSchema = z.object({
57− value: z.coerce.string().optional(),
58− token: z.coerce.string().optional(),
59−});
60−const basicSecretSchema = z.object({
61− username: z.coerce.string().optional(),
62− password: z.coerce.string().optional(),
63−});
64−const credentialOverrideHeadersSchema = z.object({
65− headers: z.record(z.coerce.string()).optional(),
66−});
67−
6860 const preparedOpenApiSpecSchema = z.object({
6961 servers: z.array(z.string()),
7062 paths: z.record(z.unknown()),
@@ -99,9 +91,31 @@ function toPreparedOpenApiSpec(value: unknown): PreparedOpenApiSpec | null { · 28 unmodified lines
9991 };
10092 }
10193
102−export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
94+export function sourceSignature(
95+ workspaceId: string,
96+ sources: Array<{
97+ id: string;
98+ type?: string;
99+ ownerScopeType?: string;
100+ organizationId?: string;
101+ workspaceId?: string;
102+ specHash?: string;
103+ authFingerprint?: string;
104+ updatedAt: number;
105+ enabled: boolean;
106+ }>,
107+): string {
103108 const parts = sources
104− .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
109+ .map((source) => {
110+ const type = source.type ?? "unknown";
111+ const ownerScopeType = source.ownerScopeType ?? "workspace";
112+ const org = source.organizationId ?? "";
113+ const ws = source.workspaceId ?? "";
114+ const specHash = source.specHash ?? "";
115+ const authFingerprint = source.authFingerprint ?? "";
116+ const enabled = source.enabled ? 1 : 0;
117+ return `${source.id}:${type}:${ownerScopeType}:${org}:${ws}:${specHash}:${authFingerprint}:${source.updatedAt}:${enabled}`;
118+ })
105119 .sort();
106120 return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`;
107121 }
@@ -189,36 +203,23 @@ export function normalizeExternalToolSource(raw: { · 81 unmodified lines
189203 return Result.ok(result);
190204 }
191205
192−function buildHeadersFromCredentialSecret(
193− auth: OpenApiAuth,
194− secret: Record<string, unknown>,
195−): Record<string, string> {
196− if (auth.type === "bearer") {
197− const parsedSecret = bearerSecretSchema.safeParse(secret);
198− const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : "";
199− return token ? { authorization: `Bearer ${token}` } : {};
206+function toCredentialHeaderSpec(auth: OpenApiAuth):
207+ | { authType: "bearer" | "apiKey" | "basic"; headerName?: string }
208+ | null {
209+ if (auth.type === "none") {
210+ return null;
200211 }
201212
202213 if (auth.type === "apiKey") {
203− const parsedSecret = apiKeySecretSchema.safeParse(secret);
204− const value = parsedSecret.success
205− ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim()
206− : "";
207− return value ? { [auth.header]: value } : {};
208− }
209−
210− if (auth.type === "basic") {
211− const parsedSecret = basicSecretSchema.safeParse(secret);
212− const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : "";
213− const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : "";
214− if (!username && !password) {
215− return {};
216− }
217− const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
218− return { authorization: `Basic ${encoded}` };
214+ return {
215+ authType: "apiKey",
216+ headerName: auth.header,
217+ };
219218 }
220219
221− return {};
220+ return {
221+ authType: auth.type,
222+ };
222223 }
223224
224225 async function resolveMcpDiscoveryHeaders(
@@ -258,38 +259,41 @@ async function resolveMcpDiscoveryHeaders( · 33 unmodified lines
258259 };
259260 }
260261
261− try {
262− const secret = await resolveCredentialPayload(record);
263− if (!secret) {
264− return {
265− headers: {},
266− warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`],
267− };
268− }
262+ const secretResult = await resolveCredentialPayloadResult(record);
263+ if (secretResult.isErr()) {
264+ return {
265+ headers: {},
266+ warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${secretResult.error.message}`],
267+ };
268+ }
269269
270− const headers = buildHeadersFromCredentialSecret(auth, secret);
271− const parsedOverrides = credentialOverrideHeadersSchema.safeParse(record.overridesJson ?? {});
272− const overrideHeaders = parsedOverrides.success ? (parsedOverrides.data.headers ?? {}) : {};
273− for (const [key, value] of Object.entries(overrideHeaders)) {
274− if (!key) continue;
275− headers[key] = value;
276− }
270+ const secret = secretResult.value;
271+ if (!secret) {
272+ return {
273+ headers: {},
274+ warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`],
275+ };
276+ }
277277
278− if (Object.keys(headers).length === 0) {
279− return {
280− headers: {},
281− warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`],
282− };
283− }
278+ const authSpec = toCredentialHeaderSpec(auth);
279+ if (!authSpec) {
280+ return { headers: {}, warnings: [] };
281+ }
284282
285− return { headers, warnings: [] };
286− } catch (error) {
287− const message = error instanceof Error ? error.message : String(error);
283+ const headers = buildCredentialAuthHeaders(authSpec, secret);
284+ const overrideHeaders = readCredentialOverrideHeaders(record.overridesJson);
285+ for (const [key, value] of Object.entries(overrideHeaders)) {
286+ headers[key] = value;
287+ }
288+
289+ if (Object.keys(headers).length === 0) {
288290 return {
289291 headers: {},
290− warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${message}`],
292+ warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`],
291293 };
292294 }
295+
296+ return { headers, warnings: [] };
293297 }
294298
295299 async function loadCachedOpenApiSpec(
@@ -308,7 +312,7 @@ async function loadCachedOpenApiSpec( · 12 unmodified lines
308312 try {
309313 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
310314 specUrl,
311− version: TOOL_SOURCE_CACHE_VERSION,
315+ version: OPENAPI_PREPARED_CACHE_VERSION,
312316 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
313317 });
314318
@@ -316,9 +320,15 @@ async function loadCachedOpenApiSpec( · 1 unmodified line
316320 const blob = await ctx.storage.get(entry.storageId);
317321 if (blob) {
318322 const json = await blob.text();
319− const parsedResult = Result.try(() => JSON.parse(json));
320− if (parsedResult.isOk()) {
321− const prepared = toPreparedOpenApiSpec(parsedResult.value);
323+ let parsedJson: unknown;
324+ try {
325+ parsedJson = JSON.parse(json);
326+ } catch {
327+ parsedJson = undefined;
328+ }
329+
330+ if (parsedJson !== undefined) {
331+ const prepared = toPreparedOpenApiSpec(parsedJson);
322332 if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) {
323333 return prepared;
324334 }
@@ -338,7 +348,7 @@ async function loadCachedOpenApiSpec( · 13 unmodified lines
338348 const storageId = await ctx.storage.store(blob);
339349 await ctx.runMutation(internal.openApiSpecCache.putEntry, {
340350 specUrl,
341− version: TOOL_SOURCE_CACHE_VERSION,
351+ version: OPENAPI_PREPARED_CACHE_VERSION,
342352 storageId,
343353 sizeBytes: json.length,
344354 });
@@ -354,7 +364,7 @@ export async function loadSourceArtifact( · 9 unmodified lines
354364 ctx: ActionCtx,
355365 source: ExternalToolSourceConfig,
356366 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
357−): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[]; openApiDts?: string }> {
367+): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[]; openApiDts?: string; openApiSourceKey?: string }> {
358368 const includeDts = options.includeDts ?? true;
359369
360370 if (source.type === "openapi" && typeof source.spec === "string") {
@@ -364,7 +374,12 @@ export async function loadSourceArtifact( · 3 unmodified lines
364374 const warnings = (prepared.warnings ?? []).map(
365375 (warning) => `Source '${source.name}': ${warning}`,
366376 );
367− return { artifact, warnings, openApiDts: prepared.dts };
377+ return {
378+ artifact,
379+ warnings,
380+ openApiDts: prepared.dts,
381+ openApiSourceKey: source.sourceKey ?? `openapi:${source.name}`,
382+ };
368383 } catch (error) {
369384 const message = error instanceof Error ? error.message : String(error);
370385 return {
executor/packages/database/src/runtime/workspace_tools.ts+65−31
@@ -30,7 +30,6 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 29 unmodified lines
3030 import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
3131 import { registrySignatureForWorkspace } from "./tool_registry_state";
3232 import { normalizeToolPathForLookup } from "./tool_paths";
33−import { asPayload } from "../lib/object";
3433
3534 const baseTools = new Map<string, ToolDefinition>();
3635
@@ -39,6 +38,33 @@ const adminAnnouncementInputSchema = z.object({ · 2 unmodified lines
3938 message: z.string().optional(),
4039 });
4140
41+const actorScopedMcpAuthSchema = z.object({
42+ mode: z.literal("actor"),
43+});
44+
45+const toolHintSchema = z.object({
46+ inputHint: z.string().optional(),
47+ outputHint: z.string().optional(),
48+ requiredInputKeys: z.array(z.string()).optional(),
49+ previewInputKeys: z.array(z.string()).optional(),
50+});
51+
52+const payloadRecordSchema = z.record(z.unknown());
53+
54+function toInputPayload(value: unknown): Record<string, unknown> {
55+ const parsed = payloadRecordSchema.safeParse(value);
56+ if (parsed.success) {
57+ return parsed.data;
58+ }
59+
60+ return value === undefined ? {} : { value };
61+}
62+
63+function toJsonSchema(value: unknown): JsonSchema {
64+ const parsed = payloadRecordSchema.safeParse(value);
65+ return parsed.success ? parsed.data : {};
66+}
67+
4268 async function listWorkspaceToolSources(
4369 ctx: ActionCtx,
4470 workspaceId: Id<"workspaces">,
@@ -56,17 +82,23 @@ async function listWorkspaceAccessPolicies( · 11 unmodified lines
5682 }
5783
5884 async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> {
59− const textResult = await Result.tryPromise(async () => await blob.text());
60− if (textResult.isErr()) {
61− return Result.err(new Error(`Failed to read cache blob: ${textResult.error.message}`));
85+ let text: string;
86+ try {
87+ text = await blob.text();
88+ } catch (error) {
89+ const message = error instanceof Error ? error.message : String(error);
90+ return Result.err(new Error(`Failed to read cache blob: ${message}`));
6291 }
6392
64− const jsonResult = Result.try(() => JSON.parse(textResult.value));
65− if (jsonResult.isErr()) {
66− return Result.err(new Error(`Failed to parse cache JSON: ${jsonResult.error.message}`));
93+ let parsedJson: unknown;
94+ try {
95+ parsedJson = JSON.parse(text);
96+ } catch (error) {
97+ const message = error instanceof Error ? error.message : String(error);
98+ return Result.err(new Error(`Failed to parse cache JSON: ${message}`));
6799 }
68100
69− return parseWorkspaceToolSnapshot(jsonResult.value);
101+ return parseWorkspaceToolSnapshot(parsedJson);
70102 }
71103
72104 // Minimal built-in tools used by tests/demos.
@@ -98,7 +130,7 @@ baseTools.set("admin.send_announcement", { · 25 unmodified lines
98130 },
99131 },
100132 run: async (input: unknown) => {
101− const parsedInput = adminAnnouncementInputSchema.safeParse(asPayload(input));
133+ const parsedInput = adminAnnouncementInputSchema.safeParse(toInputPayload(input));
102134 const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : "";
103135 const message = parsedInput.success ? (parsedInput.data.message ?? "") : "";
104136 return { ok: true, channel, message };
@@ -401,13 +433,6 @@ function normalizeHint(type?: string): string { · 296 unmodified lines
401433 return type && type.trim().length > 0 ? type : "unknown";
402434 }
403435
404−function asJsonSchema(value: unknown): JsonSchema {
405− if (!value || typeof value !== "object" || Array.isArray(value)) {
406− return {};
407− }
408− return asPayload(value);
409−}
410−
411436 async function buildWorkspaceToolRegistry(
412437 ctx: ActionCtx,
413438 args: {
@@ -433,24 +458,24 @@ async function buildWorkspaceToolRegistry( · 19 unmodified lines
433458 const normalizedPath = normalizeToolPathForLookup(st.path);
434459 const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();
435460
436− const inputSchema = asJsonSchema(st.typing?.inputSchema);
437− const outputSchema = asJsonSchema(st.typing?.outputSchema);
461+ const inputSchema = toJsonSchema(st.typing?.inputSchema);
462+ const outputSchema = toJsonSchema(st.typing?.outputSchema);
463+ const parsedTyping = toolHintSchema.safeParse(st.typing);
464+ const typing = parsedTyping.success ? parsedTyping.data : {};
438465
439− const requiredInputKeys = Array.isArray(st.typing?.requiredInputKeys)
440− ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string")
441− : extractTopLevelRequiredKeys(inputSchema);
442− const previewInputKeys = Array.isArray(st.typing?.previewInputKeys)
443− ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string")
444− : buildPreviewKeys(inputSchema);
466+ const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema);
467+ const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema);
468+ const inputHint = typing.inputHint?.trim();
469+ const outputHint = typing.outputHint?.trim();
445470
446− const displayInput = typeof st.typing?.inputHint === "string" && st.typing.inputHint.trim().length > 0
447− ? st.typing.inputHint.trim()
471+ const displayInput = inputHint && inputHint.length > 0
472+ ? inputHint
448473 : (Object.keys(inputSchema).length === 0
449474 ? "{}"
450475 : normalizeHint(jsonSchemaTypeHintFallback(inputSchema)));
451476
452− const displayOutput = typeof st.typing?.outputHint === "string" && st.typing.outputHint.trim().length > 0
453− ? st.typing.outputHint.trim()
477+ const displayOutput = outputHint && outputHint.length > 0
478+ ? outputHint
454479 : (Object.keys(outputSchema).length === 0
455480 ? "unknown"
456481 : normalizeHint(jsonSchemaTypeHintFallback(outputSchema)));
@@ -525,6 +550,11 @@ async function buildWorkspaceToolRegistry( · 68 unmodified lines
525550 buildId,
526551 });
527552
553+ await ctx.runMutation(internal.toolRegistry.pruneBuilds, {
554+ workspaceId: args.workspaceId,
555+ maxRetainedBuilds: 2,
556+ });
557+
528558 return { buildId };
529559 }
530560
@@ -553,8 +583,9 @@ export async function getWorkspaceTools( · 22 unmodified lines
553583 if (source.type !== "mcp") {
554584 return false;
555585 }
556− const auth = asPayload(source.config.auth);
557− return auth?.mode === "actor";
586+
587+ const auth = actorScopedMcpAuthSchema.safeParse(source.config.auth);
588+ return auth.success;
558589 });
559590 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
560591 const skipCacheWrite = hasActorScopedMcpSource;
@@ -670,6 +701,7 @@ export async function getWorkspaceTools( · 109 unmodified lines
670701 timedOut: boolean;
671702 sourceName: string;
672703 openApiDts?: string;
704+ openApiSourceKey?: string;
673705 }>((resolve) => {
674706 timer = setTimeout(() => {
675707 resolve({
@@ -678,6 +710,7 @@ export async function getWorkspaceTools( · 2 unmodified lines
678710 timedOut: true,
679711 sourceName: config.name,
680712 openApiDts: undefined,
713+ openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined,
681714 });
682715 }, sourceTimeoutMs);
683716 });
@@ -739,7 +772,8 @@ export async function getWorkspaceTools( · 55 unmodified lines
739772 const openApiDtsBySource: Record<string, string> = {};
740773 for (const loaded of loadedSources) {
741774 if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) {
742− openApiDtsBySource[`openapi:${loaded.sourceName}`] = loaded.openApiDts;
775+ const sourceKey = loaded.openApiSourceKey ?? `openapi:${loaded.sourceName}`;
776+ openApiDtsBySource[sourceKey] = loaded.openApiDts;
743777 }
744778 }
745779 const typeBundle = buildWorkspaceTypeBundle({
executor/packages/runner-sandbox-host/src/bridge.ts+42−35
@@ -1,6 +1,7 @@
11 import { Result } from "better-result";
22 import { api } from "@executor/database/convex/_generated/api";
33 import { ConvexClient, ConvexHttpClient } from "convex/browser";
4+import { z } from "zod";
45 import type {
56 BridgeEntrypointContext,
67 BridgeProps,
@@ -10,33 +11,41 @@ import type { · 3 unmodified lines
1011
1112 const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000;
1213
13−function asObject(value: unknown): Record<string, unknown> {
14− if (value && typeof value === "object" && !Array.isArray(value)) {
15− return value as Record<string, unknown>;
16− }
17− return {};
18−}
19−
20−function isBridgeProps(value: unknown): value is BridgeProps {
21− const props = asObject(value);
22− return (
23− typeof props.callbackConvexUrl === "string"
24− && typeof props.callbackInternalSecret === "string"
25− && typeof props.taskId === "string"
26− );
14+const bridgePropsSchema: z.ZodType<BridgeProps> = z.object({
15+ callbackConvexUrl: z.string(),
16+ callbackInternalSecret: z.string(),
17+ taskId: z.string(),
18+});
19+
20+const toolCallResultSchema: z.ZodType<ToolCallResult> = z.union([
21+ z.object({ ok: z.literal(true), value: z.unknown().optional() }),
22+ z.object({
23+ ok: z.literal(false),
24+ kind: z.literal("pending"),
25+ approvalId: z.string(),
26+ error: z.string().optional(),
27+ retryAfterMs: z.number().optional(),
28+ }),
29+ z.object({
30+ ok: z.literal(false),
31+ kind: z.enum(["denied", "failed"]),
32+ error: z.string().optional(),
33+ approvalId: z.string().optional(),
34+ retryAfterMs: z.number().optional(),
35+ }),
36+]);
37+
38+const recordSchema = z.record(z.unknown());
39+
40+function hasToolBridgeExport(
41+ value: Record<string, unknown>,
42+): value is { ToolBridge: WorkerEntrypointExports["ToolBridge"] } {
43+ return typeof value.ToolBridge === "function";
2744 }
2845
29−function isToolCallResult(value: unknown): value is ToolCallResult {
30− const record = asObject(value);
31− if (record.ok === true) {
32− return true;
33− }
34−
35− if (record.ok === false) {
36− return record.kind === "pending" || record.kind === "denied" || record.kind === "failed";
37− }
38−
39− return false;
46+function asObject(value: unknown): Record<string, unknown> {
47+ const parsed = recordSchema.safeParse(value);
48+ return parsed.success ? parsed.data : {};
4049 }
4150
4251 export function getBridgePropsFromContext(
@@ -47,11 +56,12 @@ export function getBridgePropsFromContext( · 4 unmodified lines
4756 throw new Error("WorkerEntrypoint context is unavailable");
4857 }
4958
50− if (!isBridgeProps(context.props)) {
59+ const parsedProps = bridgePropsSchema.safeParse(context.props);
60+ if (!parsedProps.success) {
5161 throw new Error("ToolBridge props are missing or invalid");
5262 }
5363
54− return context.props;
64+ return parsedProps.data;
5565 }
5666
5767 export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExports {
@@ -63,11 +73,11 @@ export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExp · 5 unmodified lines
6373 }
6474
6575 const exportsObject = asObject(exportsValue);
66− if (typeof exportsObject.ToolBridge !== "function") {
76+ if (!hasToolBridgeExport(exportsObject)) {
6777 throw new Error("Execution context ToolBridge export is unavailable");
6878 }
6979
70− return { ToolBridge: exportsObject.ToolBridge as WorkerEntrypointExports["ToolBridge"] };
80+ return { ToolBridge: exportsObject.ToolBridge };
7181 }
7282
7383 function createConvexClient(callbackConvexUrl: string): ConvexHttpClient {
@@ -157,16 +167,13 @@ export async function callToolWithBridge( · 83 unmodified lines
157167 return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` };
158168 }
159169
160− const result = response.value;
161− if (!isToolCallResult(result)) {
170+ const parsedResult = toolCallResultSchema.safeParse(response.value);
171+ if (!parsedResult.success) {
162172 return { ok: false, kind: "failed", error: "Tool callback returned invalid result payload" };
163173 }
174+ const result = parsedResult.data;
164175
165176 if (!result.ok && result.kind === "pending") {
166− if (!result.approvalId) {
167− return { ok: false, kind: "failed", error: "Approval pending without approvalId" };
168− }
169−
170177 const approvalId = result.approvalId;
171178 const wait = await Result.tryPromise(() => waitForApprovalUpdate(props, approvalId));
172179 if (wait.isErr()) {
executor/packages/runner-sandbox-host/src/request.ts+18−6
@@ -1,19 +1,31 @@
1−import { Result } from "better-result";
1+import { z } from "zod";
22 import type { RunRequest } from "./types";
33
4+const runRequestSchema: z.ZodType<RunRequest> = z.object({
5+ taskId: z.string().min(1),
6+ code: z.string().min(1),
7+ timeoutMs: z.number().int().positive().optional(),
8+ callback: z.object({
9+ convexUrl: z.string().min(1),
10+ internalSecret: z.string().min(1),
11+ }),
12+});
13+
414 export async function parseRunRequest(request: Request): Promise<RunRequest | Response> {
5− const parsed = await Result.tryPromise(() => request.json() as Promise<RunRequest>);
6− if (parsed.isErr()) {
15+ let rawBody: unknown;
16+ try {
17+ rawBody = await request.json();
18+ } catch {
719 return Response.json({ error: "Invalid JSON body" }, { status: 400 });
820 }
921
10− const body = parsed.value;
11− if (!body.taskId || !body.code || !body.callback?.convexUrl || !body.callback?.internalSecret) {
22+ const body = runRequestSchema.safeParse(rawBody);
23+ if (!body.success) {
1224 return Response.json(
1325 { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" },
1426 { status: 400 },
1527 );
1628 }
1729
18− return body;
30+ return body.data;
1931 }
executor/packages/runner-sandbox-host/src/sandbox.ts+32−15
@@ -1,7 +1,14 @@
1−import { Result } from "better-result";
1+import { z } from "zod";
22 import type { Env, RunRequest, RunResult } from "./types";
33 import { getEntrypointExports } from "./bridge";
44
5+const runResultSchema: z.ZodType<RunResult> = z.object({
6+ status: z.enum(["completed", "failed", "timed_out", "denied"]),
7+ result: z.unknown().optional(),
8+ error: z.string().optional(),
9+ exitCode: z.number().optional(),
10+});
11+
512 /**
613 * Build the user code module. The code is wrapped in an exported async
714 * function `run(tools, console)` so the harness can call it with controlled
@@ -51,32 +58,42 @@ export async function executeSandboxRun( · 43 unmodified lines
5158 const controller = new AbortController();
5259 const timer = setTimeout(() => controller.abort(), timeoutMs);
5360
54− const response = await Result.tryPromise(() =>
55− entrypoint.fetch("http://sandbox.internal/run", {
61+ let response: Response;
62+ try {
63+ response = await entrypoint.fetch("http://sandbox.internal/run", {
5664 method: "POST",
5765 signal: controller.signal,
58− }),
59− );
60−
61− clearTimeout(timer);
62−
63− if (response.isErr()) {
64− const cause = response.error.cause;
65− if (cause instanceof DOMException && cause.name === "AbortError") {
66+ });
67+ } catch (error) {
68+ clearTimeout(timer);
69+ if (error instanceof DOMException && error.name === "AbortError") {
6670 return {
6771 status: "timed_out",
6872 error: `Execution timed out after ${timeoutMs}ms`,
6973 };
7074 }
71− throw cause;
75+ throw error;
7276 }
7377
74− const body = await Result.tryPromise(() => response.value.json() as Promise<RunResult>);
75− if (body.isErr()) {
78+ clearTimeout(timer);
79+
80+ let body: unknown;
81+ try {
82+ body = await response.json();
83+ } catch {
7684 return {
7785 status: "failed",
7886 error: "Sandbox isolate returned invalid JSON",
7987 };
8088 }
81− return body.value;
89+
90+ const parsedBody = runResultSchema.safeParse(body);
91+ if (!parsedBody.success) {
92+ return {
93+ status: "failed",
94+ error: "Sandbox isolate returned invalid response payload",
95+ };
96+ }
97+
98+ return parsedBody.data;
8299 }
executor/packages/runner-sandbox-host/src/types.ts+20−9
@@ -28,7 +28,7 @@ export interface EntrypointStub { · 27 unmodified lines
2828 export interface RunRequest {
2929 taskId: string;
3030 code: string;
31− timeoutMs: number;
31+ timeoutMs?: number;
3232 callback: {
3333 convexUrl: string;
3434 internalSecret: string;
@@ -42,14 +42,25 @@ export interface RunResult { · 7 unmodified lines
4242 exitCode?: number;
4343 }
4444
45−export interface ToolCallResult {
46− ok: true | false;
47− value?: unknown;
48− error?: string;
49− kind?: "pending" | "denied" | "failed";
50− approvalId?: string;
51− retryAfterMs?: number;
52−}
45+export type ToolCallResult =
46+ | {
47+ ok: true;
48+ value?: unknown;
49+ }
50+ | {
51+ ok: false;
52+ kind: "pending";
53+ approvalId: string;
54+ error?: string;
55+ retryAfterMs?: number;
56+ }
57+ | {
58+ ok: false;
59+ kind: "denied" | "failed";
60+ error?: string;
61+ approvalId?: string;
62+ retryAfterMs?: number;
63+ };
5364
5465 export interface BridgeProps {
5566 callbackConvexUrl: string;
opencode.json+21−11
@@ -1,18 +1,18 @@
11 {
22 "$schema": "https://opencode.ai/config.json",
3− "mcp":{
3+ "mcp": {
44 "answeroverflow": {
5− "type": "remote",
6− "url": "https://www.answeroverflow.com/mcp",
7− "enabled": true
8− },
9− "oracle": {
10− "type": "local",
11− "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"],
12− "enabled": true
13− }
5+ "type": "remote",
6+ "url": "https://www.answeroverflow.com/mcp",
7+ "enabled": true
8+ },
9+ "oracle": {
10+ "type": "local",
11+ "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"],
12+ "enabled": true
13+ }
1414 },
15− "agent":{
15+ "agent": {
1616 "quick-build": {
1717 "mode": "primary",
1818 "description": "A smaller context winder but extremely fast at working",
@@ -22,6 +22,16 @@ · 3 unmodified lines
2222 "edit": true,
2323 "bash": true
2424 }
25+ },
26+ "quick-build-subagent": {
27+ "mode": "subagent",
28+ "description": "A smaller context winder but extremely fast at working",
29+ "model": "openai/gpt-5.3-codex-spark",
30+ "tools": {
31+ "write": true,
32+ "edit": true,
33+ "bash": true
34+ }
2535 }
2636 }
2737 }