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 @@
1import type { NextRequest } from "next/server";1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { z } from "zod";
4
5const anonymousTokenRequestSchema = z.object({
6 actorId: z.string().trim().min(1).optional(),
7});
3 8
4function toSiteUrl(convexUrl?: string): string | null {9function toSiteUrl(convexUrl?: string): string | null {
5 if (!convexUrl) {10 if (!convexUrl) {
@@ -48,9 +53,9 @@ export async function POST(request: NextRequest) { · 42 unmodified lines
48 53
49 let body: { actorId?: string } = {};54 let body: { actorId?: string } = {};
50 try {55 try {
51 const parsed = await request.json() as { actorId?: unknown };56 const parsed = anonymousTokenRequestSchema.safeParse(await request.json());
52 if (typeof parsed.actorId === "string" && parsed.actorId.trim().length > 0) {57 if (parsed.success && parsed.data.actorId) {
53 body.actorId = parsed.actorId.trim();58 body.actorId = parsed.data.actorId;
54 }59 }
55 } catch {60 } catch {
56 body = {};61 body = {};
@@ -67,7 +72,7 @@ export async function POST(request: NextRequest) { · 10 unmodified lines
67 });72 });
68 73
69 const text = await response.text();74 const text = await response.text();
70 let payload: unknown = null;75 let payload: unknown;
71 try {76 try {
72 payload = JSON.parse(text);77 payload = JSON.parse(text);
73 } catch {78 } catch {
executor/apps/web/src/lib/mcp/oauth-provider.test.ts+51−0
@@ -0,0 +1,51 @@
1import { expect, test } from "bun:test";
2import {
3 decodePendingCookieValue,
4 decodePopupResultCookieValue,
5 encodePendingCookieValue,
6 encodePopupResultCookieValue,
7 type McpOAuthPending,
8 type McpOAuthPopupResult,
9} from "./oauth-provider";
10
11test("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
23test("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
34test("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
48test("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 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { Result } from "better-result";2import { Result } from "better-result";
3import { z } from "zod";
3import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";4import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4import type {5import type {
5 OAuthClientInformationMixed,6 OAuthClientInformationMixed,
@@ -28,6 +29,44 @@ export type McpOAuthPopupResult = { · 22 unmodified lines
28const COOKIE_PREFIX = "executor_mcp_oauth_";29const COOKIE_PREFIX = "executor_mcp_oauth_";
29export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";30export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";
30 31
32const 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
40const versionedPendingSchema = z.object({
41 version: z.literal(1),
42 pending: pendingSchema,
43});
44
45const 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
55const versionedPopupResultSchema = z.object({
56 version: z.literal(1),
57 result: popupResultSchema,
58});
59
60function 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
31export function buildPendingCookieName(state: string): string {70export function buildPendingCookieName(state: string): string {
32 return `${COOKIE_PREFIX}${state}`;71 return `${COOKIE_PREFIX}${state}`;
33}72}
@@ -37,67 +76,41 @@ export function createOAuthState(): string { · 3 unmodified lines
37}76}
38 77
39export function encodePendingCookieValue(pending: McpOAuthPending): string {78export 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");
41}80}
42 81
43export function decodePendingCookieValue(raw: string): McpOAuthPending | null {82export function decodePendingCookieValue(raw: string): McpOAuthPending | null {
44 const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));83 const decoded = decodeCookieJson(raw);
45 if (!decodedResult.isOk()) {84 if (!decoded) {
46 return null;
47 }
48
49 const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>);
50 if (!parsedResult.isOk()) {
51 return null;85 return null;
52 }86 }
53 87
54 const parsed = parsedResult.value;88 const versionedResult = versionedPendingSchema.safeParse(decoded);
55 if (89 if (versionedResult.success) {
56 typeof parsed.state !== "string"90 return versionedResult.data.pending;
57 || typeof parsed.sourceUrl !== "string"
58 || typeof parsed.redirectUrl !== "string"
59 ) {
60 return null;
61 }91 }
62 92
63 return {93 const legacyResult = pendingSchema.safeParse(decoded);
64 state: parsed.state,94 return legacyResult.success ? legacyResult.data : null;
65 sourceUrl: parsed.sourceUrl,
66 redirectUrl: parsed.redirectUrl,
67 ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}),
68 ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}),
69 };
70}95}
71 96
72export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {97export 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");
74}99}
75 100
76export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {101export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {
77 const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));102 const decoded = decodeCookieJson(raw);
78 if (!decodedResult.isOk()) {103 if (!decoded) {
79 return null;104 return null;
80 }105 }
81 106
82 const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>);107 const versionedResult = versionedPopupResultSchema.safeParse(decoded);
83 if (!parsedResult.isOk()) {108 if (versionedResult.success) {
84 return null;109 return versionedResult.data.result;
85 }
86
87 const parsed = parsedResult.value;
88 if (typeof parsed.ok !== "boolean") {
89 return null;
90 }110 }
91 111
92 return {112 const legacyResult = popupResultSchema.safeParse(decoded);
93 ok: parsed.ok,113 return legacyResult.success ? legacyResult.data : null;
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 };
101}114}
102 115
103export class McpPopupOAuthProvider implements OAuthClientProvider {116export class McpPopupOAuthProvider implements OAuthClientProvider {
executor/apps/web/src/lib/openapi/spec-inspector.ts+58−34
@@ -1,4 +1,6 @@
1import { Result } from "better-result";
1import { parse as parseYaml } from "yaml";2import { parse as parseYaml } from "yaml";
3import { z } from "zod";
2import type { CredentialScope, SourceAuthType } from "@/lib/types";4import type { CredentialScope, SourceAuthType } from "@/lib/types";
3 5
4type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">;6type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">;
@@ -15,11 +17,36 @@ type OpenApiInspectionResult = { · 10 unmodified lines
15 inferredAuth: InferredSpecAuth;17 inferredAuth: InferredSpecAuth;
16};18};
17 19
18function asRecord(value: unknown): Record<string, unknown> {20const recordSchema = z.record(z.unknown());
19 if (!value || typeof value !== "object" || Array.isArray(value)) {21
20 return {};22const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
23
24const 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
31function toRecordValue(value: unknown): Record<string, unknown> {
32 const parsed = recordSchema.safeParse(value);
33 return parsed.success ? parsed.data : {};
34}
35
36function 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;
21 }47 }
22 return value as Record<string, unknown>;48
49 return parsedRecord.data;
23}50}
24 51
25function parseOpenApiPayload(raw: string, sourceUrl: string, contentType: string): Record<string, unknown> {52function 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
27 const loweredUrl = sourceUrl.toLowerCase();54 const loweredUrl = sourceUrl.toLowerCase();
28 const preferJson = loweredContentType.includes("json") || loweredUrl.endsWith(".json");55 const preferJson = loweredContentType.includes("json") || loweredUrl.endsWith(".json");
29 56
30 const tryJson = () => asRecord(JSON.parse(raw));57 const primary = preferJson
31 const tryYaml = () => asRecord(parseYaml(raw));58 ? parseObjectFromText(raw, "json")
32 59 : parseObjectFromText(raw, "yaml");
33 const parsed = preferJson60 const fallback = preferJson
34 ? (() => {61 ? parseObjectFromText(raw, "yaml")
35 try {62 : parseObjectFromText(raw, "json");
36 return tryJson();63 const parsed = primary ?? fallback;
37 } catch {64
38 return tryYaml();65 if (!parsed) {
39 }
40 })()
41 : (() => {
42 try {
43 return tryYaml();
44 } catch {
45 return tryJson();
46 }
47 })();
48
49 if (Object.keys(parsed).length === 0) {
50 throw new Error("Spec payload is empty or not an object");66 throw new Error("Spec payload is empty or not an object");
51 }67 }
52 68
53 return parsed;69 return parsed;
54}70}
55 71
56function normalizeAuthScheme(scheme: Record<string, unknown>): {72function normalizeAuthScheme(scheme: unknown): {
57 type: SupportedAuthType;73 type: SupportedAuthType;
58 header?: string;74 header?: string;
59} | null {75} | 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();
61 82
62 if (type === "http") {83 if (type === "http") {
63 const httpScheme = String(scheme.scheme ?? "").toLowerCase();84 const httpScheme = (parsedScheme.data.scheme ?? "").toLowerCase();
64 if (httpScheme === "bearer") {85 if (httpScheme === "bearer") {
65 return { type: "bearer" };86 return { type: "bearer" };
66 }87 }
@@ -71,8 +92,8 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 4 unmodified lines
71 }92 }
72 93
73 if (type === "apikey") {94 if (type === "apikey") {
74 const location = String(scheme.in ?? "").toLowerCase();95 const location = (parsedScheme.data.in ?? "").toLowerCase();
75 const header = typeof scheme.name === "string" ? scheme.name.trim() : "";96 const header = (parsedScheme.data.name ?? "").trim();
76 if (location === "header" && header.length > 0) {97 if (location === "header" && header.length > 0) {
77 return { type: "apiKey", header };98 return { type: "apiKey", header };
78 }99 }
@@ -87,23 +108,26 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 8 unmodified lines
87}108}
88 109
89function inferSecuritySchemaAuth(spec: Record<string, unknown>): InferredSpecAuth {110function inferSecuritySchemaAuth(spec: Record<string, unknown>): InferredSpecAuth {
90 const components = asRecord(spec.components);111 const components = toRecordValue(spec.components);
91 const securitySchemes = asRecord(components.securitySchemes);112 const securitySchemes = toRecordValue(components.securitySchemes);
92 const schemeNames = Object.keys(securitySchemes);113 const schemeNames = Object.keys(securitySchemes);
93 if (schemeNames.length === 0) {114 if (schemeNames.length === 0) {
94 return { type: "none", inferred: true };115 return { type: "none", inferred: true };
95 }116 }
96 117
97 const globalSecurity = Array.isArray(spec.security)118 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 })
99 : [];123 : [];
100 const referencedSchemeNames = globalSecurity.flatMap((entry) => Object.keys(entry));124 const referencedSchemeNames = globalSecurity.flatMap((entry) => Object.keys(entry));
101 const candidateNames = referencedSchemeNames.length > 0125 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)))]
103 : schemeNames;127 : schemeNames;
104 128
105 const normalized = candidateNames129 const normalized = candidateNames
106 .map((name) => normalizeAuthScheme(asRecord(securitySchemes[name])))130 .map((name) => normalizeAuthScheme(securitySchemes[name]))
107 .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));131 .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
108 132
109 if (normalized.length === 0) {133 if (normalized.length === 0) {
executor/apps/web/src/lib/session-storage.ts+49−5
@@ -1,9 +1,16 @@
1import type { Id } from "@executor/database/convex/_generated/dataModel";1import type { Id } from "@executor/database/convex/_generated/dataModel";
2import { z } from "zod";
2 3
3const SESSION_KEY = "executor_session_id";4const SESSION_KEY = "executor_session_id";
4const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";5const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id";
5const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";6const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account";
6 7
8const workspaceByAccountSchema = z.record(z.string());
9
10function isWorkspaceId(value: string): value is Id<"workspaces"> {
11 return value.trim().length > 0;
12}
13
7export function readStoredSessionId() {14export function readStoredSessionId() {
8 if (typeof window === "undefined") {15 if (typeof window === "undefined") {
9 return null;16 return null;
@@ -17,7 +24,12 @@ export function readStoredActiveWorkspaceId() { · 7 unmodified lines
17 return null;24 return null;
18 }25 }
19 26
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;
21}33}
22 34
23export function persistSessionId(sessionId: string) {35export function persistSessionId(sessionId: string) {
@@ -33,18 +45,50 @@ export function clearSessionStorage() { · 9 unmodified lines
33 localStorage.removeItem(ACTIVE_WORKSPACE_KEY);45 localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
34}46}
35 47
36export function readWorkspaceByAccount() {48export function readWorkspaceByAccount(): Record<string, Id<"workspaces">> {
49 if (typeof window === "undefined") {
50 return {};
51 }
52
37 const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);53 const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);
38 if (!raw) {54 if (!raw) {
39 return {} as Record<string, Id<"workspaces">>;55 return {};
40 }56 }
41 57
58 let parsedJson: unknown;
42 try {59 try {
43 const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>;60 parsedJson = JSON.parse(raw);
44 return parsed ?? {};
45 } catch {61 } catch {
46 return {};62 return {};
47 }63 }
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;
48}92}
49 93
50function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {94function 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
5export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied";5export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied";
6export type ApprovalStatus = "pending" | "approved" | "denied";6export type ApprovalStatus = "pending" | "approved" | "denied";
7export type PolicyDecision = "allow" | "require_approval" | "deny";7export type PolicyDecision = "allow" | "require_approval" | "deny";
8export type PolicyScopeType = "organization" | "workspace";
9export type PolicyMatchType = "glob" | "exact";
10export type PolicyEffect = "allow" | "deny";
11export type PolicyApprovalMode = "inherit" | "auto" | "required";
8export type CredentialScope = "workspace" | "actor";12export type CredentialScope = "workspace" | "actor";
9export type CredentialProvider = "local-convex" | "workos-vault";13export type CredentialProvider = "local-convex" | "workos-vault";
14export type OwnerScopeType = "organization" | "workspace";
10export type ToolApprovalMode = "auto" | "required";15export type ToolApprovalMode = "auto" | "required";
11export type ToolSourceType = "mcp" | "openapi" | "graphql";16export type ToolSourceType = "mcp" | "openapi" | "graphql";
12 17
@@ -65,9 +70,17 @@ export interface TaskEventRecord { · 52 unmodified lines
65 70
66export interface AccessPolicyRecord {71export interface AccessPolicyRecord {
67 id: string;72 id: string;
68 workspaceId: string;73 scopeType?: PolicyScopeType;
74 organizationId?: string;
75 workspaceId?: string;
76 targetActorId?: string;
69 actorId?: string;77 actorId?: string;
70 clientId?: string;78 clientId?: string;
79 resourceType?: "tool_path";
80 resourcePattern?: string;
81 matchType?: PolicyMatchType;
82 effect?: PolicyEffect;
83 approvalMode?: PolicyApprovalMode;
71 toolPathPattern: string;84 toolPathPattern: string;
72 decision: PolicyDecision;85 decision: PolicyDecision;
73 priority: number;86 priority: number;
@@ -78,7 +91,9 @@ export interface AccessPolicyRecord { · 4 unmodified lines
78export interface CredentialRecord {91export interface CredentialRecord {
79 id: string;92 id: string;
80 bindingId?: string;93 bindingId?: string;
81 workspaceId: string;94 ownerScopeType?: OwnerScopeType;
95 organizationId?: string;
96 workspaceId?: string;
82 sourceKey: string;97 sourceKey: string;
83 scope: CredentialScope;98 scope: CredentialScope;
84 actorId?: string;99 actorId?: string;
@@ -92,10 +107,14 @@ export interface CredentialRecord { · 7 unmodified lines
92 107
93export interface ToolSourceRecord {108export interface ToolSourceRecord {
94 id: string;109 id: string;
95 workspaceId: string;110 ownerScopeType?: OwnerScopeType;
111 organizationId?: string;
112 workspaceId?: string;
96 name: string;113 name: string;
97 type: ToolSourceType;114 type: ToolSourceType;
98 config: Record<string, unknown>;115 config: Record<string, unknown>;
116 specHash?: string;
117 authFingerprint?: string;
99 enabled: boolean;118 enabled: boolean;
100 createdAt: number;119 createdAt: number;
101 updatedAt: number;120 updatedAt: number;
executor/packages/core/src/credential-providers.test.ts+30−1
@@ -1,5 +1,5 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { resolveCredentialPayload } from "./credential-providers";2import { resolveCredentialPayload, resolveCredentialPayloadResult } from "./credential-providers";
3 3
4test("local provider returns stored payload", async () => {4test("local provider returns stored payload", async () => {
5 const payload = await resolveCredentialPayload({5 const payload = await resolveCredentialPayload({
@@ -46,6 +46,23 @@ test("WorkOS Vault provider falls back to id key and raw token", async () => { · 40 unmodified lines
46 expect(payload).toEqual({ token: "ghp_raw_token" });46 expect(payload).toEqual({ token: "ghp_raw_token" });
47});47});
48 48
49test("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
49test("WorkOS Vault provider gives actionable error on missing reference", async () => {66test("WorkOS Vault provider gives actionable error on missing reference", async () => {
50 await expect(67 await expect(
51 resolveCredentialPayload({68 resolveCredentialPayload({
@@ -54,3 +71,15 @@ test("WorkOS Vault provider gives actionable error on missing reference", async · 2 unmodified lines
54 }),71 }),
55 ).rejects.toThrow("Re-save this credential");72 ).rejects.toThrow("Re-save this credential");
56});73});
74
75test("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 @@
1"use node";1"use node";
2 2
3import { WorkOS } from "@workos-inc/node";
4import { Result } from "better-result";3import { Result } from "better-result";
5import { z } from "zod";4import { z } from "zod";
5import {
6 createWorkosClient,
7 parseWorkosVaultReference,
8 withWorkosVaultRetryResult,
9} from "./credentials/workos-vault";
6import type { CredentialProvider, CredentialRecord } from "./types";10import type { CredentialProvider, CredentialRecord } from "./types";
7import { asRecord } from "./utils";
8 11
9type CredentialPayload = Record<string, unknown>;12type CredentialPayload = Record<string, unknown>;
10 13
@@ -18,67 +21,31 @@ type VaultObjectReader = (input: VaultReadInput) => Promise<string>; · 7 unmodified lines
18type ProviderResolver = (21type ProviderResolver = (
19 record: Pick<CredentialRecord, "provider" | "secretJson">,22 record: Pick<CredentialRecord, "provider" | "secretJson">,
20 readVaultObject: VaultObjectReader,23 readVaultObject: VaultObjectReader,
21) => Promise<CredentialPayload | null>;24) => Promise<Result<CredentialPayload | null, Error>>;
22 25
23const workosVaultSecretConfigSchema = z.object({26const recordSchema = z.record(z.unknown());
24 objectId: z.string().optional(),
25 id: z.string().optional(),
26 apiKey: z.string().optional(),
27});
28 27
29function resolveWorkosApiKey(explicitApiKey?: string): string {28function coerceRecord(value: unknown): Record<string, unknown> {
30 const candidate =29 const parsed = recordSchema.safeParse(value);
31 explicitApiKey?.trim() ||30 return parsed.success ? parsed.data : {};
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;
40}31}
41 32
42function isRetryableVaultError(error: unknown): boolean {33const envSecretKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
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}
53 34
54async function sleep(ms: number): Promise<void> {35async function defaultReadVaultObject(input: VaultReadInput): Promise<string> {
55 await new Promise((resolve) => setTimeout(resolve, ms));36 const workosResult = createWorkosClient(input.apiKey);
56}37 if (workosResult.isErr()) {
57 38 throw new Error("WorkOS Vault provider requires WORKOS_API_KEY");
58async 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 }
72 }39 }
73 40
74 throw new Error("Unreachable retry state");41 const readResult = await withWorkosVaultRetryResult(async () => {
75}42 return await workosResult.value.vault.readObject({ id: input.objectId });
76
77async 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 });
81 });43 });
44 if (readResult.isErr()) {
45 throw readResult.error;
46 }
47
48 const object = readResult.value;
82 const value = typeof object.value === "string" ? object.value : "";49 const value = typeof object.value === "string" ? object.value : "";
83 if (!value.trim()) {50 if (!value.trim()) {
84 throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`);51 throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`);
@@ -92,41 +59,91 @@ function parseSecretValue(raw: string): CredentialPayload { · 7 unmodified lines
92 return {};59 return {};
93 }60 }
94 61
95 const parsedJson = Result.try(() => JSON.parse(value));62 try {
96 if (parsedJson.isOk()) {63 const parsed = JSON.parse(value);
97 const parsed = parsedJson.value;64 const asObject = coerceRecord(parsed);
98 const asObject = asRecord(parsed);
99 if (Object.keys(asObject).length > 0) {65 if (Object.keys(asObject).length > 0) {
100 return asObject;66 return asObject;
101 }67 }
68 } catch {
69 // Continue with env-style and token fallback parsing.
70 }
71
72 const envStyleResult = parseEnvStyleSecret(value);
73 if (envStyleResult) {
74 return envStyleResult;
102 }75 }
103 76
104 return { token: value };77 return { token: value };
105}78}
106 79
80function 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
90function 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
107async function resolveLocalConvex(119async function resolveLocalConvex(
108 record: Pick<CredentialRecord, "secretJson">,120 record: Pick<CredentialRecord, "secretJson">,
109): Promise<CredentialPayload | null> {121): Promise<Result<CredentialPayload | null, Error>> {
110 return asRecord(record.secretJson);122 return Result.ok(coerceRecord(record.secretJson));
111}123}
112 124
113async function resolveWorkosVault(125async function resolveWorkosVault(
114 record: Pick<CredentialRecord, "secretJson">,126 record: Pick<CredentialRecord, "secretJson">,
115 readVaultObject: VaultObjectReader,127 readVaultObject: VaultObjectReader,
116): Promise<CredentialPayload | null> {128): Promise<Result<CredentialPayload | null, Error>> {
117 const parsedConfig = workosVaultSecretConfigSchema.safeParse(asRecord(record.secretJson));129 const parsedReference = parseWorkosVaultReference(coerceRecord(record.secretJson));
118 const config = parsedConfig.success ? parsedConfig.data : {};130 const objectId = parsedReference.objectId ?? "";
119 const objectId = config.objectId?.trim() || config.id?.trim() || "";
120 131
121 if (!objectId) {132 if (!objectId) {
122 throw new Error(133 return Result.err(
123 "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.",134 new Error("Encrypted credential is missing its secure reference. Re-save this credential in the dashboard."),
124 );135 );
125 }136 }
126 const apiKey = config.apiKey?.trim() || undefined;137 const apiKey = parsedReference.apiKey;
127 138
128 const raw = await readVaultObject({ objectId, apiKey });139 const rawResult = await Result.tryPromise(async () => {
129 return parseSecretValue(raw);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));
130}147}
131 148
132const providers: Record<CredentialProvider, ProviderResolver> = {149const providers: Record<CredentialProvider, ProviderResolver> = {
@@ -138,10 +155,22 @@ export async function resolveCredentialPayload( · 5 unmodified lines
138 record: Pick<CredentialRecord, "provider" | "secretJson">,155 record: Pick<CredentialRecord, "provider" | "secretJson">,
139 options?: { readVaultObject?: VaultObjectReader },156 options?: { readVaultObject?: VaultObjectReader },
140): Promise<CredentialPayload | null> {157): 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
166export async function resolveCredentialPayloadResult(
167 record: Pick<CredentialRecord, "provider" | "secretJson">,
168 options?: { readVaultObject?: VaultObjectReader },
169): Promise<Result<CredentialPayload | null, Error>> {
141 const provider = record.provider;170 const provider = record.provider;
142 const resolver = providers[provider];171 const resolver = providers[provider];
143 if (!resolver) {172 if (!resolver) {
144 throw new Error(`Unsupported credential provider: ${provider}`);173 return Result.err(new Error(`Unsupported credential provider: ${provider}`));
145 }174 }
146 175
147 return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject);176 return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject);
executor/packages/core/src/credentials/workos-vault.test.ts+64−0
@@ -0,0 +1,64 @@
1import { expect, test } from "bun:test";
2import {
3 extractWorkosVaultObjectId,
4 isWorkosVaultRetryableError,
5 parseWorkosVaultReference,
6 withWorkosVaultRetryResult,
7} from "./workos-vault";
8
9test("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
18test("extractWorkosVaultObjectId returns null when missing", () => {
19 expect(extractWorkosVaultObjectId({ apiKey: "key" })).toBeNull();
20 expect(extractWorkosVaultObjectId({ id: "secret_789" })).toBe("secret_789");
21});
22
23test("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
29test("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
50test("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
3import { WorkOS } from "@workos-inc/node";
4import { Result } from "better-result";
5import { z } from "zod";
6
7const workosVaultReferenceSchema = z.object({
8 objectId: z.string().optional(),
9 id: z.string().optional(),
10 apiKey: z.string().optional(),
11});
12
13function normalizeUnknownError(error: unknown): Error {
14 return error instanceof Error ? error : new Error(String(error));
15}
16
17function normalizeDelayMs(value: number, fallback: number): number {
18 if (!Number.isFinite(value) || value < 0) {
19 return fallback;
20 }
21 return Math.floor(value);
22}
23
24function normalizeAttempts(value: number, fallback: number): number {
25 if (!Number.isFinite(value) || value < 1) {
26 return fallback;
27 }
28 return Math.floor(value);
29}
30
31export 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
49export function extractWorkosVaultObjectId(value: unknown): string | null {
50 const parsed = parseWorkosVaultReference(value);
51 return parsed.objectId ?? null;
52}
53
54export 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
63export 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
72export 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
85async function sleep(ms: number): Promise<void> {
86 await new Promise((resolve) => setTimeout(resolve, ms));
87}
88
89export interface WorkosVaultRetryOptions {
90 maxAttempts?: number;
91 initialDelayMs?: number;
92 maxDelayMs?: number;
93 exhaustionErrorMessage?: string;
94}
95
96export 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 @@
1import type { GraphqlExecutionEnvelope } from "../tool/source-execution";1import type { GraphqlExecutionEnvelope } from "../tool/source-execution";
2import { asRecord } from "../utils";2import { z } from "zod";
3
4const recordSchema = z.record(z.unknown());
5
6function coerceRecord(value: unknown): Record<string, unknown> {
7 const parsed = recordSchema.safeParse(value);
8 return parsed.success ? parsed.data : {};
9}
3 10
4export interface GqlTypeRef {11export interface GqlTypeRef {
5 kind: string;12 kind: string;
@@ -49,7 +56,7 @@ export function selectGraphqlFieldEnvelope( · 43 unmodified lines
49 envelope: GraphqlExecutionEnvelope,56 envelope: GraphqlExecutionEnvelope,
50 operationName: string,57 operationName: string,
51): GraphqlExecutionEnvelope {58): GraphqlExecutionEnvelope {
52 const record = asRecord(envelope.data);59 const record = coerceRecord(envelope.data);
53 if (Object.prototype.hasOwnProperty.call(record, operationName)) {60 if (Object.prototype.hasOwnProperty.call(record, operationName)) {
54 return {61 return {
55 data: record[operationName],62 data: record[operationName],
@@ -82,7 +89,7 @@ export function normalizeGraphqlFieldVariables( · 26 unmodified lines
82 && typeof value === "object"89 && typeof value === "object"
83 && !Array.isArray(value)90 && !Array.isArray(value)
84 ) {91 ) {
85 const nested = asRecord(value);92 const nested = coerceRecord(value);
86 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {93 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {
87 return { [argName]: nested[argName] };94 return { [argName]: nested[argName] };
88 }95 }
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
82 try {82 try {
83 const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8");83 const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8");
84 const parsed = convexJsonSchema.safeParse(JSON.parse(raw));84 const parsed = convexJsonSchema.safeParse(JSON.parse(raw));
85 if (!parsed.success || typeof parsed.data.functions !== "string") {85 if (!parsed.success || !parsed.data.functions) {
86 return null;86 return null;
87 }87 }
88
88 const value = parsed.data.functions.trim();89 const value = parsed.data.functions.trim();
89 return value.length > 0 ? value : null;90 return value.length > 0 ? value : null;
90 } catch {91 } catch {
executor/packages/core/src/managed/runtime-info.ts+7−5
@@ -1,7 +1,6 @@
1import fs from "node:fs/promises";1import fs from "node:fs/promises";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { Result } from "better-result";
5import { z } from "zod";4import { z } from "zod";
6 5
7import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime";6import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime";
@@ -151,10 +150,13 @@ export function runtimeInfo(): ManagedRuntimeInfo { · 143 unmodified lines
151export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> {150export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> {
152 if (await pathExists(info.configPath)) {151 if (await pathExists(info.configPath)) {
153 const raw = await fs.readFile(info.configPath, "utf8");152 const raw = await fs.readFile(info.configPath, "utf8");
154 const parsedResult = Result.try(() => JSON.parse(raw));153 let parsedValue: unknown;
155 const parsed = parsedResult.isOk()154 try {
156 ? parseManagedRuntimeConfig(parsedResult.value)155 parsedValue = JSON.parse(raw);
157 : null;156 } catch {
157 parsedValue = null;
158 }
159 const parsed = parseManagedRuntimeConfig(parsedValue);
158 160
159 if (!parsed) {161 if (!parsed) {
160 const config = defaultConfig();162 const config = defaultConfig();
executor/packages/core/src/mcp/server-approval.ts+14−12
@@ -9,7 +9,6 @@ import type { · 8 unmodified lines
9 McpExecutorService,9 McpExecutorService,
10} from "./server-contracts";10} from "./server-contracts";
11import { getTaskTerminalState } from "./server-utils";11import { getTaskTerminalState } from "./server-utils";
12import { asRecord } from "../utils";
13 12
14const elicitationResponseSchema = z.object({13const elicitationResponseSchema = z.object({
15 action: z.enum(["accept", "decline", "cancel"]),14 action: z.enum(["accept", "decline", "cancel"]),
@@ -19,6 +18,11 @@ const elicitationResponseSchema = z.object({ · 3 unmodified lines
19 }).optional(),18 }).optional(),
20});19});
21 20
21const subscriptionEventPayloadSchema = z.object({
22 status: z.string().optional(),
23 pendingApprovalCount: z.coerce.number().optional(),
24});
25
22function formatApprovalInput(input: unknown, maxLength = 2000): string {26function formatApprovalInput(input: unknown, maxLength = 2000): string {
23 let serialized: string;27 let serialized: string;
24 try {28 try {
@@ -88,7 +92,7 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 63 unmodified lines
88 }92 }
89 93
90 const action = parsedResponse.data.action;94 const action = parsedResponse.data.action;
91 const content = asRecord(parsedResponse.data.content);95 const content = parsedResponse.data.content;
92 96
93 if (action !== "accept") {97 if (action !== "accept") {
94 return {98 return {
@@ -99,12 +103,10 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 4 unmodified lines
99 };103 };
100 }104 }
101 105
102 const selectedDecision = content.decision;106 const selectedDecision = content?.decision;
103 const decision = selectedDecision === "approved" ? "approved" : "denied";107 const decision = selectedDecision === "approved" ? "approved" : "denied";
104 const selectedReason = content.reason;108 const selectedReason = content?.reason?.trim();
105 const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0109 const reason = selectedReason && selectedReason.length > 0 ? selectedReason : undefined;
106 ? selectedReason
107 : undefined;
108 110
109 return { decision, reason };111 return { decision, reason };
110 };112 };
@@ -209,13 +211,13 @@ export function waitForTerminalTask( · 98 unmodified lines
209 };211 };
210 212
211 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {213 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {
212 const payload = asRecord(event.payload);214 const parsedPayload = subscriptionEventPayloadSchema.safeParse(event.payload);
213 const type = typeof payload.status === "string" ? payload.status : undefined;215 const type = parsedPayload.success ? parsedPayload.data.status : undefined;
214 const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"216 const pendingApprovalCount = parsedPayload.success
215 ? payload.pendingApprovalCount217 ? (parsedPayload.data.pendingApprovalCount ?? 0)
216 : 0;218 : 0;
217 219
218 if (typeof type === "string" && getTaskTerminalState(type)) {220 if (type && getTaskTerminalState(type)) {
219 void done();221 void done();
220 return;222 return;
221 }223 }
executor/packages/core/src/openapi-auth.ts+7−3
@@ -1,6 +1,10 @@
1import type { OpenApiAuth } from "./tool/source-types";1import type { OpenApiAuth } from "./tool/source-types";
2import { z } from "zod";2import { z } from "zod";
3import { asRecord } from "./utils";3import { toPlainObject } from "./utils";
4
5function toRecordOrEmpty(value: unknown): Record<string, unknown> {
6 return toPlainObject(value) ?? {};
7}
4 8
5const securityRequirementSchema = z.record(z.array(z.unknown()).optional());9const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
6 10
@@ -12,8 +16,8 @@ const securitySchemeSchema = z.object({ · 5 unmodified lines
12});16});
13 17
14export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined {18export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined {
15 const components = asRecord(spec.components);19 const components = toRecordOrEmpty(spec.components);
16 const securitySchemes = asRecord(components.securitySchemes);20 const securitySchemes = toRecordOrEmpty(components.securitySchemes);
17 if (Object.keys(securitySchemes).length === 0) {21 if (Object.keys(securitySchemes).length === 0) {
18 return undefined;22 return undefined;
19 }23 }
executor/packages/core/src/openapi-compaction.ts+18−14
@@ -11,7 +11,11 @@ import { · 10 unmodified lines
11 responseTypeHintFromSchema,11 responseTypeHintFromSchema,
12 type OpenApiParameterHint,12 type OpenApiParameterHint,
13} from "./openapi/schema-hints";13} from "./openapi/schema-hints";
14import { asRecord } from "./utils";14import { toPlainObject } from "./utils";
15
16function toRecordOrEmpty(value: unknown): Record<string, unknown> {
17 return toPlainObject(value) ?? {};
18}
15 19
16export interface CompactOpenApiPathsOptions {20export interface CompactOpenApiPathsOptions {
17 includeSchemas?: boolean;21 includeSchemas?: boolean;
@@ -28,13 +32,13 @@ export function compactOpenApiPaths( · 10 unmodified lines
28 componentRequestBodies?: Record<string, unknown>,32 componentRequestBodies?: Record<string, unknown>,
29 options: CompactOpenApiPathsOptions = {},33 options: CompactOpenApiPathsOptions = {},
30): Record<string, unknown> {34): Record<string, unknown> {
31 const paths = asRecord(pathsValue);35 const paths = toRecordOrEmpty(pathsValue);
32 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;36 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
33 const compactPaths: Record<string, unknown> = {};37 const compactPaths: Record<string, unknown> = {};
34 const compParams = componentParameters ? asRecord(componentParameters) : {};38 const compParams = componentParameters ? toRecordOrEmpty(componentParameters) : {};
35 const compSchemas = componentSchemas ? asRecord(componentSchemas) : {};39 const compSchemas = componentSchemas ? toRecordOrEmpty(componentSchemas) : {};
36 const compResponses = componentResponses ? asRecord(componentResponses) : {};40 const compResponses = componentResponses ? toRecordOrEmpty(componentResponses) : {};
37 const compRequestBodies = componentRequestBodies ? asRecord(componentRequestBodies) : {};41 const compRequestBodies = componentRequestBodies ? toRecordOrEmpty(componentRequestBodies) : {};
38 const includeSchemas = options.includeSchemas ?? true;42 const includeSchemas = options.includeSchemas ?? true;
39 const includeTypeHints = options.includeTypeHints ?? true;43 const includeTypeHints = options.includeTypeHints ?? true;
40 const includeParameterSchemas = options.includeParameterSchemas ?? true;44 const includeParameterSchemas = options.includeParameterSchemas ?? true;
@@ -45,7 +49,7 @@ export function compactOpenApiPaths( · 4 unmodified lines
45 const prefix = "#/components/parameters/";49 const prefix = "#/components/parameters/";
46 if (ref.startsWith(prefix)) {50 if (ref.startsWith(prefix)) {
47 const key = ref.slice(prefix.length);51 const key = ref.slice(prefix.length);
48 const resolved = asRecord(compParams[key]);52 const resolved = toRecordOrEmpty(compParams[key]);
49 if (Object.keys(resolved).length > 0) return resolved;53 if (Object.keys(resolved).length > 0) return resolved;
50 }54 }
51 }55 }
@@ -55,7 +59,7 @@ export function compactOpenApiPaths( · 3 unmodified lines
55 const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => {59 const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => {
56 if (!Array.isArray(entries)) return [];60 if (!Array.isArray(entries)) return [];
57 return entries61 return entries
58 .map((entry) => resolveParam(asRecord(entry)))62 .map((entry) => resolveParam(toRecordOrEmpty(entry)))
59 .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string")63 .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string")
60 .map((entry) => ({64 .map((entry) => ({
61 name: String(entry.name),65 name: String(entry.name),
@@ -66,7 +70,7 @@ export function compactOpenApiPaths( · 4 unmodified lines
66 };70 };
67 71
68 for (const [pathTemplate, pathValue] of Object.entries(paths)) {72 for (const [pathTemplate, pathValue] of Object.entries(paths)) {
69 const pathObject = asRecord(pathValue);73 const pathObject = toRecordOrEmpty(pathValue);
70 const compactPathObject: Record<string, unknown> = {};74 const compactPathObject: Record<string, unknown> = {};
71 const sharedParameters = normalizeParameters(pathObject.parameters);75 const sharedParameters = normalizeParameters(pathObject.parameters);
72 if (sharedParameters.length > 0) {76 if (sharedParameters.length > 0) {
@@ -74,7 +78,7 @@ export function compactOpenApiPaths( · 1 unmodified line
74 }78 }
75 79
76 for (const method of methods) {80 for (const method of methods) {
77 const operation = asRecord(pathObject[method]);81 const operation = toRecordOrEmpty(pathObject[method]);
78 if (Object.keys(operation).length === 0) continue;82 if (Object.keys(operation).length === 0) continue;
79 83
80 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);84 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
@@ -106,16 +110,16 @@ export function compactOpenApiPaths( · 25 unmodified lines
106 // Always attempt to compute minimal input/output schemas. This keeps the110 // Always attempt to compute minimal input/output schemas. This keeps the
107 // prepared spec compact while enabling schema-first tool signatures.111 // prepared spec compact while enabling schema-first tool signatures.
108 if (includeSchemas || hasGeneratedTypes) {112 if (includeSchemas || hasGeneratedTypes) {
109 const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies);113 const requestBody = resolveRequestBodyRef(toRecordOrEmpty(operation.requestBody), compRequestBodies);
110 const requestBodyContent = asRecord(requestBody.content);114 const requestBodyContent = toRecordOrEmpty(requestBody.content);
111 const rawRequestBodySchema = getPreferredContentSchema(requestBodyContent);115 const rawRequestBodySchema = getPreferredContentSchema(requestBodyContent);
112 requestBodySchema = resolveSchemaRef(rawRequestBodySchema, compSchemas);116 requestBodySchema = resolveSchemaRef(rawRequestBodySchema, compSchemas);
113 117
114 const responses = asRecord(operation.responses);118 const responses = toRecordOrEmpty(operation.responses);
115 for (const [status, responseValue] of Object.entries(responses)) {119 for (const [status, responseValue] of Object.entries(responses)) {
116 if (!status.startsWith("2")) continue;120 if (!status.startsWith("2")) continue;
117 responseStatus = status;121 responseStatus = status;
118 const resolvedResponse = resolveResponseRef(asRecord(responseValue), compResponses);122 const resolvedResponse = resolveResponseRef(toRecordOrEmpty(responseValue), compResponses);
119 responseSchema = resolveSchemaRef(123 responseSchema = resolveSchemaRef(
120 getPreferredResponseSchema(resolvedResponse),124 getPreferredResponseSchema(resolvedResponse),
121 compSchemas,125 compSchemas,
executor/packages/core/src/openapi-prepare.ts+46−35
@@ -6,10 +6,15 @@ import { inferOpenApiAuth } from "./openapi-auth"; · 5 unmodified lines
6import { compactOpenApiPaths } from "./openapi-compaction";6import { compactOpenApiPaths } from "./openapi-compaction";
7import { extractOperationIdsFromDts } from "./openapi/schema-hints";7import { extractOperationIdsFromDts } from "./openapi/schema-hints";
8import type { PreparedOpenApiSpec } from "./tool/source-types";8import type { PreparedOpenApiSpec } from "./tool/source-types";
9import { asRecord } from "./utils";9import { toPlainObject } from "./utils";
10 10
11const unknownRecordSchema = z.record(z.unknown());11const unknownRecordSchema = z.record(z.unknown());
12 12
13const openApiDocumentSchema = z.object({
14 openapi: z.string().optional(),
15 swagger: z.string().optional(),
16}).catchall(z.unknown());
17
13interface SwaggerParserAdapter {18interface SwaggerParserAdapter {
14 bundle(spec: unknown): Promise<unknown>;19 bundle(spec: unknown): Promise<unknown>;
15 parse(spec: unknown): Promise<unknown>;20 parse(spec: unknown): Promise<unknown>;
@@ -41,16 +46,13 @@ function toRecordResult(value: unknown, label: string): Result<Record<string, un · 25 unmodified lines
41 return Result.ok(parsed.data);46 return Result.ok(parsed.data);
42}47}
43 48
44function describeResultError(error: unknown): string {49function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof openapiTS>[0] {
45 const cause = asRecord(error).cause;50 const parsed = openApiDocumentSchema.safeParse(spec);
46 if (cause instanceof Error) {51 if (!parsed.success) {
47 return cause.message;52 throw new Error(`OpenAPI document is invalid: ${parsed.error.message}`);
48 }
49 if (typeof cause === "string" && cause.trim().length > 0) {
50 return cause;
51 }53 }
52 54
53 return error instanceof Error ? error.message : String(error);55 return parsed.data as Parameters<typeof openapiTS>[0] & Record<string, unknown>;
54}56}
55 57
56function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {58function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {
@@ -62,7 +64,9 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 5 unmodified lines
62 let target: unknown = spec;64 let target: unknown = spec;
63 for (const segment of segments) {65 for (const segment of segments) {
64 if (target && typeof target === "object") {66 if (target && typeof target === "object") {
65 target = asRecord(target)[segment];67 const targetRecord = toPlainObject(target);
68 if (!targetRecord) return false;
69 target = targetRecord[segment];
66 } else {70 } else {
67 return false;71 return false;
68 }72 }
@@ -73,11 +77,12 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 4 unmodified lines
73 function hasBrokenDiscriminators(obj: unknown): boolean {77 function hasBrokenDiscriminators(obj: unknown): boolean {
74 if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators);78 if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators);
75 if (obj && typeof obj === "object") {79 if (obj && typeof obj === "object") {
76 const record = asRecord(obj);80 const record = toPlainObject(obj);
81 if (!record) return false;
77 if (record.discriminator && typeof record.discriminator === "object") {82 if (record.discriminator && typeof record.discriminator === "object") {
78 const disc = asRecord(record.discriminator);83 const disc = toPlainObject(record.discriminator) ?? {};
79 if (disc.mapping && typeof disc.mapping === "object") {84 if (disc.mapping && typeof disc.mapping === "object") {
80 const mapping = asRecord(disc.mapping);85 const mapping = toPlainObject(disc.mapping) ?? {};
81 if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) {86 if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) {
82 return true;87 return true;
83 }88 }
@@ -93,13 +98,14 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 9 unmodified lines
93 function walk(obj: unknown): unknown {98 function walk(obj: unknown): unknown {
94 if (Array.isArray(obj)) return obj.map(walk);99 if (Array.isArray(obj)) return obj.map(walk);
95 if (obj && typeof obj === "object") {100 if (obj && typeof obj === "object") {
96 const record = asRecord(obj);101 const record = toPlainObject(obj);
102 if (!record) return obj;
97 const clone: Record<string, unknown> = {};103 const clone: Record<string, unknown> = {};
98 for (const [key, value] of Object.entries(record)) {104 for (const [key, value] of Object.entries(record)) {
99 if (key === "discriminator" && typeof value === "object" && value !== null) {105 if (key === "discriminator" && typeof value === "object" && value !== null) {
100 const disc = asRecord(value);106 const disc = toPlainObject(value) ?? {};
101 if (disc.mapping && typeof disc.mapping === "object") {107 if (disc.mapping && typeof disc.mapping === "object") {
102 const mapping = asRecord(disc.mapping);108 const mapping = toPlainObject(disc.mapping) ?? {};
103 const hasBroken = Object.values(mapping).some(109 const hasBroken = Object.values(mapping).some(
104 (ref) => typeof ref === "string" && !refExists(ref),110 (ref) => typeof ref === "string" && !refExists(ref),
105 );111 );
@@ -127,7 +133,7 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 21 unmodified lines
127 133
128async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string | null> {134async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string | null> {
129 try {135 try {
130 const ast = await openapiTS(spec as never, { silent: true });136 const ast = await openapiTS(toOpenApiTsInput(spec), { silent: true });
131 return astToString(ast);137 return astToString(ast);
132 } catch (error) {138 } catch (error) {
133 const msg = error instanceof Error ? error.message : String(error);139 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
135 if (patched) {141 if (patched) {
136 console.warn(`[executor] openapi-typescript failed, retrying with patched spec: ${msg}`);142 console.warn(`[executor] openapi-typescript failed, retrying with patched spec: ${msg}`);
137 try {143 try {
138 const ast = await openapiTS(patched as never, { silent: true });144 const ast = await openapiTS(toOpenApiTsInput(patched), { silent: true });
139 return astToString(ast);145 return astToString(ast);
140 } catch (retryError) {146 } catch (retryError) {
141 const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);147 const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
@@ -168,13 +174,15 @@ export async function prepareOpenApiSpec( · 26 unmodified lines
168 174
169 let parsed: Record<string, unknown>;175 let parsed: Record<string, unknown>;
170 if (typeof spec === "string") {176 if (typeof spec === "string") {
171 const parsedResult = await Result.tryPromise(() => parser.parse(spec));177 let parsedValue: unknown;
172 if (parsedResult.isErr()) {178 try {
173 const msg = describeResultError(parsedResult.error);179 parsedValue = await parser.parse(spec);
180 } catch (error) {
181 const msg = error instanceof Error ? error.message : String(error);
174 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);182 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
175 }183 }
176 184
177 const parsedRecord = toRecordResult(parsedResult.value, `Parsed OpenAPI source '${sourceName}'`);185 const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`);
178 if (parsedRecord.isErr()) {186 if (parsedRecord.isErr()) {
179 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`);187 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`);
180 }188 }
@@ -184,18 +192,23 @@ export async function prepareOpenApiSpec( · 3 unmodified lines
184 parsed = spec;192 parsed = spec;
185 }193 }
186 194
187 let bundled: Record<string, unknown>;195 let bundled: Record<string, unknown> = parsed;
188 const dtsPromise = shouldGenerateDts196 const dtsPromise = shouldGenerateDts
189 ? generateOpenApiDts(parsed)197 ? generateOpenApiDts(parsed)
190 : Promise.resolve<string | null>(null);198 : Promise.resolve<string | null>(null);
191 if (shouldBundle) {199 if (shouldBundle) {
192 const bundleResult = await Result.tryPromise(() => parser.bundle(parsed));200 let bundledValue: unknown;
193 if (bundleResult.isErr()) {201 try {
194 const bundleMessage = describeResultError(bundleResult.error);202 bundledValue = await parser.bundle(parsed);
203 } catch (error) {
204 const bundleMessage = error instanceof Error ? error.message : String(error);
195 warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`);205 warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`);
196 bundled = parsed;206 bundled = parsed;
197 } else {207 bundledValue = undefined;
198 const bundledRecord = toRecordResult(bundleResult.value, `Bundled OpenAPI source '${sourceName}'`);208 }
209
210 if (bundledValue !== undefined) {
211 const bundledRecord = toRecordResult(bundledValue, `Bundled OpenAPI source '${sourceName}'`);
199 if (bundledRecord.isErr()) {212 if (bundledRecord.isErr()) {
200 warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`);213 warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`);
201 bundled = parsed;214 bundled = parsed;
@@ -203,8 +216,6 @@ export async function prepareOpenApiSpec( · 1 unmodified line
203 bundled = bundledRecord.value;216 bundled = bundledRecord.value;
204 }217 }
205 }218 }
206 } else {
207 bundled = parsed;
208 }219 }
209 const dts = await dtsPromise;220 const dts = await dtsPromise;
210 221
@@ -215,17 +226,17 @@ export async function prepareOpenApiSpec( · 4 unmodified lines
215 return {226 return {
216 servers: servers227 servers: servers
217 .map((server) => {228 .map((server) => {
218 const value = asRecord(server).url;229 const value = toPlainObject(server)?.url;
219 return typeof value === "string" ? value : "";230 return typeof value === "string" ? value : "";
220 })231 })
221 .filter((url) => url.length > 0),232 .filter((url) => url.length > 0),
222 paths: compactOpenApiPaths(233 paths: compactOpenApiPaths(
223 bundled.paths,234 bundled.paths,
224 operationTypeIds,235 operationTypeIds,
225 asRecord(asRecord(bundled.components).parameters),236 toPlainObject(toPlainObject(bundled.components)?.parameters) ?? {},
226 asRecord(asRecord(bundled.components).schemas),237 toPlainObject(toPlainObject(bundled.components)?.schemas) ?? {},
227 asRecord(asRecord(bundled.components).responses),238 toPlainObject(toPlainObject(bundled.components)?.responses) ?? {},
228 asRecord(asRecord(bundled.components).requestBodies),239 toPlainObject(toPlainObject(bundled.components)?.requestBodies) ?? {},
229 {240 {
230 includeSchemas: profile === "full",241 includeSchemas: profile === "full",
231 includeTypeHints: true,242 includeTypeHints: true,
executor/packages/core/src/openapi/schema-hints.ts+41−37
@@ -1,4 +1,8 @@
1import { asRecord } from "../utils";1import { toPlainObject } from "../utils";
2
3function toRecordOrEmpty(value: unknown): Record<string, unknown> {
4 return toPlainObject(value) ?? {};
5}
2 6
3type JsonSchema = Record<string, unknown>;7type JsonSchema = Record<string, unknown>;
4const COMPONENT_REF_INLINE_DEPTH = 2;8const COMPONENT_REF_INLINE_DEPTH = 2;
@@ -6,7 +10,7 @@ const COMPONENT_REF_INLINE_DEPTH = 2; · 1 unmodified line
6function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {10function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {
7 const shape = schema;11 const shape = schema;
8 const type = typeof shape.type === "string" ? shape.type : undefined;12 const type = typeof shape.type === "string" ? shape.type : undefined;
9 const props = asRecord(shape.properties);13 const props = toRecordOrEmpty(shape.properties);
10 const propCount = Object.keys(props).length;14 const propCount = Object.keys(props).length;
11 if (type !== "object" && propCount === 0) return false;15 if (type !== "object" && propCount === 0) return false;
12 if (propCount === 0) return false;16 if (propCount === 0) return false;
@@ -38,18 +42,18 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 25 unmodified lines
38 const preferredKeys = ["application/json", "*/*"];42 const preferredKeys = ["application/json", "*/*"];
39 43
40 for (const key of preferredKeys) {44 for (const key of preferredKeys) {
41 const schema = asRecord(asRecord(content[key]).schema);45 const schema = toRecordOrEmpty(toRecordOrEmpty(content[key]).schema);
42 if (Object.keys(schema).length > 0) return schema;46 if (Object.keys(schema).length > 0) return schema;
43 }47 }
44 48
45 for (const [key, value] of Object.entries(content)) {49 for (const [key, value] of Object.entries(content)) {
46 if (!key.includes("json")) continue;50 if (!key.includes("json")) continue;
47 const schema = asRecord(asRecord(value).schema);51 const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema);
48 if (Object.keys(schema).length > 0) return schema;52 if (Object.keys(schema).length > 0) return schema;
49 }53 }
50 54
51 for (const value of Object.values(content)) {55 for (const value of Object.values(content)) {
52 const schema = asRecord(asRecord(value).schema);56 const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema);
53 if (Object.keys(schema).length > 0) return schema;57 if (Object.keys(schema).length > 0) return schema;
54 }58 }
55 59
@@ -57,12 +61,12 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 1 unmodified line
57}61}
58 62
59export function getPreferredResponseSchema(responseValue: Record<string, unknown>): Record<string, unknown> {63export function getPreferredResponseSchema(responseValue: Record<string, unknown>): Record<string, unknown> {
60 const contentSchema = getPreferredContentSchema(asRecord(responseValue.content));64 const contentSchema = getPreferredContentSchema(toRecordOrEmpty(responseValue.content));
61 if (Object.keys(contentSchema).length > 0) {65 if (Object.keys(contentSchema).length > 0) {
62 return contentSchema;66 return contentSchema;
63 }67 }
64 68
65 const schema = asRecord(responseValue.schema);69 const schema = toRecordOrEmpty(responseValue.schema);
66 if (Object.keys(schema).length > 0) {70 if (Object.keys(schema).length > 0) {
67 return schema;71 return schema;
68 }72 }
@@ -81,7 +85,7 @@ export function resolveSchemaRef( · 12 unmodified lines
81 }85 }
82 86
83 const key = ref.slice(prefix.length);87 const key = ref.slice(prefix.length);
84 const resolved = asRecord(componentSchemas[key]);88 const resolved = toRecordOrEmpty(componentSchemas[key]);
85 if (Object.keys(resolved).length === 0) {89 if (Object.keys(resolved).length === 0) {
86 return schema;90 return schema;
87 }91 }
@@ -99,7 +103,7 @@ export function resolveRequestBodyRef( · 11 unmodified lines
99 }103 }
100 104
101 const key = ref.slice(prefix.length);105 const key = ref.slice(prefix.length);
102 const resolved = asRecord(componentRequestBodies[key]);106 const resolved = toRecordOrEmpty(componentRequestBodies[key]);
103 if (Object.keys(resolved).length === 0) {107 if (Object.keys(resolved).length === 0) {
104 return requestBody;108 return requestBody;
105 }109 }
@@ -117,7 +121,7 @@ export function resolveResponseRef( · 11 unmodified lines
117 }121 }
118 122
119 const key = ref.slice(prefix.length);123 const key = ref.slice(prefix.length);
120 const resolved = asRecord(componentResponses[key]);124 const resolved = toRecordOrEmpty(componentResponses[key]);
121 if (Object.keys(resolved).length === 0) {125 if (Object.keys(resolved).length === 0) {
122 return response;126 return response;
123 }127 }
@@ -125,7 +129,7 @@ export function resolveResponseRef( · 1 unmodified line
125}129}
126 130
127export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> {131export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> {
128 const schema = asRecord(entry.schema);132 const schema = toRecordOrEmpty(entry.schema);
129 if (Object.keys(schema).length > 0) {133 if (Object.keys(schema).length > 0) {
130 return schema;134 return schema;
131 }135 }
@@ -139,7 +143,7 @@ export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record · 7 unmodified lines
139 if (Array.isArray(entry.enum) && entry.enum.length > 0) {143 if (Array.isArray(entry.enum) && entry.enum.length > 0) {
140 fallback.enum = entry.enum;144 fallback.enum = entry.enum;
141 }145 }
142 const items = asRecord(entry.items);146 const items = toRecordOrEmpty(entry.items);
143 if (Object.keys(items).length > 0) {147 if (Object.keys(items).length > 0) {
144 fallback.items = items;148 fallback.items = items;
145 }149 }
@@ -239,13 +243,13 @@ function joinUnion(parts: string[]): string { · 93 unmodified lines
239function isObjectSchema(schema: JsonSchema): boolean {243function isObjectSchema(schema: JsonSchema): boolean {
240 const type = typeof schema.type === "string" ? schema.type : undefined;244 const type = typeof schema.type === "string" ? schema.type : undefined;
241 if (type === "object") return true;245 if (type === "object") return true;
242 return Object.keys(asRecord(schema.properties)).length > 0;246 return Object.keys(toRecordOrEmpty(schema.properties)).length > 0;
243}247}
244 248
245function isEmptyObjectSchema(schema: JsonSchema): boolean {249function isEmptyObjectSchema(schema: JsonSchema): boolean {
246 const type = typeof schema.type === "string" ? schema.type : undefined;250 const type = typeof schema.type === "string" ? schema.type : undefined;
247 if (type !== "object") return false;251 if (type !== "object") return false;
248 return Object.keys(asRecord(schema.properties)).length === 0;252 return Object.keys(toRecordOrEmpty(schema.properties)).length === 0;
249}253}
250 254
251function isPlainScalarSchema(schema: JsonSchema): boolean {255function isPlainScalarSchema(schema: JsonSchema): boolean {
@@ -279,7 +283,7 @@ function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] { · 27 unmodified lines
279 // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]`283 // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]`
280 // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`.284 // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`.
281 // These broad scalar/empty-object variants add noise without helping the UI/LLM.285 // 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);
283 287
284 let filtered = variants;288 let filtered = variants;
285 if (hasNonEmptyObject) {289 if (hasNonEmptyObject) {
@@ -324,7 +328,7 @@ function repairMissingRequiredProperties( · 38 unmodified lines
324 const requiredLists = variants.map((v) => new Set(328 const requiredLists = variants.map((v) => new Set(
325 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),329 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),
326 ));330 ));
327 const propsLists = variants.map((v) => asRecord(v.properties));331 const propsLists = variants.map((v) => toRecordOrEmpty(v.properties));
328 332
329 const keysToConsider = new Set<string>();333 const keysToConsider = new Set<string>();
330 for (const req of requiredLists) {334 for (const req of requiredLists) {
@@ -342,7 +346,7 @@ function repairMissingRequiredProperties( · 11 unmodified lines
342 if (!schema || typeof schema !== "object") continue;346 if (!schema || typeof schema !== "object") continue;
343 const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs);347 const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs);
344 if (!hint || hint === "unknown") continue;348 if (!hint || hint === "unknown") continue;
345 candidates.push({ hint, schema: asRecord(schema) });349 candidates.push({ hint, schema: toRecordOrEmpty(schema) });
346 }350 }
347 351
348 if (candidates.length === 0) continue;352 if (candidates.length === 0) continue;
@@ -416,8 +420,8 @@ function mergeDiscriminatedObjectUnionVariants( · 67 unmodified lines
416 420
417 for (let i = 0; i < variants.length; i++) {421 for (let i = 0; i < variants.length; i++) {
418 const variant = variants[i]!;422 const variant = variants[i]!;
419 const props = asRecord(variant.properties);423 const props = toRecordOrEmpty(variant.properties);
420 const discSchema = asRecord(props[discriminantKey]);424 const discSchema = toRecordOrEmpty(props[discriminantKey]);
421 const disc = extractStringLiteralFromSchema(discSchema);425 const disc = extractStringLiteralFromSchema(discSchema);
422 if (!disc) {426 if (!disc) {
423 return variants;427 return variants;
@@ -479,8 +483,8 @@ function mergeDiscriminatedObjectUnionVariants( · 55 unmodified lines
479 for (const member of group) used.add(member.idx);483 for (const member of group) used.add(member.idx);
480 484
481 const base = group[0]!.schema;485 const base = group[0]!.schema;
482 const baseProps = asRecord(base.properties);486 const baseProps = toRecordOrEmpty(base.properties);
483 const discSchema = asRecord(baseProps[discriminantKey]);487 const discSchema = toRecordOrEmpty(baseProps[discriminantKey]);
484 const discType = typeof discSchema.type === "string" ? discSchema.type : "string";488 const discType = typeof discSchema.type === "string" ? discSchema.type : "string";
485 489
486 const mergedDiscriminant: JsonSchema = {490 const mergedDiscriminant: JsonSchema = {
@@ -521,8 +525,8 @@ function tryCollapseSimpleObjectUnion( · 34 unmodified lines
521 const right = variants[1]!;525 const right = variants[1]!;
522 if (!isObjectSchema(left) || !isObjectSchema(right)) return null;526 if (!isObjectSchema(left) || !isObjectSchema(right)) return null;
523 527
524 const leftProps = asRecord(left.properties);528 const leftProps = toRecordOrEmpty(left.properties);
525 const rightProps = asRecord(right.properties);529 const rightProps = toRecordOrEmpty(right.properties);
526 const leftKeys = Object.keys(leftProps);530 const leftKeys = Object.keys(leftProps);
527 const rightKeys = Object.keys(rightProps);531 const rightKeys = Object.keys(rightProps);
528 if (leftKeys.length === 0 || rightKeys.length === 0) return null;532 if (leftKeys.length === 0 || rightKeys.length === 0) return null;
@@ -535,8 +539,8 @@ function tryCollapseSimpleObjectUnion( · 6 unmodified lines
535 539
536 const sup = leftSubRight ? right : left;540 const sup = leftSubRight ? right : left;
537 const sub = leftSubRight ? left : right;541 const sub = leftSubRight ? left : right;
538 const supProps = asRecord(sup.properties);542 const supProps = toRecordOrEmpty(sup.properties);
539 const subProps = asRecord(sub.properties);543 const subProps = toRecordOrEmpty(sub.properties);
540 const supKeys = Object.keys(supProps);544 const supKeys = Object.keys(supProps);
541 const subKeys = Object.keys(subProps);545 const subKeys = Object.keys(subProps);
542 const extraKeys = supKeys.filter((k) => !subKeys.includes(k));546 const extraKeys = supKeys.filter((k) => !subKeys.includes(k));
@@ -591,13 +595,13 @@ function tryFactorCommonObjectFields( · 48 unmodified lines
591 if (variants.length < 2) return null;595 if (variants.length < 2) return null;
592 if (!variants.every(isObjectSchema)) return null;596 if (!variants.every(isObjectSchema)) return null;
593 597
594 const propsList = variants.map((v) => asRecord(v.properties));598 const propsList = variants.map((v) => toRecordOrEmpty(v.properties));
595 const commonKeys = intersectKeys(propsList);599 const commonKeys = intersectKeys(propsList);
596 if (commonKeys.length === 0) return null;600 if (commonKeys.length === 0) return null;
597 601
598 const commonEntries: Array<{ key: string; hint: string; required: boolean }> = [];602 const commonEntries: Array<{ key: string; hint: string; required: boolean }> = [];
599 for (const key of commonKeys) {603 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));
601 const firstHint = hints[0];605 const firstHint = hints[0];
602 if (!firstHint || hints.some((h) => h !== firstHint)) continue;606 if (!firstHint || hints.some((h) => h !== firstHint)) continue;
603 607
@@ -613,7 +617,7 @@ function tryFactorCommonObjectFields( · 9 unmodified lines
613 const commonProps = new Set(commonEntries.map((e) => e.key));617 const commonProps = new Set(commonEntries.map((e) => e.key));
614 const residualSchemas: JsonSchema[] = [];618 const residualSchemas: JsonSchema[] = [];
615 for (const variant of variants) {619 for (const variant of variants) {
616 const props = asRecord(variant.properties);620 const props = toRecordOrEmpty(variant.properties);
617 const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key)));621 const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key)));
618 const req = Array.isArray(variant.required) ? variant.required : [];622 const req = Array.isArray(variant.required) ? variant.required : [];
619 const residualRequired = req.filter((key) => typeof key === "string" && !commonProps.has(key));623 const residualRequired = req.filter((key) => typeof key === "string" && !commonProps.has(key));
@@ -625,7 +629,7 @@ function tryFactorCommonObjectFields( · 5 unmodified lines
625 }629 }
626 630
627 // If factoring doesn't reduce anything, bail.631 // 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);
629 if (!reduces) return null;633 if (!reduces) return null;
630 634
631 const commonInner = commonEntries635 const commonInner = commonEntries
@@ -662,7 +666,7 @@ function tryFactorPartialCommonObjectFields( · 30 unmodified lines
662 if (variants.length < 3) return null;666 if (variants.length < 3) return null;
663 if (!variants.every(isObjectSchema)) return null;667 if (!variants.every(isObjectSchema)) return null;
664 668
665 const propsList = variants.map((v) => asRecord(v.properties));669 const propsList = variants.map((v) => toRecordOrEmpty(v.properties));
666 const requiredList = variants.map((v) => new Set(670 const requiredList = variants.map((v) => new Set(
667 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),671 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),
668 ));672 ));
@@ -753,7 +757,7 @@ function tryFactorPartialCommonObjectFields( · 84 unmodified lines
753 757
754 // If factoring doesn't actually remove anything useful, bail.758 // If factoring doesn't actually remove anything useful, bail.
755 const anyResidualHasProps = residualSchemas.some(759 const anyResidualHasProps = residualSchemas.some(
756 (s) => Object.keys(asRecord(s.properties)).length > 0,760 (s) => Object.keys(toRecordOrEmpty(s.properties)).length > 0,
757 );761 );
758 if (!anyResidualHasProps) return null;762 if (!anyResidualHasProps) return null;
759 763
@@ -788,13 +792,13 @@ export function jsonSchemaTypeHintFallback( · 28 unmodified lines
788 if (!schema || typeof schema !== "object") return "unknown";792 if (!schema || typeof schema !== "object") return "unknown";
789 if (depth > 12) return "unknown";793 if (depth > 12) return "unknown";
790 794
791 const shape = asRecord(schema);795 const shape = toRecordOrEmpty(schema);
792 if (typeof shape.$ref === "string") {796 if (typeof shape.$ref === "string") {
793 const ref = shape.$ref;797 const ref = shape.$ref;
794 const prefix = "#/components/schemas/";798 const prefix = "#/components/schemas/";
795 if (ref.startsWith(prefix)) {799 if (ref.startsWith(prefix)) {
796 const key = ref.slice(prefix.length);800 const key = ref.slice(prefix.length);
797 const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};801 const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {};
798 const canInline = Object.keys(resolved).length > 0802 const canInline = Object.keys(resolved).length > 0
799 && !seenRefs.has(ref)803 && !seenRefs.has(ref)
800 && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved));804 && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved));
@@ -934,7 +938,7 @@ export function jsonSchemaTypeHintFallback( · 133 unmodified lines
934 return `${maybeParenthesizeArrayElement(itemType)}[]`;938 return `${maybeParenthesizeArrayElement(itemType)}[]`;
935 }939 }
936 940
937 const props = asRecord(shape.properties);941 const props = toRecordOrEmpty(shape.properties);
938 const additionalProperties = shape.additionalProperties;942 const additionalProperties = shape.additionalProperties;
939 const requiredRaw = Array.isArray(shape.required) ? shape.required : [];943 const requiredRaw = Array.isArray(shape.required) ? shape.required : [];
940 const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string"));944 const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string"));
@@ -973,12 +977,12 @@ function collectTopLevelSchemaKeys( · 32 unmodified lines
973): string[] {977): string[] {
974 if (!schema || typeof schema !== "object") return [];978 if (!schema || typeof schema !== "object") return [];
975 979
976 const record = asRecord(schema);980 const record = toRecordOrEmpty(schema);
977 const ref = typeof record.$ref === "string" ? record.$ref : "";981 const ref = typeof record.$ref === "string" ? record.$ref : "";
978 if (ref.startsWith("#/components/schemas/")) {982 if (ref.startsWith("#/components/schemas/")) {
979 if (seenRefs.has(ref)) return [];983 if (seenRefs.has(ref)) return [];
980 const key = ref.slice("#/components/schemas/".length);984 const key = ref.slice("#/components/schemas/".length);
981 const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};985 const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {};
982 if (Object.keys(resolved).length === 0) return [];986 if (Object.keys(resolved).length === 0) return [];
983 const nextSeen = new Set(seenRefs);987 const nextSeen = new Set(seenRefs);
984 nextSeen.add(ref);988 nextSeen.add(ref);
@@ -988,7 +992,7 @@ function collectTopLevelSchemaKeys( · 3 unmodified lines
988 const keys: string[] = [];992 const keys: string[] = [];
989 const seen = new Set<string>();993 const seen = new Set<string>();
990 994
991 for (const key of Object.keys(asRecord(record.properties))) {995 for (const key of Object.keys(toRecordOrEmpty(record.properties))) {
992 pushUnique(keys, seen, key);996 pushUnique(keys, seen, key);
993 }997 }
994 998
executor/packages/core/src/openapi/tool-builder.ts+38−23
@@ -1,3 +1,4 @@
1import { z } from "zod";
1import {2import {
2 buildOpenApiArgPreviewKeys,3 buildOpenApiArgPreviewKeys,
3 buildOpenApiInputSchema,4 buildOpenApiInputSchema,
@@ -6,7 +7,6 @@ import { · 2 unmodified lines
6 jsonSchemaTypeHintFallback,7 jsonSchemaTypeHintFallback,
7 responseTypeHintFromSchema,8 responseTypeHintFromSchema,
8} from "./schema-hints";9} from "./schema-hints";
9import { z } from "zod";
10import { buildOpenApiToolPath } from "./tool-path";10import { buildOpenApiToolPath } from "./tool-path";
11import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";11import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
12import { executeOpenApiRequest } from "../tool/source-execution";12import { executeOpenApiRequest } from "../tool/source-execution";
@@ -14,7 +14,7 @@ import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/sourc · 1 unmodified line
14import type { ToolDefinition } from "../types";14import type { ToolDefinition } from "../types";
15import type { ToolTypedRef } from "../types";15import type { ToolTypedRef } from "../types";
16import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";16import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
17import { asRecord } from "../utils";17import { toPlainObject } from "../utils";
18import type { SerializedTool } from "../tool/source-serialization";18import type { SerializedTool } from "../tool/source-serialization";
19 19
20type OpenApiOperationParameter = {20type OpenApiOperationParameter = {
@@ -24,11 +24,29 @@ type OpenApiOperationParameter = { · 3 unmodified lines
24 schema: Record<string, unknown>;24 schema: Record<string, unknown>;
25};25};
26 26
27const recordArraySchema = z.array(z.record(z.unknown()));27const stringArraySchema = z.array(z.string());
28const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0);
28 29
29function asRecordArray(value: unknown): Record<string, unknown>[] {30function toRecordOrEmpty(value: unknown): Record<string, unknown> {
30 const parsed = recordArraySchema.safeParse(value);31 return toPlainObject(value) ?? {};
31 return parsed.success ? parsed.data : [];32}
33
34function parseNonEmptyTrimmedString(value: unknown): string | undefined {
35 const parsed = nonEmptyTrimmedStringSchema.safeParse(value);
36 return parsed.success ? parsed.data : undefined;
37}
38
39function 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;
32}50}
33 51
34function buildOpenApiOperationParameters(52function buildOpenApiOperationParameters(
@@ -37,12 +55,12 @@ function buildOpenApiOperationParameters( · 2 unmodified lines
37): OpenApiOperationParameter[] {55): OpenApiOperationParameter[] {
38 return [56 return [
39 ...sharedParameters,57 ...sharedParameters,
40 ...asRecordArray(operation.parameters),58 ...toRecordArray(operation.parameters),
41 ].map((entry) => ({59 ].map((entry) => ({
42 name: String(entry.name ?? ""),60 name: String(entry.name ?? ""),
43 in: String(entry.in ?? "query"),61 in: String(entry.in ?? "query"),
44 required: Boolean(entry.required),62 required: Boolean(entry.required),
45 schema: asRecord(entry.schema),63 schema: toRecordOrEmpty(entry.schema),
46 }));64 }));
47}65}
48 66
@@ -60,7 +78,7 @@ export function buildOpenApiToolsFromPrepared( · 11 unmodified lines
60 const sourceLabel = `openapi:${config.name}`;78 const sourceLabel = `openapi:${config.name}`;
61 const credentialSourceKey = getCredentialSourceKey(config);79 const credentialSourceKey = getCredentialSourceKey(config);
62 const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth);80 const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth);
63 const paths = asRecord(prepared.paths);81 const paths = toRecordOrEmpty(prepared.paths);
64 const tools: ToolDefinition[] = [];82 const tools: ToolDefinition[] = [];
65 83
66 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;84 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
@@ -68,11 +86,11 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line
68 const usedToolPaths = new Set<string>();86 const usedToolPaths = new Set<string>();
69 87
70 for (const [pathTemplate, pathValue] of Object.entries(paths)) {88 for (const [pathTemplate, pathValue] of Object.entries(paths)) {
71 const pathObject = asRecord(pathValue);89 const pathObject = toRecordOrEmpty(pathValue);
72 const sharedParameters = asRecordArray(pathObject.parameters);90 const sharedParameters = toRecordArray(pathObject.parameters);
73 91
74 for (const method of methods) {92 for (const method of methods) {
75 const operation = asRecord(pathObject[method]);93 const operation = toRecordOrEmpty(pathObject[method]);
76 if (Object.keys(operation).length === 0) continue;94 if (Object.keys(operation).length === 0) continue;
77 95
78 const tags = Array.isArray(operation.tags) ? operation.tags : [];96 const tags = Array.isArray(operation.tags) ? operation.tags : [];
@@ -80,22 +98,19 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line
80 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);98 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
81 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);99 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);
82 100
83 const inputSchema = asRecord(operation._inputSchema);101 const inputSchema = toRecordOrEmpty(operation._inputSchema);
84 const outputSchema = asRecord(operation._outputSchema);102 const outputSchema = toRecordOrEmpty(operation._outputSchema);
85 const inputHint = typeof operation._argsTypeHint === "string" && operation._argsTypeHint.trim().length > 0103 const inputHint = parseNonEmptyTrimmedString(operation._argsTypeHint);
86 ? operation._argsTypeHint.trim()104 const outputHint = parseNonEmptyTrimmedString(operation._returnsTypeHint);
87 : undefined;
88 const outputHint = typeof operation._returnsTypeHint === "string" && operation._returnsTypeHint.trim().length > 0
89 ? operation._returnsTypeHint.trim()
90 : undefined;
91 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);105 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
92 const previewInputKeys = Array.isArray(operation._previewInputKeys)106 const parsedPreviewInputKeys = stringArraySchema.safeParse(operation._previewInputKeys);
93 ? operation._previewInputKeys.filter((value): value is string => typeof value === "string")107 const previewInputKeys = parsedPreviewInputKeys.success
108 ? parsedPreviewInputKeys.data
94 : buildPreviewKeys(inputSchema);109 : buildPreviewKeys(inputSchema);
95 110
96 const typedRef: ToolTypedRef = {111 const typedRef: ToolTypedRef = {
97 kind: "openapi_operation",112 kind: "openapi_operation",
98 sourceKey: sourceLabel,113 sourceKey: config.sourceKey ?? sourceLabel,
99 operationId: operationIdRaw,114 operationId: operationIdRaw,
100 };115 };
101 116
executor/packages/core/src/postman-runtime.ts+9−3
@@ -1,6 +1,5 @@
1import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils";1import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils";
2import { z } from "zod";2import { z } from "zod";
3import { asRecord } from "./utils";
4import type { PostmanRequestBody } from "./postman/collection-utils";3import type { PostmanRequestBody } from "./postman/collection-utils";
5 4
6export interface PostmanSerializedRunSpec {5export interface PostmanSerializedRunSpec {
@@ -21,6 +20,13 @@ const postmanRuntimePayloadSchema = z.object({ · 14 unmodified lines
21 body: z.unknown().optional(),20 body: z.unknown().optional(),
22}).passthrough();21}).passthrough();
23 22
23const recordSchema = z.record(z.unknown());
24
25function coerceRecord(value: unknown): Record<string, unknown> {
26 const parsed = recordSchema.safeParse(value);
27 return parsed.success ? parsed.data : {};
28}
29
24export async function executePostmanRequest(30export async function executePostmanRequest(
25 runSpec: PostmanSerializedRunSpec,31 runSpec: PostmanSerializedRunSpec,
26 payload: Record<string, unknown>,32 payload: Record<string, unknown>,
@@ -55,7 +61,7 @@ export async function executePostmanRequest( · 28 unmodified lines
55 }61 }
56 }62 }
57 63
58 const queryOverrides = asRecord(normalizedPayload.query);64 const queryOverrides = coerceRecord(normalizedPayload.query);
59 for (const [key, value] of Object.entries(queryOverrides)) {65 for (const [key, value] of Object.entries(queryOverrides)) {
60 if (!key || value === undefined || value === null) continue;66 if (!key || value === undefined || value === null) continue;
61 url.searchParams.set(key, stringifyTemplateValue(value));67 url.searchParams.set(key, stringifyTemplateValue(value));
@@ -69,7 +75,7 @@ export async function executePostmanRequest( · 7 unmodified lines
69 Object.assign(headers, runSpec.authHeaders);75 Object.assign(headers, runSpec.authHeaders);
70 Object.assign(headers, credentialHeaders ?? {});76 Object.assign(headers, credentialHeaders ?? {});
71 77
72 const headerOverrides = asRecord(normalizedPayload.headers);78 const headerOverrides = coerceRecord(normalizedPayload.headers);
73 for (const [name, value] of Object.entries(headerOverrides)) {79 for (const [name, value] of Object.entries(headerOverrides)) {
74 if (!name || value === undefined || value === null) continue;80 if (!name || value === undefined || value === null) continue;
75 headers[name] = stringifyTemplateValue(value);81 headers[name] = stringifyTemplateValue(value);
executor/packages/core/src/postman-utils.ts+8−2
@@ -1,6 +1,12 @@
1import { asRecord } from "./utils";1import { z } from "zod";
2 2
3const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g;3const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g;
4const recordSchema = z.record(z.unknown());
5
6function coerceRecord(value: unknown): Record<string, unknown> {
7 const parsed = recordSchema.safeParse(value);
8 return parsed.success ? parsed.data : {};
9}
4 10
5export function stringifyTemplateValue(value: unknown): string {11export function stringifyTemplateValue(value: unknown): string {
6 if (value === null || value === undefined) return "";12 if (value === null || value === undefined) return "";
@@ -29,7 +35,7 @@ export function findUnresolvedPostmanTemplateKeys(value: string): string[] { · 22 unmodified lines
29 35
30export function asStringRecord(value: unknown): Record<string, string> {36export function asStringRecord(value: unknown): Record<string, string> {
31 const result: Record<string, string> = {};37 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))) {
33 result[key] = stringifyTemplateValue(entry);39 result[key] = stringifyTemplateValue(entry);
34 }40 }
35 return result;41 return result;
executor/packages/core/src/postman/collection-utils.ts+77−32
@@ -1,11 +1,50 @@
1import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils";1import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils";
2import { stringifyTemplateValue } from "../postman-utils";2import { stringifyTemplateValue } from "../postman-utils";
3import { asRecord } from "../utils";3import { z } from "zod";
4 4
5export type PostmanRequestBody =5export type PostmanRequestBody =
6 | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> }6 | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> }
7 | { kind: "raw"; text: string };7 | { kind: "raw"; text: string };
8 8
9const 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
16const 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
25function parseKeyValueEntries(value: unknown): Array<z.infer<typeof postmanKeyValueEntrySchema>> {
26 const parsed = z.array(postmanKeyValueEntrySchema).safeParse(value);
27 return parsed.success ? parsed.data : [];
28}
29
30function isDisabled(entry: z.infer<typeof postmanKeyValueEntrySchema>): boolean {
31 return entry.disabled === true || entry.enabled === false;
32}
33
34function 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
9export function buildPostmanToolPath(48export function buildPostmanToolPath(
10 sourceName: string,49 sourceName: string,
11 requestName: string,50 requestName: string,
@@ -48,57 +87,63 @@ export function resolvePostmanFolderPath( · 36 unmodified lines
48}87}
49 88
50export function extractPostmanVariableMap(value: unknown): Record<string, string> {89export function extractPostmanVariableMap(value: unknown): Record<string, string> {
51 if (!Array.isArray(value)) return {};
52 const result: Record<string, string> = {};90 const result: Record<string, string> = {};
53 for (const entry of value) {91 for (const entry of parseKeyValueEntries(value)) {
54 const record = asRecord(entry);92 const normalized = toNormalizedEntry(entry);
55 const key = typeof record.key === "string" ? record.key.trim() : "";93 if (!normalized) continue;
56 if (!key) continue;94 result[normalized.key] = normalized.value;
57 if (record.disabled === true) continue;
58 result[key] = stringifyTemplateValue(record.value);
59 }95 }
60 return result;96 return result;
61}97}
62 98
63export function extractPostmanHeaderMap(value: unknown): Record<string, string> {99export function extractPostmanHeaderMap(value: unknown): Record<string, string> {
64 if (!Array.isArray(value)) return {};
65 const result: Record<string, string> = {};100 const result: Record<string, string> = {};
66 for (const entry of value) {101 for (const entry of parseKeyValueEntries(value)) {
67 const record = asRecord(entry);102 const normalized = toNormalizedEntry(entry);
68 const key = typeof record.key === "string" ? record.key.trim() : "";103 if (!normalized) continue;
69 if (!key || record.disabled === true) continue;104 result[normalized.key] = normalized.value;
70 result[key] = stringifyTemplateValue(record.value);
71 }105 }
72 return result;106 return result;
73}107}
74 108
75export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> {109export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> {
76 if (!Array.isArray(value)) return [];
77 const entries: Array<{ key: string; value: string }> = [];110 const entries: Array<{ key: string; value: string }> = [];
78 for (const entry of value) {111 for (const entry of parseKeyValueEntries(value)) {
79 const record = asRecord(entry);112 const normalized = toNormalizedEntry(entry);
80 const key = typeof record.key === "string" ? record.key.trim() : "";113 if (!normalized) continue;
81 if (!key || record.disabled === true) continue;114 entries.push(normalized);
82 entries.push({ key, value: stringifyTemplateValue(record.value) });
83 }115 }
84 return entries;116 return entries;
85}117}
86 118
87export function extractPostmanBody(record: Record<string, unknown>): PostmanRequestBody | undefined {119export function extractPostmanBody(value: unknown): PostmanRequestBody | undefined {
88 const dataMode = typeof record.dataMode === "string" ? record.dataMode.toLowerCase() : "";120 const parsedBody = postmanBodySchema.safeParse(value);
89 if (dataMode === "urlencoded" && Array.isArray(record.data)) {121 if (!parsedBody.success) return undefined;
90 const entries: Array<{ key: string; value: string }> = [];122
91 for (const item of record.data) {123 const body = parsedBody.data;
92 const entry = asRecord(item);124 const mode = (body.mode ?? body.dataMode ?? "").trim().toLowerCase();
93 const key = typeof entry.key === "string" ? entry.key.trim() : "";125
94 if (!key || entry.disabled === true) continue;126 if (mode === "urlencoded") {
95 entries.push({ key, value: stringifyTemplateValue(entry.value) });127 const rawEntries = body.urlencoded ?? body.data ?? [];
96 }128 const entries = rawEntries
129 .map((entry) => toNormalizedEntry(entry))
130 .filter((entry): entry is { key: string; value: string } => Boolean(entry));
97 return entries.length > 0 ? { kind: "urlencoded", entries } : undefined;131 return entries.length > 0 ? { kind: "urlencoded", entries } : undefined;
98 }132 }
99 133
100 if (typeof record.rawModeData === "string" && record.rawModeData.length > 0) {134 if (mode === "raw") {
101 return { kind: "raw", text: record.rawModeData };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 ?? "" };
102 }147 }
103 148
104 return undefined;149 return undefined;
executor/packages/core/src/runtimes/runtime-core.ts+10−4
@@ -39,16 +39,22 @@ function sanitizeExecutionResult(value: unknown): unknown { · 38 unmodified lines
39 return undefined;39 return undefined;
40 }40 }
41 41
42 const serialized = Result.try(() => JSON.stringify(value));42 let serialized: string | undefined;
43 if (serialized.isErr()) {43 try {
44 serialized = JSON.stringify(value);
45 } catch {
44 return String(value);46 return String(value);
45 }47 }
46 48
47 if (serialized.value === undefined) {49 if (serialized === undefined) {
48 return null;50 return null;
49 }51 }
50 52
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 }
52}58}
53 59
54function createToolsProxy(60function createToolsProxy(
executor/packages/core/src/tool-discovery.ts+7−5
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10} from "./tool-discovery/formatting";10} from "./tool-discovery/formatting";
11import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing";11import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing";
12import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";12import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";
13import { asRecord } from "./utils";13const recordSchema = z.record(z.unknown());
14 14
15const catalogToolsInputSchema = z.object({15const catalogToolsInputSchema = z.object({
16 namespace: z.string().optional(),16 namespace: z.string().optional(),
@@ -87,8 +87,9 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 70 unmodified lines
87 outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] },87 outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] },
88 },88 },
89 run: async (input: unknown, context) => {89 run: async (input: unknown, context) => {
90 const payload = asRecord(input);90 const payload = recordSchema.safeParse(input);
91 const parsedInput = catalogToolsInputSchema.safeParse(payload);91 const normalizedPayload = payload.success ? payload.data : {};
92 const parsedInput = catalogToolsInputSchema.safeParse(normalizedPayload);
92 const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();93 const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();
93 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();94 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
94 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;95 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
@@ -177,8 +178,9 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 82 unmodified lines
177 },178 },
178 },179 },
179 run: async (input: unknown, context) => {180 run: async (input: unknown, context) => {
180 const payload = asRecord(input);181 const payload = recordSchema.safeParse(input);
181 const parsedInput = discoverInputSchema.safeParse(payload);182 const normalizedPayload = payload.success ? payload.data : {};
183 const parsedInput = discoverInputSchema.safeParse(normalizedPayload);
182 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();184 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
183 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;185 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
184 const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1)));186 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 @@
1import { z } from "zod";
1import type { ToolDefinition } from "../types";2import type { ToolDefinition } from "../types";
2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";3import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";4import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
4import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize";5import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize";
5import { asRecord } from "../utils";
6import type { DiscoverIndexEntry } from "./types";6import type { DiscoverIndexEntry } from "./types";
7 7
8const GENERIC_NAMESPACE_SUFFIXES = new Set([8const GENERIC_NAMESPACE_SUFFIXES = new Set([
@@ -14,13 +14,21 @@ const GENERIC_NAMESPACE_SUFFIXES = new Set([ · 5 unmodified lines
14 "services",14 "services",
15]);15]);
16 16
17const recordSchema = z.record(z.unknown());
18const stringArraySchema = z.array(z.string());
19
20function coerceRecord(value: unknown): Record<string, unknown> {
21 const parsed = recordSchema.safeParse(value);
22 return parsed.success ? parsed.data : {};
23}
24
17function normalizeHint(type?: string): string {25function normalizeHint(type?: string): string {
18 return type && type.trim().length > 0 ? type : "unknown";26 return type && type.trim().length > 0 ? type : "unknown";
19}27}
20 28
21function isEmptyObjectSchema(schema: Record<string, unknown>): boolean {29function isEmptyObjectSchema(schema: Record<string, unknown>): boolean {
22 if (Object.keys(schema).length === 0) return true;30 if (Object.keys(schema).length === 0) return true;
23 const props = asRecord(schema.properties);31 const props = coerceRecord(schema.properties);
24 const required = Array.isArray(schema.required) ? schema.required : [];32 const required = Array.isArray(schema.required) ? schema.required : [];
25 return Object.keys(props).length === 0 && required.length === 0;33 return Object.keys(props).length === 0 && required.length === 0;
26}34}
@@ -104,18 +112,21 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 77 unmodified lines
104 const preferredPath = preferredToolPath(tool.path);112 const preferredPath = preferredToolPath(tool.path);
105 const aliases = getPathAliases(tool.path);113 const aliases = getPathAliases(tool.path);
106 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();114 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
115 const typing = tool.typing;
107 116
108 const inputSchema = asRecord(tool.typing?.inputSchema);117 const inputSchema = coerceRecord(typing?.inputSchema);
109 const outputSchema = asRecord(tool.typing?.outputSchema);118 const outputSchema = coerceRecord(typing?.outputSchema);
110 119
111 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);120 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);
112 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);121 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);
113 122
114 const requiredInputKeys = Array.isArray(tool.typing?.requiredInputKeys)123 const parsedRequiredInputKeys = stringArraySchema.safeParse(typing?.requiredInputKeys);
115 ? tool.typing!.requiredInputKeys!.filter((value): value is string => typeof value === "string")124 const requiredInputKeys = parsedRequiredInputKeys.success
125 ? parsedRequiredInputKeys.data
116 : extractTopLevelRequiredKeys(inputSchema);126 : extractTopLevelRequiredKeys(inputSchema);
117 const previewInputKeys = Array.isArray(tool.typing?.previewInputKeys)127 const parsedPreviewInputKeys = stringArraySchema.safeParse(typing?.previewInputKeys);
118 ? tool.typing!.previewInputKeys!.filter((value): value is string => typeof value === "string")128 const previewInputKeys = parsedPreviewInputKeys.success
129 ? parsedPreviewInputKeys.data
119 : buildPreviewKeys(inputSchema);130 : buildPreviewKeys(inputSchema);
120 131
121 const displayInputHint = normalizeHint(132 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
19import type { GraphqlToolSourceConfig } from "../tool/source-types";19import type { GraphqlToolSourceConfig } from "../tool/source-types";
20import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";20import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
21import type { ToolDefinition } from "../types";21import type { ToolDefinition } from "../types";
22import { asRecord } from "../utils";
23 22
24const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({23const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({
25 kind: z.string(),24 kind: z.string(),
@@ -53,7 +52,7 @@ const gqlEnumValueSchema = z.object({ · 27 unmodified lines
53 description: z.string().nullable().optional().transform((value) => value ?? null),52 description: z.string().nullable().optional().transform((value) => value ?? null),
54});53});
55 54
56const gqlTypeSchema: z.ZodType<GqlType> = z.object({55const gqlTypeSchema = z.object({
57 kind: z.string(),56 kind: z.string(),
58 name: z.string(),57 name: z.string(),
59 fields: z.array(gqlFieldSchema).nullable().optional().transform((value) => value ?? null),58 fields: z.array(gqlFieldSchema).nullable().optional().transform((value) => value ?? null),
@@ -61,7 +60,7 @@ const gqlTypeSchema: z.ZodType<GqlType> = z.object({ · 1 unmodified line
61 enumValues: z.array(gqlEnumValueSchema).nullable().optional().transform((value) => value ?? null),60 enumValues: z.array(gqlEnumValueSchema).nullable().optional().transform((value) => value ?? null),
62});61});
63 62
64const gqlSchemaSchema: z.ZodType<GqlSchema> = z.object({63const gqlSchemaSchema = z.object({
65 queryType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),64 queryType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),
66 mutationType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),65 mutationType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),
67 types: z.array(gqlTypeSchema),66 types: z.array(gqlTypeSchema),
@@ -72,6 +71,59 @@ const gqlIntrospectionResponseSchema = z.object({ · 4 unmodified lines
72 errors: z.array(z.unknown()).optional(),71 errors: z.array(z.unknown()).optional(),
73});72});
74 73
74const graphqlObjectInputSchema = z.object({
75 query: z.string().optional(),
76 variables: z.unknown().optional(),
77}).catchall(z.unknown());
78
79const graphqlToolInputSchema = z.union([
80 z.string(),
81 graphqlObjectInputSchema,
82]);
83
84const recordSchema = z.record(z.unknown());
85
86function coerceRecord(value: unknown): Record<string, unknown> {
87 const parsed = recordSchema.safeParse(value);
88 return parsed.success ? parsed.data : {};
89}
90
91function 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
75const INTROSPECTION_QUERY = `127const INTROSPECTION_QUERY = `
76 query IntrospectionQuery {128 query IntrospectionQuery {
77 __schema {129 __schema {
@@ -220,7 +272,7 @@ function parseGraphqlIntrospectionSchema(payload: unknown): Result<GqlSchema, Er · 142 unmodified lines
220}272}
221 273
222function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope {274function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope {
223 const payload = asRecord(value);275 const payload = coerceRecord(value);
224 return {276 return {
225 data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null,277 data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null,
226 errors: Array.isArray(payload.errors) ? payload.errors : [],278 errors: Array.isArray(payload.errors) ? payload.errors : [],
@@ -248,15 +300,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 21 unmodified lines
248 throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);300 throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);
249 }301 }
250 302
251 const introspectionJsonResult = await Result.tryPromise(() => introspectionResult.json());303 let introspectionJson: unknown;
252 if (introspectionJsonResult.isErr()) {304 try {
253 const message = introspectionJsonResult.error.cause instanceof Error305 introspectionJson = await introspectionResult.json();
254 ? introspectionJsonResult.error.cause.message306 } catch (error) {
255 : String(introspectionJsonResult.error.cause);307 const message = error instanceof Error ? error.message : String(error);
256 throw new Error(`GraphQL introspection response parse failed: ${message}`);308 throw new Error(`GraphQL introspection response parse failed: ${message}`);
257 }309 }
258 310
259 const schemaResult = parseGraphqlIntrospectionSchema(introspectionJsonResult.value);311 const schemaResult = parseGraphqlIntrospectionSchema(introspectionJson);
260 if (schemaResult.isErr()) {312 if (schemaResult.isErr()) {
261 throw schemaResult.error;313 throw schemaResult.error;
262 }314 }
@@ -306,18 +358,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 43 unmodified lines
306 authHeaders,358 authHeaders,
307 },359 },
308 run: async (input: unknown, context) => {360 run: async (input: unknown, context) => {
309 const payload = asRecord(input);361 const normalized = normalizeGraphqlToolInput(input);
310 const query = String(payload.query ?? "");362 if (!normalized.hasExplicitQuery) {
311 const variables = payload.variables ?? undefined;
312
313 if (!query.trim()) {
314 throw new Error("GraphQL query string is required");363 throw new Error("GraphQL query string is required");
315 }364 }
316 return await executeGraphqlRequest(365 return await executeGraphqlRequest(
317 config.endpoint,366 config.endpoint,
318 authHeaders,367 authHeaders,
319 query,368 normalized.query,
320 variables,369 normalized.variables,
321 context.credential?.headers,370 context.credential?.headers,
322 );371 );
323 },372 },
@@ -389,9 +438,9 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 65 unmodified lines
389 _pseudoTool: true,438 _pseudoTool: true,
390 run: async (input: unknown, context) => {439 run: async (input: unknown, context) => {
391 // If someone calls this directly, delegate to the main graphql tool440 // If someone calls this directly, delegate to the main graphql tool
392 const payload = asRecord(input);441 const normalized = normalizeGraphqlToolInput(input);
393 const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;442 const payload = normalized.payload;
394 if (!hasExplicitQuery) {443 if (!normalized.hasExplicitQuery) {
395 // Auto-build the query from the variables444 // Auto-build the query from the variables
396 payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);445 payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);
397 if (payload.variables === undefined) {446 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
8import type { McpToolSourceConfig } from "../tool/source-types";8import type { McpToolSourceConfig } from "../tool/source-types";
9import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";9import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
10import type { ToolDefinition } from "../types";10import type { ToolDefinition } from "../types";
11import { asRecord } from "../utils";
12import type { SerializedTool } from "../tool/source-serialization";11import type { SerializedTool } from "../tool/source-serialization";
13 12
14const listedToolsResponseSchema = z.object({13const listedToolsResponseSchema = z.object({
15 tools: z.array(z.record(z.unknown())).optional(),14 tools: z.array(z.record(z.unknown())).optional(),
16});15});
17 16
17const recordSchema = z.record(z.unknown());
18
19function coerceRecord(value: unknown): Record<string, unknown> {
20 const parsed = recordSchema.safeParse(value);
21 return parsed.success ? parsed.data : {};
22}
23
18function extractListedTools(value: unknown): Record<string, unknown>[] {24function extractListedTools(value: unknown): Record<string, unknown>[] {
19 const parsed = listedToolsResponseSchema.safeParse(value);25 const parsed = listedToolsResponseSchema.safeParse(value);
20 if (!parsed.success) {26 if (!parsed.success) {
@@ -68,8 +74,8 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 47 unmodified lines
68 74
69 return tools.map((tool) => {75 return tools.map((tool) => {
70 const toolName = String(tool.name ?? "tool");76 const toolName = String(tool.name ?? "tool");
71 const inputSchema = asRecord(tool.inputSchema);77 const inputSchema = coerceRecord(tool.inputSchema);
72 const outputSchema = asRecord(tool.outputSchema);78 const outputSchema = coerceRecord(tool.outputSchema);
73 const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0);79 const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0);
74 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);80 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
75 return {81 return {
@@ -93,7 +99,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines
93 toolName,99 toolName,
94 },100 },
95 run: async (input: unknown, context) => {101 run: async (input: unknown, context) => {
96 const payload = asRecord(input);102 const payload = coerceRecord(input);
97 const result = await callToolWithReconnect(toolName, payload, context.credential?.headers);103 const result = await callToolWithReconnect(toolName, payload, context.credential?.headers);
98 return extractMcpResult(result);104 return extractMcpResult(result);
99 },105 },
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+54−25
@@ -1,6 +1,5 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
4import { z } from "zod";3import { z } from "zod";
5import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";4import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";
6import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";5import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
@@ -16,7 +15,7 @@ import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman · 9 unmodified lines
16import { prepareOpenApiSpec } from "../openapi-prepare";15import { prepareOpenApiSpec } from "../openapi-prepare";
17import type { OpenApiToolSourceConfig } from "../tool/source-types";16import type { OpenApiToolSourceConfig } from "../tool/source-types";
18import type { ToolDefinition } from "../types";17import type { ToolDefinition } from "../types";
19import { asRecord } from "../utils";18import { toPlainObject } from "../utils";
20import type { SerializedTool } from "../tool/source-serialization";19import type { SerializedTool } from "../tool/source-serialization";
21 20
22const POSTMAN_SPEC_PREFIX = "postman:";21const POSTMAN_SPEC_PREFIX = "postman:";
@@ -30,6 +29,29 @@ const postmanCollectionResponseSchema = z.object({ · 7 unmodified lines
30 }).optional(),29 }).optional(),
31});30});
32 31
32const postmanFolderSchema = z.object({
33 id: z.string(),
34 name: z.string().optional(),
35 folder: z.string().optional(),
36});
37
38const 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
33function parsePostmanCollectionUid(spec: string): string | null {55function parsePostmanCollectionUid(spec: string): string | null {
34 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {56 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {
35 return null;57 return null;
@@ -67,14 +89,15 @@ async function loadPostmanCollectionTools( · 31 unmodified lines
67 throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`);89 throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`);
68 }90 }
69 91
70 const responseJsonResult = await Result.tryPromise(() => response.json());92 let responseJson: unknown;
71 if (responseJsonResult.isErr()) {93 try {
72 const cause = responseJsonResult.error.cause;94 responseJson = await response.json();
73 const message = cause instanceof Error ? cause.message : String(cause);95 } catch (error) {
96 const message = error instanceof Error ? error.message : String(error);
74 throw new Error(`Failed to parse API collection ${collectionUid}: ${message}`);97 throw new Error(`Failed to parse API collection ${collectionUid}: ${message}`);
75 }98 }
76 99
77 const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJsonResult.value);100 const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJson);
78 if (!parsedCollection.success) {101 if (!parsedCollection.success) {
79 throw new Error(`Invalid API collection response for ${collectionUid}: ${parsedCollection.error.message}`);102 throw new Error(`Invalid API collection response for ${collectionUid}: ${parsedCollection.error.message}`);
80 }103 }
@@ -87,11 +110,14 @@ async function loadPostmanCollectionTools( · 6 unmodified lines
87 110
88 const folderById = new Map<string, { name: string; parentId?: string }>();111 const folderById = new Map<string, { name: string; parentId?: string }>();
89 for (const folder of folders) {112 for (const folder of folders) {
90 const id = typeof folder.id === "string" ? folder.id : "";113 const parsedFolder = postmanFolderSchema.safeParse(folder);
91 if (!id) continue;114 if (!parsedFolder.success) continue;
92 const name = typeof folder.name === "string" && folder.name.trim().length > 0 ? folder.name : "folder";115
93 const parentId = typeof folder.folder === "string" ? folder.folder : undefined;116 const id = parsedFolder.data.id;
94 folderById.set(id, { name, parentId });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 });
95 }121 }
96 122
97 const sourceLabel = `catalog:${config.name}`;123 const sourceLabel = `catalog:${config.name}`;
@@ -115,29 +141,32 @@ async function loadPostmanCollectionTools( · 17 unmodified lines
115 const tools: ToolDefinition[] = [];141 const tools: ToolDefinition[] = [];
116 142
117 for (const request of requests) {143 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();
119 const method = methodRaw.length > 0 ? methodRaw : "get";148 const method = methodRaw.length > 0 ? methodRaw : "get";
120 const url = typeof request.url === "string" ? request.url : "";149 const url = parsedRequest.data.url;
121 if (!url) continue;150 if (!url) continue;
122 151
123 const requestId = typeof request.id === "string" ? request.id : "";152 const requestId = parsedRequest.data.id ?? "";
124 const requestName = typeof request.name === "string" && request.name.trim().length > 0153 const requestName = parsedRequest.data.name && parsedRequest.data.name.trim().length > 0
125 ? request.name.trim()154 ? parsedRequest.data.name.trim()
126 : requestId || `${method.toUpperCase()} request`;155 : requestId || `${method.toUpperCase()} request`;
127 const folderId = typeof request.folder === "string" ? request.folder : undefined;156 const folderId = parsedRequest.data.folder;
128 const folderPath = resolvePostmanFolderPath(folderId, folderById);157 const folderPath = resolvePostmanFolderPath(folderId, folderById);
129 const requestVariables = {158 const requestVariables = {
130 ...collectionVariables,159 ...collectionVariables,
131 ...extractPostmanVariableMap(request.pathVariableData),160 ...extractPostmanVariableMap(parsedRequest.data.pathVariableData ?? parsedRequest.data.pathVariables),
132 };161 };
133 162
134 const runSpec: PostmanSerializedRunSpec = {163 const runSpec: PostmanSerializedRunSpec = {
135 kind: "postman",164 kind: "postman",
136 method,165 method,
137 url,166 url,
138 headers: extractPostmanHeaderMap(request.headerData),167 headers: extractPostmanHeaderMap(parsedRequest.data.headerData ?? parsedRequest.data.headers),
139 queryParams: extractPostmanQueryEntries(request.queryParams),168 queryParams: extractPostmanQueryEntries(parsedRequest.data.queryParams ?? parsedRequest.data.queryParam),
140 body: extractPostmanBody(request),169 body: extractPostmanBody(parsedRequest.data.bodyData ?? parsedRequest.data.body),
141 variables: requestVariables,170 variables: requestVariables,
142 authHeaders,171 authHeaders,
143 };172 };
@@ -152,8 +181,8 @@ async function loadPostmanCollectionTools( · 8 unmodified lines
152 path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths),181 path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths),
153 source: sourceLabel,182 source: sourceLabel,
154 approval,183 approval,
155 description: typeof request.description === "string" && request.description.trim().length > 0184 description: parsedRequest.data.description && parsedRequest.data.description.trim().length > 0
156 ? request.description185 ? parsedRequest.data.description
157 : `${method.toUpperCase()} ${url}`,186 : `${method.toUpperCase()} ${url}`,
158 typing: {187 typing: {
159 inputSchema,188 inputSchema,
@@ -163,7 +192,7 @@ async function loadPostmanCollectionTools( · 3 unmodified lines
163 credential: credentialSpec,192 credential: credentialSpec,
164 _runSpec: runSpec,193 _runSpec: runSpec,
165 run: async (input: unknown, context) => {194 run: async (input: unknown, context) => {
166 const payloadRecord = asRecord(input);195 const payloadRecord = toPlainObject(input) ?? {};
167 return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers);196 return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers);
168 },197 },
169 };198 };
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
448 }448 }
449});449});
450 450
451test("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
451test("parseGraphqlOperationPaths handles aliases and fragments", async () => {532test("parseGraphqlOperationPaths handles aliases and fragments", async () => {
452 const parsed = parseGraphqlOperationPaths(533 const parsed = parseGraphqlOperationPaths(
453 "linear",534 "linear",
@@ -708,6 +789,7 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 254 unmodified lines
708 const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context);789 const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context);
709 const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context);790 const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context);
710 const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context);791 const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context);
792 const rawStringResult = await rawTool!.run("query { teams { totalCount } }", context);
711 793
712 expect(teamsResult).toEqual({794 expect(teamsResult).toEqual({
713 data: {795 data: {
@@ -739,6 +821,15 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 25 unmodified lines
739 },821 },
740 errors: [],822 errors: [],
741 });823 });
824 expect(rawStringResult).toEqual({
825 data: {
826 teams: {
827 totalCount: 1,
828 nodes: [{ id: "team_1", name: "Core" }],
829 },
830 },
831 errors: [],
832 });
742 833
743 const helperQueries = capturedQueries.filter((query) => !query.includes("__schema"));834 const helperQueries = capturedQueries.filter((query) => !query.includes("__schema"));
744 const helperVariables = capturedVariables.slice(1);835 const helperVariables = capturedVariables.slice(1);
executor/packages/core/src/tool-typing/convex-sanitize.ts+6−2
@@ -1,5 +1,9 @@
1import type { JsonSchema } from "../types";1import type { JsonSchema } from "../types";
2import { asRecord } from "../utils";2import { isPlainObject } from "../utils";
3
4function toRecord(value: unknown): Record<string, unknown> {
5 return isPlainObject(value) ? value : {};
6}
3 7
4function stripDollarKeysDeep(value: unknown): unknown {8function stripDollarKeysDeep(value: unknown): unknown {
5 if (Array.isArray(value)) {9 if (Array.isArray(value)) {
@@ -8,7 +12,7 @@ function stripDollarKeysDeep(value: unknown): unknown { · 2 unmodified lines
8 12
9 if (value && typeof value === "object") {13 if (value && typeof value === "object") {
10 const out: Record<string, unknown> = {};14 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))) {
12 if (key.startsWith("$")) continue;16 if (key.startsWith("$")) continue;
13 out[key] = stripDollarKeysDeep(child);17 out[key] = stripDollarKeysDeep(child);
14 }18 }
executor/packages/core/src/tool-typing/schema-utils.ts+8−4
@@ -1,5 +1,9 @@
1import type { JsonSchema } from "../types";1import type { JsonSchema } from "../types";
2import { asRecord } from "../utils";2import { isPlainObject } from "../utils";
3
4function toRecord(value: unknown): Record<string, unknown> {
5 return isPlainObject(value) ? value : {};
6}
3 7
4function uniq(values: string[]): string[] {8function uniq(values: string[]): string[] {
5 return [...new Set(values.filter((v) => v.trim().length > 0))];9 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
16 20
17 const allOf = asUnknownArray(schema.allOf);21 const allOf = asUnknownArray(schema.allOf);
18 for (const entry of allOf) {22 for (const entry of allOf) {
19 const nested = asRecord(entry);23 const nested = toRecord(entry);
20 if (Object.keys(nested).length === 0) continue;24 if (Object.keys(nested).length === 0) continue;
21 collectTopLevelRequiredKeys(nested, out);25 collectTopLevelRequiredKeys(nested, out);
22 }26 }
23}27}
24 28
25function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void {29function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void {
26 const props = asRecord(schema.properties);30 const props = toRecord(schema.properties);
27 out.push(...Object.keys(props));31 out.push(...Object.keys(props));
28 32
29 const allOf = asUnknownArray(schema.allOf);33 const allOf = asUnknownArray(schema.allOf);
30 for (const entry of allOf) {34 for (const entry of allOf) {
31 const nested = asRecord(entry);35 const nested = toRecord(entry);
32 if (Object.keys(nested).length === 0) continue;36 if (Object.keys(nested).length === 0) continue;
33 collectTopLevelPropertyKeys(nested, out);37 collectTopLevelPropertyKeys(nested, out);
34 }38 }
executor/packages/core/src/tool-typing/typebundle.ts+6−2
@@ -1,9 +1,13 @@
1import type { ToolDefinition } from "../types";1import type { ToolDefinition } from "../types";
2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";3import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
4import { asRecord } from "../utils";4import { isPlainObject } from "../utils";
5import { BASE_ENVIRONMENT_DTS } from "./env-types";5import { BASE_ENVIRONMENT_DTS } from "./env-types";
6 6
7function toRecord(value: unknown): Record<string, unknown> {
8 return isPlainObject(value) ? value : {};
9}
10
7const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;11const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
8 12
9function emitMemberName(name: string): string {13function emitMemberName(name: string): string {
@@ -31,7 +35,7 @@ function wrapDtsInNamespace(namespace: string, rawDts: string): string { · 21 unmodified lines
31function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {35function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {
32 if (!schema || Object.keys(schema).length === 0) return fallback;36 if (!schema || Object.keys(schema).length === 0) return fallback;
33 // Special-case empty object input to keep signatures tidy.37 // Special-case empty object input to keep signatures tidy.
34 const props = asRecord(schema.properties);38 const props = toRecord(schema.properties);
35 const required = Array.isArray(schema.required) ? schema.required : [];39 const required = Array.isArray(schema.required) ? schema.required : [];
36 if (Object.keys(props).length === 0 && required.length === 0) {40 if (Object.keys(props).length === 0 && required.length === 0) {
37 return fallback === "{}" ? "{}" : fallback;41 return fallback === "{}" ? "{}" : fallback;
executor/packages/core/src/tool/source-auth.test.ts+77−0
@@ -0,0 +1,77 @@
1import { expect, test } from "bun:test";
2import {
3 buildCredentialAuthHeaders,
4 buildCredentialSpec,
5 buildStaticAuthHeaders,
6 readCredentialOverrideHeaders,
7} from "./source-auth";
8
9test("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
19test("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
35test("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
44test("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
53test("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
64test("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 @@
1import type { OpenApiAuth } from "./source-types";1import type { OpenApiAuth } from "./source-types";
2import type { ToolCredentialSpec } from "../types";2import type { ToolCredentialSpec } from "../types";
3import { z } from "zod";
4
5export type CredentialHeaderAuthSpec = {
6 authType: "bearer" | "apiKey" | "basic";
7 headerName?: string;
8};
9
10const secretRecordSchema = z.record(z.unknown());
11const credentialOverrideHeadersSchema = z.object({
12 headers: z.record(z.coerce.string()).optional(),
13});
14
15function toRecord(value: unknown): Record<string, unknown> {
16 const parsed = secretRecordSchema.safeParse(value);
17 return parsed.success ? parsed.data : {};
18}
19
20function getTrimmedString(value: unknown): string {
21 if (typeof value !== "string") {
22 return "";
23 }
24
25 return value.trim();
26}
27
28function 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
45export 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
73export 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}
3 86
4export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> {87export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> {
5 if (!auth || auth.type === "none") return {};88 if (!auth || auth.type === "none") return {};
executor/packages/core/src/tool/source-execution.ts+10−3
@@ -1,5 +1,12 @@
1import { Result, TaggedError } from "better-result";1import { Result, TaggedError } from "better-result";
2import { asRecord } from "../utils";2import { z } from "zod";
3
4const recordSchema = z.record(z.unknown());
5
6function toRecord(value: unknown): Record<string, unknown> {
7 const parsed = recordSchema.safeParse(value);
8 return parsed.success ? parsed.data : {};
9}
3 10
4type OpenApiRequestErrorArgs = {11type OpenApiRequestErrorArgs = {
5 status: number | null;12 status: number | null;
@@ -88,7 +95,7 @@ export async function executeOpenApiRequest( · 82 unmodified lines
88 input: unknown,95 input: unknown,
89 credentialHeaders?: Record<string, string>,96 credentialHeaders?: Record<string, string>,
90): Promise<Result<unknown, OpenApiRequestError>> {97): Promise<Result<unknown, OpenApiRequestError>> {
91 const payload = asRecord(input);98 const payload = toRecord(input);
92 const readMethods = new Set(["get", "head", "options"]);99 const readMethods = new Set(["get", "head", "options"]);
93 const { url, bodyInput } = buildOpenApiUrl(100 const { url, bodyInput } = buildOpenApiUrl(
94 runSpec.baseUrl,101 runSpec.baseUrl,
@@ -154,7 +161,7 @@ export async function executeOpenApiRequest( · 59 unmodified lines
154function hasGraphqlData(data: unknown): boolean {161function hasGraphqlData(data: unknown): boolean {
155 if (data === null || data === undefined) return false;162 if (data === null || data === undefined) return false;
156 if (Array.isArray(data)) return data.length > 0;163 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;
158 return true;165 return true;
159}166}
160 167
executor/packages/core/src/tool/source-serialization.ts+76−29
@@ -5,7 +5,13 @@ import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest · 4 unmodified lines
5import { Result } from "better-result";5import { Result } from "better-result";
6import { z } from "zod";6import { z } from "zod";
7import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";7import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";
8import { asRecord } from "../utils";8
9const recordSchema = z.record(z.unknown());
10
11function toRecord(value: unknown): Record<string, unknown> {
12 const parsed = recordSchema.safeParse(value);
13 return parsed.success ? parsed.data : {};
14}
9 15
10export interface SerializedTool {16export interface SerializedTool {
11 path: string;17 path: string;
@@ -124,21 +130,62 @@ const toolTypingSchema: z.ZodType<ToolTyping> = z.object({ · 112 unmodified lines
124 130
125const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({131const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({
126 sourceKey: z.string(),132 sourceKey: z.string(),
127 mode: z.enum(["static", "workspace", "actor"]),133 mode: z.enum(["workspace", "actor"]),
128 authType: z.enum(["bearer", "apiKey", "basic"]),134 authType: z.enum(["bearer", "apiKey", "basic"]),
129 headerName: z.string().optional(),135 headerName: z.string().optional(),
130 staticSecretJson: z.record(z.unknown()).optional(),136 staticSecretJson: z.record(z.unknown()).optional(),
131});137});
132 138
133const graphqlRawInputSchema = z.object({139const graphqlObjectInputSchema = z.object({
134 query: z.string(),140 query: z.string().optional(),
135 variables: z.unknown().optional(),141 variables: z.unknown().optional(),
142}).catchall(z.unknown());
143
144const graphqlInvocationInputSchema = z.union([
145 z.string(),
146 graphqlObjectInputSchema,
147]);
148
149const invalidSerializedToolFallbackSchema = z.object({
150 path: z.string().optional(),
151 source: z.string().optional(),
136});152});
137 153
138const graphqlFieldInputSchema = z.object({154function normalizeGraphqlInvocationInput(input: unknown): {
139 query: z.string().optional(),155 payload: Record<string, unknown>;
140 variables: z.unknown().optional(),156 query: string;
141}).passthrough();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}
142 189
143const serializedRunSpecSchema = z.union([190const serializedRunSpecSchema = z.union([
144 openApiRunSpecSchema,191 openApiRunSpecSchema,
@@ -229,16 +276,16 @@ export function rehydrateTools( · 84 unmodified lines
229 return serialized.map((candidate, index) => {276 return serialized.map((candidate, index) => {
230 const parsed = parseSerializedTool(candidate);277 const parsed = parseSerializedTool(candidate);
231 if (parsed.isErr()) {278 if (parsed.isErr()) {
232 const raw = asRecord(candidate);279 const fallback = invalidSerializedToolFallbackSchema.safeParse(candidate);
233 const path = typeof raw.path === "string" && raw.path.trim().length > 0280 const path = fallback.success && fallback.data.path && fallback.data.path.trim().length > 0
234 ? raw.path281 ? fallback.data.path
235 : `invalid_serialized_tool_${index + 1}`;282 : `invalid_serialized_tool_${index + 1}`;
236 283
237 return {284 return {
238 path,285 path,
239 description: "Invalid serialized tool definition",286 description: "Invalid serialized tool definition",
240 approval: "required",287 approval: "required",
241 source: typeof raw.source === "string" ? raw.source : undefined,288 source: fallback.success ? fallback.data.source : undefined,
242 run: async () => {289 run: async () => {
243 throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`);290 throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`);
244 },291 },
@@ -282,7 +329,7 @@ export function rehydrateTools( · 37 unmodified lines
282 return {329 return {
283 ...base,330 ...base,
284 run: async (input: unknown, context) => {331 run: async (input: unknown, context) => {
285 const payload = asRecord(input);332 const payload = toRecord(input);
286 return await executePostmanRequest(runSpec, payload, context.credential?.headers);333 return await executePostmanRequest(runSpec, payload, context.credential?.headers);
287 },334 },
288 };335 };
@@ -305,7 +352,7 @@ export function rehydrateTools( · 16 unmodified lines
305 () => connectMcp(url, queryParams, transport, mergedHeaders),352 () => connectMcp(url, queryParams, transport, mergedHeaders),
306 );353 );
307 354
308 const payload = asRecord(input);355 const payload = toRecord(input);
309 const result = await callMcpToolWithReconnect(356 const result = await callMcpToolWithReconnect(
310 () => conn.client.callTool({ name: toolName, arguments: payload }),357 () => conn.client.callTool({ name: toolName, arguments: payload }),
311 async () => {358 async () => {
@@ -330,14 +377,17 @@ export function rehydrateTools( · 18 unmodified lines
330 return {377 return {
331 ...base,378 ...base,
332 run: async (input: unknown, context) => {379 run: async (input: unknown, context) => {
333 const payload = asRecord(input);380 const normalized = normalizeGraphqlInvocationInput(input);
334 const parsedInput = graphqlRawInputSchema.safeParse(payload);381 if (!normalized.hasExplicitQuery) {
335 const query = parsedInput.success ? parsedInput.data.query : "";
336 if (!query.trim()) {
337 throw new Error("GraphQL query string is required");382 throw new Error("GraphQL query string is required");
338 }383 }
339 const variables = parsedInput.success ? parsedInput.data.variables : undefined;384 const response = await executeGraphqlRequest(
340 const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);385 endpoint,
386 authHeaders,
387 normalized.query,
388 normalized.variables,
389 context.credential?.headers,
390 );
341 if (response.isErr()) {391 if (response.isErr()) {
342 throw new Error(response.error.message);392 throw new Error(response.error.message);
343 }393 }
@@ -351,15 +401,12 @@ export function rehydrateTools( · 7 unmodified lines
351 return {401 return {
352 ...base,402 ...base,
353 run: async (input: unknown, context) => {403 run: async (input: unknown, context) => {
354 const payload = asRecord(input);404 const normalized = normalizeGraphqlInvocationInput(input);
355 const parsedInput = graphqlFieldInputSchema.safeParse(payload);405 const query = normalized.hasExplicitQuery ? normalized.query : queryTemplate;
356 const normalizedInput = parsedInput.success ? parsedInput.data : {};406
357 const hasExplicitQuery = typeof normalizedInput.query === "string" && normalizedInput.query.trim().length > 0;407 let variables = normalized.variables;
358 const query = hasExplicitQuery ? normalizedInput.query : queryTemplate;408 if (variables === undefined && !normalized.hasExplicitQuery) {
359 409 variables = normalizeGraphqlFieldVariables(argNames ?? [], normalized.payload);
360 let variables = normalizedInput.variables;
361 if (variables === undefined && !hasExplicitQuery) {
362 variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);
363 }410 }
364 411
365 const envelopeResult = await executeGraphqlRequest(412 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
6export type ApprovalStatus = "pending" | "approved" | "denied";6export type ApprovalStatus = "pending" | "approved" | "denied";
7export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";7export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";
8export type PolicyDecision = "allow" | "require_approval" | "deny";8export type PolicyDecision = "allow" | "require_approval" | "deny";
9export type PolicyScopeType = "organization" | "workspace";
10export type PolicyMatchType = "glob" | "exact";
11export type PolicyEffect = "allow" | "deny";
12export type PolicyApprovalMode = "inherit" | "auto" | "required";
13export type OwnerScopeType = "organization" | "workspace";
9export type CredentialScope = "workspace" | "actor";14export type CredentialScope = "workspace" | "actor";
10export type CredentialProvider = "local-convex" | "workos-vault";15export type CredentialProvider = "local-convex" | "workos-vault";
11export type ToolApprovalMode = "auto" | "required";16export type ToolApprovalMode = "auto" | "required";
@@ -87,20 +92,31 @@ export interface ToolCallRecord { · 75 unmodified lines
87 92
88export interface AccessPolicyRecord {93export interface AccessPolicyRecord {
89 id: string;94 id: string;
90 workspaceId: Id<"workspaces">;95 scopeType?: PolicyScopeType;
91 actorId?: string;96 organizationId?: Id<"organizations">;
97 workspaceId?: Id<"workspaces">;
98 targetActorId?: string;
92 clientId?: string;99 clientId?: string;
93 toolPathPattern: string;100 resourceType?: "tool_path";
94 decision: PolicyDecision;101 resourcePattern?: string;
102 matchType?: PolicyMatchType;
103 effect?: PolicyEffect;
104 approvalMode?: PolicyApprovalMode;
95 priority: number;105 priority: number;
96 createdAt: number;106 createdAt: number;
97 updatedAt: number;107 updatedAt: number;
108 // Legacy aliases retained for callers that still expect v1 fields.
109 actorId?: string;
110 toolPathPattern: string;
111 decision: PolicyDecision;
98}112}
99 113
100export interface CredentialRecord {114export interface CredentialRecord {
101 id: string;115 id: string;
102 bindingId?: string;116 bindingId?: string;
103 workspaceId: Id<"workspaces">;117 ownerScopeType?: OwnerScopeType;
118 organizationId?: Id<"organizations">;
119 workspaceId?: Id<"workspaces">;
104 sourceKey: string;120 sourceKey: string;
105 scope: CredentialScope;121 scope: CredentialScope;
106 actorId?: string;122 actorId?: string;
@@ -114,10 +130,14 @@ export interface CredentialRecord { · 7 unmodified lines
114 130
115export interface ToolSourceRecord {131export interface ToolSourceRecord {
116 id: string;132 id: string;
117 workspaceId: Id<"workspaces">;133 ownerScopeType?: OwnerScopeType;
134 organizationId?: Id<"organizations">;
135 workspaceId?: Id<"workspaces">;
118 name: string;136 name: string;
119 type: ToolSourceType;137 type: ToolSourceType;
120 config: Record<string, unknown>;138 config: Record<string, unknown>;
139 specHash?: string;
140 authFingerprint?: string;
121 enabled: boolean;141 enabled: boolean;
122 createdAt: number;142 createdAt: number;
123 updatedAt: number;143 updatedAt: number;
executor/packages/core/src/utils.ts+13−7
@@ -1,12 +1,18 @@
1/**1/**
2 * Safely cast an unknown value to a Record<string, unknown>.2 * Runtime guard for plain object payloads.
3 * Returns an empty object for nullish, non-object, or array values.
4 */3 */
5export function asRecord(value: unknown): Record<string, unknown> {4export function isPlainObject(value: unknown): value is Record<string, unknown> {
6 if (value && typeof value === "object" && !Array.isArray(value)) {5 if (!value || typeof value !== "object" || Array.isArray(value)) return false;
7 return value as Record<string, unknown>;6
8 }7 const prototype = Object.getPrototypeOf(value);
9 return {};8 return prototype === Object.prototype || prototype === null;
9}
10
11/**
12 * Convert a runtime value into a plain object record, or return undefined.
13 */
14export function toPlainObject(value: unknown): Record<string, unknown> | undefined {
15 return isPlainObject(value) ? value : undefined;
10}16}
11 17
12/**18/**
executor/packages/core/src/workspace/tool-cache.test.ts+17−3
@@ -5,6 +5,7 @@ · 4 unmodified lines
5 * round-trip with functional `run` methods.5 * round-trip with functional `run` methods.
6 */6 */
7import { test, expect, describe } from "bun:test";7import { test, expect, describe } from "bun:test";
8import { z } from "zod";
8import type { Id } from "../../../database/convex/_generated/dataModel";9import type { Id } from "../../../database/convex/_generated/dataModel";
9import {10import {
10 prepareOpenApiSpec,11 prepareOpenApiSpec,
@@ -20,6 +21,12 @@ import type { ToolDefinition } from "../types"; · 9 unmodified lines
20 21
21const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;22const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
22 23
24const unknownRecordSchema = z.record(z.unknown());
25const graphqlBodySchema = z.object({
26 query: z.string().optional(),
27 variables: z.unknown().optional(),
28});
29
23function makeBaseTools(): Map<string, ToolDefinition> {30function makeBaseTools(): Map<string, ToolDefinition> {
24 return new Map([31 return new Map([
25 [32 [
@@ -36,7 +43,8 @@ function makeBaseTools(): Map<string, ToolDefinition> { · 10 unmodified lines
36 previewInputKeys: ["message"],43 previewInputKeys: ["message"],
37 },44 },
38 run: async (input: unknown) => {45 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 : {};
40 return payload.message;48 return payload.message;
41 },49 },
42 },50 },
@@ -338,7 +346,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 295 unmodified lines
338 const originalFetch = globalThis.fetch;346 const originalFetch = globalThis.fetch;
339 const calls: Array<{ query: string; variables: unknown }> = [];347 const calls: Array<{ query: string; variables: unknown }> = [];
340 globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {348 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 : {};
342 calls.push({ query: body.query ?? "", variables: body.variables });351 calls.push({ query: body.query ?? "", variables: body.variables });
343 352
344 if ((body.query ?? "").includes("teams")) {353 if ((body.query ?? "").includes("teams")) {
@@ -359,14 +368,19 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 14 unmodified lines
359 { query: "query viewer { viewer { id } }" },368 { query: "query viewer { viewer { id } }" },
360 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },369 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
361 );370 );
371 const rawStringResult = await rawTool!.run(
372 "query viewer { viewer { id } }",
373 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
374 );
362 const teamsResult = await teamsTool!.run(375 const teamsResult = await teamsTool!.run(
363 {},376 {},
364 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },377 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
365 );378 );
366 379
367 expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });380 expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
381 expect(rawStringResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
368 expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] });382 expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] });
369 expect(calls.length).toBe(2);383 expect(calls.length).toBe(3);
370 } finally {384 } finally {
371 globalThis.fetch = originalFetch;385 globalThis.fetch = originalFetch;
372 }386 }
executor/packages/database/convex/_generated/api.d.ts+79−25
@@ -88,9 +88,10 @@ export declare const api: { · 87 unmodified lines
88 {88 {
89 actorId?: string;89 actorId?: string;
90 id?: string;90 id?: string;
91 ownerScopeType?: "organization" | "workspace";
91 provider?: "local-convex" | "workos-vault";92 provider?: "local-convex" | "workos-vault";
92 scope: "workspace" | "actor";93 scope: "workspace" | "actor";
93 secretJson: any;94 secretJson: Record<string, any>;
94 sessionId?: string;95 sessionId?: string;
95 sourceKey: string;96 sourceKey: string;
96 workspaceId: Id<"workspaces">;97 workspaceId: Id<"workspaces">;
@@ -106,7 +107,7 @@ export declare const api: { · 9 unmodified lines
106 actorId?: string;107 actorId?: string;
107 clientId?: string;108 clientId?: string;
108 code: string;109 code: string;
109 metadata?: any;110 metadata?: Record<string, any>;
110 runtimeId?: string;111 runtimeId?: string;
111 sessionId?: string;112 sessionId?: string;
112 timeoutMs?: number;113 timeoutMs?: number;
@@ -268,7 +269,7 @@ export declare const api: { · 155 unmodified lines
268 "public",269 "public",
269 {270 {
270 callId: string;271 callId: string;
271 input?: any;272 input?: Record<string, any>;
272 internalSecret: string;273 internalSecret: string;
273 runId: string;274 runId: string;
274 toolPath: string;275 toolPath: string;
@@ -336,10 +337,14 @@ export declare const api: { · 61 unmodified lines
336 "public",337 "public",
337 {338 {
338 actorId?: string;339 actorId?: string;
340 approvalMode?: "inherit" | "auto" | "required";
339 clientId?: string;341 clientId?: string;
340 decision: "allow" | "require_approval" | "deny";342 decision?: "allow" | "require_approval" | "deny";
343 effect?: "allow" | "deny";
341 id?: string;344 id?: string;
345 matchType?: "glob" | "exact";
342 priority?: number;346 priority?: number;
347 scopeType?: "organization" | "workspace";
343 sessionId?: string;348 sessionId?: string;
344 toolPathPattern: string;349 toolPathPattern: string;
345 workspaceId: Id<"workspaces">;350 workspaceId: Id<"workspaces">;
@@ -352,10 +357,11 @@ export declare const api: { · 6 unmodified lines
352 {357 {
353 actorId?: string;358 actorId?: string;
354 id?: string;359 id?: string;
355 overridesJson?: any;360 overridesJson?: Record<string, any>;
361 ownerScopeType?: "organization" | "workspace";
356 provider?: "local-convex" | "workos-vault";362 provider?: "local-convex" | "workos-vault";
357 scope: "workspace" | "actor";363 scope: "workspace" | "actor";
358 secretJson: any;364 secretJson: Record<string, any>;
359 sessionId?: string;365 sessionId?: string;
360 sourceKey: string;366 sourceKey: string;
361 workspaceId: Id<"workspaces">;367 workspaceId: Id<"workspaces">;
@@ -366,10 +372,11 @@ export declare const api: { · 4 unmodified lines
366 "mutation",372 "mutation",
367 "public",373 "public",
368 {374 {
369 config: any;375 config: Record<string, any>;
370 enabled?: boolean;376 enabled?: boolean;
371 id?: string;377 id?: string;
372 name: string;378 name: string;
379 ownerScopeType?: "organization" | "workspace";
373 sessionId?: string;380 sessionId?: string;
374 type: "mcp" | "openapi" | "graphql";381 type: "mcp" | "openapi" | "graphql";
375 workspaceId: Id<"workspaces">;382 workspaceId: Id<"workspaces">;
@@ -486,7 +493,12 @@ export declare const internal: { · 110 unmodified lines
486 createApproval: FunctionReference<493 createApproval: FunctionReference<
487 "mutation",494 "mutation",
488 "internal",495 "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 },
490 any502 any
491 >;503 >;
492 getApproval: FunctionReference<504 getApproval: FunctionReference<
@@ -537,7 +549,12 @@ export declare const internal: { · 44 unmodified lines
537 createApproval: FunctionReference<549 createApproval: FunctionReference<
538 "mutation",550 "mutation",
539 "internal",551 "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 },
541 any558 any
542 >;559 >;
543 createTask: FunctionReference<560 createTask: FunctionReference<
@@ -548,7 +565,7 @@ export declare const internal: { · 4 unmodified lines
548 clientId?: string;565 clientId?: string;
549 code: string;566 code: string;
550 id: string;567 id: string;
551 metadata?: any;568 metadata?: Record<string, any>;
552 runtimeId: string;569 runtimeId: string;
553 timeoutMs?: number;570 timeoutMs?: number;
554 workspaceId: Id<"workspaces">;571 workspaceId: Id<"workspaces">;
@@ -558,7 +575,12 @@ export declare const internal: { · 3 unmodified lines
558 createTaskEvent: FunctionReference<575 createTaskEvent: FunctionReference<
559 "mutation",576 "mutation",
560 "internal",577 "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 },
562 any584 any
563 >;585 >;
564 credentials: {586 credentials: {
@@ -586,10 +608,11 @@ export declare const internal: { · 21 unmodified lines
586 {608 {
587 actorId?: string;609 actorId?: string;
588 id?: string;610 id?: string;
589 overridesJson?: any;611 overridesJson?: Record<string, any>;
612 ownerScopeType?: "organization" | "workspace";
590 provider?: "local-convex" | "workos-vault";613 provider?: "local-convex" | "workos-vault";
591 scope: "workspace" | "actor";614 scope: "workspace" | "actor";
592 secretJson: any;615 secretJson: Record<string, any>;
593 sourceKey: string;616 sourceKey: string;
594 workspaceId: Id<"workspaces">;617 workspaceId: Id<"workspaces">;
595 },618 },
@@ -733,11 +756,17 @@ export declare const internal: { · 137 unmodified lines
733 "internal",756 "internal",
734 {757 {
735 actorId?: string;758 actorId?: string;
759 approvalMode?: "inherit" | "auto" | "required";
736 clientId?: string;760 clientId?: string;
737 decision: "allow" | "require_approval" | "deny";761 decision?: "allow" | "require_approval" | "deny";
762 effect?: "allow" | "deny";
738 id?: string;763 id?: string;
764 matchType?: "glob" | "exact";
739 priority?: number;765 priority?: number;
740 toolPathPattern: string;766 resourcePattern?: string;
767 scopeType?: "organization" | "workspace";
768 targetActorId?: string;
769 toolPathPattern?: string;
741 workspaceId: Id<"workspaces">;770 workspaceId: Id<"workspaces">;
742 },771 },
743 any772 any
@@ -775,7 +804,12 @@ export declare const internal: { · 31 unmodified lines
775 createTaskEvent: FunctionReference<804 createTaskEvent: FunctionReference<
776 "mutation",805 "mutation",
777 "internal",806 "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 },
779 any813 any
780 >;814 >;
781 listTaskEvents: FunctionReference<815 listTaskEvents: FunctionReference<
@@ -794,7 +828,7 @@ export declare const internal: { · 12 unmodified lines
794 clientId?: string;828 clientId?: string;
795 code: string;829 code: string;
796 id: string;830 id: string;
797 metadata?: any;831 metadata?: Record<string, any>;
798 runtimeId: string;832 runtimeId: string;
799 timeoutMs?: number;833 timeoutMs?: number;
800 workspaceId: Id<"workspaces">;834 workspaceId: Id<"workspaces">;
@@ -897,10 +931,11 @@ export declare const internal: { · 96 unmodified lines
897 "mutation",931 "mutation",
898 "internal",932 "internal",
899 {933 {
900 config: any;934 config: Record<string, any>;
901 enabled?: boolean;935 enabled?: boolean;
902 id?: string;936 id?: string;
903 name: string;937 name: string;
938 ownerScopeType?: "organization" | "workspace";
904 type: "mcp" | "openapi" | "graphql";939 type: "mcp" | "openapi" | "graphql";
905 workspaceId: Id<"workspaces">;940 workspaceId: Id<"workspaces">;
906 },941 },
@@ -912,11 +947,17 @@ export declare const internal: { · 5 unmodified lines
912 "internal",947 "internal",
913 {948 {
914 actorId?: string;949 actorId?: string;
950 approvalMode?: "inherit" | "auto" | "required";
915 clientId?: string;951 clientId?: string;
916 decision: "allow" | "require_approval" | "deny";952 decision?: "allow" | "require_approval" | "deny";
953 effect?: "allow" | "deny";
917 id?: string;954 id?: string;
955 matchType?: "glob" | "exact";
918 priority?: number;956 priority?: number;
919 toolPathPattern: string;957 resourcePattern?: string;
958 scopeType?: "organization" | "workspace";
959 targetActorId?: string;
960 toolPathPattern?: string;
920 workspaceId: Id<"workspaces">;961 workspaceId: Id<"workspaces">;
921 },962 },
922 any963 any
@@ -927,10 +968,11 @@ export declare const internal: { · 4 unmodified lines
927 {968 {
928 actorId?: string;969 actorId?: string;
929 id?: string;970 id?: string;
930 overridesJson?: any;971 overridesJson?: Record<string, any>;
972 ownerScopeType?: "organization" | "workspace";
931 provider?: "local-convex" | "workos-vault";973 provider?: "local-convex" | "workos-vault";
932 scope: "workspace" | "actor";974 scope: "workspace" | "actor";
933 secretJson: any;975 secretJson: Record<string, any>;
934 sourceKey: string;976 sourceKey: string;
935 workspaceId: Id<"workspaces">;977 workspaceId: Id<"workspaces">;
936 },978 },
@@ -951,10 +993,11 @@ export declare const internal: { · 14 unmodified lines
951 "mutation",993 "mutation",
952 "internal",994 "internal",
953 {995 {
954 config: any;996 config: Record<string, any>;
955 enabled?: boolean;997 enabled?: boolean;
956 id?: string;998 id?: string;
957 name: string;999 name: string;
1000 ownerScopeType?: "organization" | "workspace";
958 type: "mcp" | "openapi" | "graphql";1001 type: "mcp" | "openapi" | "graphql";
959 workspaceId: Id<"workspaces">;1002 workspaceId: Id<"workspaces">;
960 },1003 },
@@ -981,7 +1024,7 @@ export declare const internal: { · 20 unmodified lines
981 actorId: string;1024 actorId: string;
982 clientId?: string;1025 clientId?: string;
983 code: string;1026 code: string;
984 metadata?: any;1027 metadata?: Record<string, any>;
985 runtimeId?: string;1028 runtimeId?: string;
986 scheduleAfterCreate?: boolean;1029 scheduleAfterCreate?: boolean;
987 timeoutMs?: number;1030 timeoutMs?: number;
@@ -1006,7 +1049,12 @@ export declare const internal: { · 18 unmodified lines
1006 handleExternalToolCall: FunctionReference<1049 handleExternalToolCall: FunctionReference<
1007 "action",1050 "action",
1008 "internal",1051 "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 },
1010 any1058 any
1011 >;1059 >;
1012 listToolsInternal: FunctionReference<1060 listToolsInternal: FunctionReference<
@@ -1258,6 +1306,12 @@ export declare const internal: { · 245 unmodified lines
1258 },1306 },
1259 any1307 any
1260 >;1308 >;
1309 pruneBuilds: FunctionReference<
1310 "mutation",
1311 "internal",
1312 { maxRetainedBuilds?: number; workspaceId: Id<"workspaces"> },
1313 any
1314 >;
1261 putNamespacesBatch: FunctionReference<1315 putNamespacesBatch: FunctionReference<
1262 "mutation",1316 "mutation",
1263 "internal",1317 "internal",
executor/packages/database/convex/credentialsNode.ts+2−0
@@ -8,12 +8,14 @@ import { jsonObjectValidator } from "../src/database/validators"; · 7 unmodified lines
8 8
9const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));9const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
10const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));10const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
11const ownerScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace"));
11 12
12export const upsertCredential = action({13export const upsertCredential = action({
13 args: {14 args: {
14 id: v.optional(v.string()),15 id: v.optional(v.string()),
15 workspaceId: v.id("workspaces"),16 workspaceId: v.id("workspaces"),
16 sessionId: v.optional(v.string()),17 sessionId: v.optional(v.string()),
18 ownerScopeType: v.optional(ownerScopeTypeValidator),
17 sourceKey: v.string(),19 sourceKey: v.string(),
18 scope: credentialScopeValidator,20 scope: credentialScopeValidator,
19 actorId: v.optional(v.string()),21 actorId: v.optional(v.string()),
executor/packages/database/convex/database/credentials.ts+156−55
@@ -1,4 +1,5 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { z } from "zod";
2import { internalMutation, internalQuery } from "../_generated/server";3import { internalMutation, internalQuery } from "../_generated/server";
3import { mapCredential } from "../../src/database/mappers";4import { mapCredential } from "../../src/database/mappers";
4import { computeBoundAuthFingerprint } from "../../src/database/readers";5import { computeBoundAuthFingerprint } from "../../src/database/readers";
@@ -6,8 +7,15 @@ import { · 1 unmodified line
6 credentialProviderValidator,7 credentialProviderValidator,
7 credentialScopeValidator,8 credentialScopeValidator,
8 jsonObjectValidator,9 jsonObjectValidator,
10 ownerScopeTypeValidator,
9} from "../../src/database/validators";11} from "../../src/database/validators";
10import { asRecord } from "../../src/lib/object";12
13const recordSchema = z.record(z.unknown());
14
15function toRecordValue(value: unknown): Record<string, unknown> {
16 const parsed = recordSchema.safeParse(value);
17 return parsed.success ? parsed.data : {};
18}
11 19
12function scopeKeyForCredential(scope: "workspace" | "actor", actorId?: string): string {20function scopeKeyForCredential(scope: "workspace" | "actor", actorId?: string): string {
13 if (scope === "workspace") {21 if (scope === "workspace") {
@@ -26,6 +34,7 @@ export const upsertCredential = internalMutation({ · 12 unmodified lines
26 args: {34 args: {
27 id: v.optional(v.string()),35 id: v.optional(v.string()),
28 workspaceId: v.id("workspaces"),36 workspaceId: v.id("workspaces"),
37 ownerScopeType: v.optional(ownerScopeTypeValidator),
29 sourceKey: v.string(),38 sourceKey: v.string(),
30 scope: credentialScopeValidator,39 scope: credentialScopeValidator,
31 actorId: v.optional(v.string()),40 actorId: v.optional(v.string()),
@@ -35,20 +44,39 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines
35 },44 },
36 handler: async (ctx, args) => {45 handler: async (ctx, args) => {
37 const now = Date.now();46 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
38 const actorId = args.scope === "actor" ? args.actorId?.trim() : undefined;55 const actorId = args.scope === "actor" ? args.actorId?.trim() : undefined;
39 const scopeKey = scopeKeyForCredential(args.scope, actorId);56 const scopeKey = scopeKeyForCredential(args.scope, actorId);
40 const submittedSecret = asRecord(args.secretJson);57 const submittedSecret = toRecordValue(args.secretJson);
41 const hasSubmittedSecret = Object.keys(submittedSecret).length > 0;58 const hasSubmittedSecret = Object.keys(submittedSecret).length > 0;
42 59
43 const existing = await ctx.db60 const existing = ownerScopeType === "workspace"
44 .query("sourceCredentials")61 ? await ctx.db
45 .withIndex("by_workspace_source_scope_key", (q) =>62 .query("sourceCredentials")
46 q63 .withIndex("by_workspace_source_scope_key", (q) =>
47 .eq("workspaceId", args.workspaceId)64 q
48 .eq("sourceKey", args.sourceKey)65 .eq("workspaceId", args.workspaceId)
49 .eq("scopeKey", scopeKey),66 .eq("sourceKey", args.sourceKey)
50 )67 .eq("scopeKey", scopeKey),
51 .unique();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();
52 80
53 let requestedId = args.id?.trim() || "";81 let requestedId = args.id?.trim() || "";
54 if (requestedId.startsWith("bind_")) {82 if (requestedId.startsWith("bind_")) {
@@ -56,31 +84,42 @@ export const upsertCredential = internalMutation({ · 1 unmodified line
56 .query("sourceCredentials")84 .query("sourceCredentials")
57 .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId))85 .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId))
58 .unique();86 .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) {
60 requestedId = binding.credentialId;92 requestedId = binding.credentialId;
61 }93 }
62 }94 }
63 95
64 const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`;96 const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`;
65 97
66 const linkedRows = await ctx.db98 const linkedRows = ownerScopeType === "workspace"
67 .query("sourceCredentials")99 ? await ctx.db
68 .withIndex("by_workspace_credential", (q) =>100 .query("sourceCredentials")
69 q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId),101 .withIndex("by_workspace_credential", (q) =>
70 )102 q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId),
71 .collect();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();
72 const exemplar = linkedRows[0] ?? existing ?? null;111 const exemplar = linkedRows[0] ?? existing ?? null;
73 112
74 const provider = args.provider ?? exemplar?.provider ?? "local-convex";113 const provider = args.provider ?? exemplar?.provider ?? "local-convex";
75 const fallbackSecret = asRecord(exemplar?.secretJson);114 const fallbackSecret = toRecordValue(exemplar?.secretJson);
76 const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;115 const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;
77 if (Object.keys(finalSecret).length === 0) {116 if (Object.keys(finalSecret).length === 0) {
78 throw new Error("Credential values are required");117 throw new Error("Credential values are required");
79 }118 }
80 119
81 const overridesJson = args.overridesJson === undefined120 const overridesJson = args.overridesJson === undefined
82 ? asRecord(existing?.overridesJson)121 ? toRecordValue(existing?.overridesJson)
83 : asRecord(args.overridesJson);122 : toRecordValue(args.overridesJson);
84 123
85 const boundAuthFingerprint = await computeBoundAuthFingerprint(124 const boundAuthFingerprint = await computeBoundAuthFingerprint(
86 ctx,125 ctx,
@@ -100,6 +139,9 @@ export const upsertCredential = internalMutation({ · 13 unmodified lines
100 139
101 if (existing) {140 if (existing) {
102 await ctx.db.patch(existing._id, {141 await ctx.db.patch(existing._id, {
142 ownerScopeType,
143 organizationId,
144 workspaceId: ownerWorkspaceId,
103 credentialId: connectionId,145 credentialId: connectionId,
104 provider,146 provider,
105 secretJson: finalSecret,147 secretJson: finalSecret,
@@ -113,7 +155,9 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines
113 await ctx.db.insert("sourceCredentials", {155 await ctx.db.insert("sourceCredentials", {
114 bindingId: `bind_${crypto.randomUUID()}`,156 bindingId: `bind_${crypto.randomUUID()}`,
115 credentialId: connectionId,157 credentialId: connectionId,
116 workspaceId: args.workspaceId,158 ownerScopeType,
159 organizationId,
160 workspaceId: ownerWorkspaceId,
117 sourceKey: args.sourceKey,161 sourceKey: args.sourceKey,
118 scope: args.scope,162 scope: args.scope,
119 scopeKey,163 scopeKey,
@@ -127,15 +171,26 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines
127 });171 });
128 }172 }
129 173
130 const updated = await ctx.db174 const updated = ownerScopeType === "workspace"
131 .query("sourceCredentials")175 ? await ctx.db
132 .withIndex("by_workspace_source_scope_key", (q) =>176 .query("sourceCredentials")
133 q177 .withIndex("by_workspace_source_scope_key", (q) =>
134 .eq("workspaceId", args.workspaceId)178 q
135 .eq("sourceKey", args.sourceKey)179 .eq("workspaceId", args.workspaceId)
136 .eq("scopeKey", scopeKey),180 .eq("sourceKey", args.sourceKey)
137 )181 .eq("scopeKey", scopeKey),
138 .unique();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();
139 194
140 if (!updated) {195 if (!updated) {
141 throw new Error("Failed to read upserted credential");196 throw new Error("Failed to read upserted credential");
@@ -148,11 +203,30 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines
148export const listCredentials = internalQuery({203export const listCredentials = internalQuery({
149 args: { workspaceId: v.id("workspaces") },204 args: { workspaceId: v.id("workspaces") },
150 handler: async (ctx, args) => {205 handler: async (ctx, args) => {
151 const docs = await ctx.db206 const workspace = await ctx.db.get(args.workspaceId);
152 .query("sourceCredentials")207 if (!workspace) {
153 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))208 return [];
154 .order("desc")209 }
155 .collect();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
156 return docs.map(mapCredential);230 return docs.map(mapCredential);
157 },231 },
158});232});
@@ -181,35 +255,62 @@ export const resolveCredential = internalQuery({ · 22 unmodified lines
181 actorId: v.optional(v.string()),255 actorId: v.optional(v.string()),
182 },256 },
183 handler: async (ctx, args) => {257 handler: async (ctx, args) => {
184 if (args.scope === "actor") {258 const workspace = await ctx.db.get(args.workspaceId);
185 const actorId = args.actorId?.trim() || "";259 if (!workspace) {
186 if (!actorId) {260 return null;
187 return null;261 }
188 }262
263 const workspaceScopeKey = scopeKeyForCredential("workspace");
264 const actorId = args.actorId?.trim() || "";
265 const actorScopeKey = actorId ? scopeKeyForCredential("actor", actorId) : null;
189 266
190 const actorDoc = await ctx.db267 const tryWorkspaceOwned = async (scopeKey: string) => {
268 return await ctx.db
191 .query("sourceCredentials")269 .query("sourceCredentials")
192 .withIndex("by_workspace_source_scope_key", (q) =>270 .withIndex("by_workspace_source_scope_key", (q) =>
193 q271 q
194 .eq("workspaceId", args.workspaceId)272 .eq("workspaceId", args.workspaceId)
195 .eq("sourceKey", args.sourceKey)273 .eq("sourceKey", args.sourceKey)
196 .eq("scopeKey", scopeKeyForCredential("actor", actorId)),274 .eq("scopeKey", scopeKey),
197 )275 )
198 .unique();276 .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 }
199 308
200 return actorDoc ? mapCredential(actorDoc) : null;309 const organizationDoc = await tryOrganizationOwned(workspaceScopeKey);
310 if (organizationDoc) {
311 return mapCredential(organizationDoc as never);
201 }312 }
202 313
203 const workspaceDoc = await ctx.db314 return null;
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;
214 },315 },
215});316});
executor/packages/database/convex/database/policies.ts+75−13
@@ -2,7 +2,26 @@ import { v } from "convex/values"; · 1 unmodified line
2import { internalMutation, internalQuery } from "../_generated/server";2import { internalMutation, internalQuery } from "../_generated/server";
3import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog";3import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog";
4import { mapPolicy } from "../../src/database/mappers";4import { mapPolicy } from "../../src/database/mappers";
5import { policyDecisionValidator } from "../../src/database/validators";5import {
6 policyApprovalModeValidator,
7 policyDecisionValidator,
8 policyEffectValidator,
9 policyMatchTypeValidator,
10 policyScopeTypeValidator,
11} from "../../src/database/validators";
12
13function 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}
6 25
7export const listRuntimeTargets = internalQuery({26export const listRuntimeTargets = internalQuery({
8 args: {},27 args: {},
@@ -15,15 +34,34 @@ export const upsertAccessPolicy = internalMutation({ · 6 unmodified lines
15 args: {34 args: {
16 id: v.optional(v.string()),35 id: v.optional(v.string()),
17 workspaceId: v.id("workspaces"),36 workspaceId: v.id("workspaces"),
37 scopeType: v.optional(policyScopeTypeValidator),
18 actorId: v.optional(v.string()),38 actorId: v.optional(v.string()),
39 targetActorId: v.optional(v.string()),
19 clientId: v.optional(v.string()),40 clientId: v.optional(v.string()),
20 toolPathPattern: v.string(),41 toolPathPattern: v.optional(v.string()),
21 decision: policyDecisionValidator,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),
22 priority: v.optional(v.number()),47 priority: v.optional(v.number()),
23 },48 },
24 handler: async (ctx, args) => {49 handler: async (ctx, args) => {
25 const now = Date.now();50 const now = Date.now();
26 const policyId = args.id ?? `policy_${crypto.randomUUID()}`;51 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
27 const existing = await ctx.db65 const existing = await ctx.db
28 .query("accessPolicies")66 .query("accessPolicies")
29 .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))67 .withIndex("by_policy_id", (q) => q.eq("policyId", policyId))
@@ -31,22 +69,32 @@ export const upsertAccessPolicy = internalMutation({ · 1 unmodified line
31 69
32 if (existing) {70 if (existing) {
33 await ctx.db.patch(existing._id, {71 await ctx.db.patch(existing._id, {
34 workspaceId: args.workspaceId,72 scopeType,
35 actorId: args.actorId?.trim() || undefined,73 organizationId: workspace.organizationId,
74 workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
75 targetActorId,
36 clientId: args.clientId?.trim() || undefined,76 clientId: args.clientId?.trim() || undefined,
37 toolPathPattern: args.toolPathPattern,77 resourceType: "tool_path",
38 decision: args.decision,78 resourcePattern,
79 matchType,
80 effect,
81 approvalMode,
39 priority: args.priority ?? 100,82 priority: args.priority ?? 100,
40 updatedAt: now,83 updatedAt: now,
41 });84 });
42 } else {85 } else {
43 await ctx.db.insert("accessPolicies", {86 await ctx.db.insert("accessPolicies", {
44 policyId,87 policyId,
45 workspaceId: args.workspaceId,88 scopeType,
46 actorId: args.actorId?.trim() || undefined,89 organizationId: workspace.organizationId,
90 workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
91 targetActorId,
47 clientId: args.clientId?.trim() || undefined,92 clientId: args.clientId?.trim() || undefined,
48 toolPathPattern: args.toolPathPattern,93 resourceType: "tool_path",
49 decision: args.decision,94 resourcePattern,
95 matchType,
96 effect,
97 approvalMode,
50 priority: args.priority ?? 100,98 priority: args.priority ?? 100,
51 createdAt: now,99 createdAt: now,
52 updatedAt: now,100 updatedAt: now,
@@ -67,12 +115,26 @@ export const upsertAccessPolicy = internalMutation({ · 14 unmodified lines
67export const listAccessPolicies = internalQuery({115export const listAccessPolicies = internalQuery({
68 args: { workspaceId: v.id("workspaces") },116 args: { workspaceId: v.id("workspaces") },
69 handler: async (ctx, args) => {117 handler: async (ctx, args) => {
70 const docs = await ctx.db118 const workspace = await ctx.db.get(args.workspaceId);
119 if (!workspace) {
120 return [];
121 }
122
123 const workspaceDocs = await ctx.db
71 .query("accessPolicies")124 .query("accessPolicies")
72 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))125 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
73 .collect();126 .collect();
74 127
75 return docs128 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
76 .sort((a, b) => {138 .sort((a, b) => {
77 if (a.priority !== b.priority) {139 if (a.priority !== b.priority) {
78 return b.priority - a.priority;140 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
2import { internalMutation, internalQuery } from "../_generated/server";2import { internalMutation, internalQuery } from "../_generated/server";
3import { getTaskDoc, mapTaskEvent } from "../../src/database/readers";3import { getTaskDoc, mapTaskEvent } from "../../src/database/readers";
4import { jsonObjectValidator } from "../../src/database/validators";4import { jsonObjectValidator } from "../../src/database/validators";
5import { asRecord } from "../../src/lib/object";
6 5
7export const createTaskEvent = internalMutation({6export const createTaskEvent = internalMutation({
8 args: {7 args: {
@@ -30,7 +29,7 @@ export const createTaskEvent = internalMutation({ · 21 unmodified lines
30 taskId: args.taskId,29 taskId: args.taskId,
31 eventName: args.eventName,30 eventName: args.eventName,
32 type: args.type,31 type: args.type,
33 payload: asRecord(args.payload),32 payload: args.payload,
34 createdAt,33 createdAt,
35 });34 });
36 35
executor/packages/database/convex/database/tasks.ts+4−2
@@ -3,7 +3,6 @@ import { internalMutation, internalQuery } from "../_generated/server"; · 2 unmodified lines
3import { mapTask } from "../../src/database/mappers";3import { mapTask } from "../../src/database/mappers";
4import { getTaskDoc } from "../../src/database/readers";4import { getTaskDoc } from "../../src/database/readers";
5import { completedTaskStatusValidator, jsonObjectValidator } from "../../src/database/validators";5import { completedTaskStatusValidator, jsonObjectValidator } from "../../src/database/validators";
6import { asRecord } from "../../src/lib/object";
7import { DEFAULT_TASK_TIMEOUT_MS } from "../../src/task/constants";6import { DEFAULT_TASK_TIMEOUT_MS } from "../../src/task/constants";
8import { isTerminalTaskStatus } from "../../src/task/status";7import { isTerminalTaskStatus } from "../../src/task/status";
9 8
@@ -25,6 +24,9 @@ export const createTask = internalMutation({ · 15 unmodified lines
25 }24 }
26 25
27 const now = Date.now();26 const now = Date.now();
27 const metadata = args.metadata === undefined
28 ? {}
29 : args.metadata;
28 await ctx.db.insert("tasks", {30 await ctx.db.insert("tasks", {
29 taskId: args.id,31 taskId: args.id,
30 code: args.code,32 code: args.code,
@@ -34,7 +36,7 @@ export const createTask = internalMutation({ · 3 unmodified lines
34 clientId: args.clientId?.trim() || undefined,36 clientId: args.clientId?.trim() || undefined,
35 status: "queued",37 status: "queued",
36 timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS,38 timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS,
37 metadata: asRecord(args.metadata),39 metadata,
38 createdAt: now,40 createdAt: now,
39 updatedAt: now,41 updatedAt: now,
40 });42 });
executor/packages/database/convex/database/tool_sources.ts+64−16
@@ -6,12 +6,13 @@ import { · 5 unmodified lines
6 normalizeSourceAuthFingerprint,6 normalizeSourceAuthFingerprint,
7} from "../../src/database/mappers";7} from "../../src/database/mappers";
8import { normalizeToolSourceConfig } from "../../src/database/tool_source_config";8import { normalizeToolSourceConfig } from "../../src/database/tool_source_config";
9import { jsonObjectValidator, toolSourceTypeValidator } from "../../src/database/validators";9import { jsonObjectValidator, ownerScopeTypeValidator, toolSourceTypeValidator } from "../../src/database/validators";
10 10
11export const upsertToolSource = internalMutation({11export const upsertToolSource = internalMutation({
12 args: {12 args: {
13 id: v.optional(v.string()),13 id: v.optional(v.string()),
14 workspaceId: v.id("workspaces"),14 workspaceId: v.id("workspaces"),
15 ownerScopeType: v.optional(ownerScopeTypeValidator),
15 name: v.string(),16 name: v.string(),
16 type: toolSourceTypeValidator,17 type: toolSourceTypeValidator,
17 config: jsonObjectValidator,18 config: jsonObjectValidator,
@@ -20,6 +21,14 @@ export const upsertToolSource = internalMutation({ · 2 unmodified lines
20 handler: async (ctx, args) => {21 handler: async (ctx, args) => {
21 const now = Date.now();22 const now = Date.now();
22 const sourceId = args.id ?? `src_${crypto.randomUUID()}`;23 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
23 const configResult = normalizeToolSourceConfig(args.type, args.config);32 const configResult = normalizeToolSourceConfig(args.type, args.config);
24 if (configResult.isErr()) {33 if (configResult.isErr()) {
25 throw new Error(configResult.error.message);34 throw new Error(configResult.error.message);
@@ -32,19 +41,28 @@ export const upsertToolSource = internalMutation({ · 6 unmodified lines
32 .query("toolSources")41 .query("toolSources")
33 .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))42 .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
34 .unique(),43 .unique(),
35 ctx.db44 ownerScopeType === "workspace"
36 .query("toolSources")45 ? ctx.db
37 .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name))46 .query("toolSources")
38 .unique(),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(),
39 ]);55 ]);
40 56
41 if (conflict && conflict.sourceId !== sourceId) {57 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`);
43 }59 }
44 60
45 if (existing) {61 if (existing) {
46 await ctx.db.patch(existing._id, {62 await ctx.db.patch(existing._id, {
47 workspaceId: args.workspaceId,63 ownerScopeType,
64 organizationId,
65 workspaceId: ownerWorkspaceId,
48 name: args.name,66 name: args.name,
49 type: args.type,67 type: args.type,
50 config,68 config,
@@ -56,7 +74,9 @@ export const upsertToolSource = internalMutation({ · 5 unmodified lines
56 } else {74 } else {
57 await ctx.db.insert("toolSources", {75 await ctx.db.insert("toolSources", {
58 sourceId,76 sourceId,
59 workspaceId: args.workspaceId,77 ownerScopeType,
78 organizationId,
79 workspaceId: ownerWorkspaceId,
60 name: args.name,80 name: args.name,
61 type: args.type,81 type: args.type,
62 config,82 config,
@@ -82,11 +102,30 @@ export const upsertToolSource = internalMutation({ · 19 unmodified lines
82export const listToolSources = internalQuery({102export const listToolSources = internalQuery({
83 args: { workspaceId: v.id("workspaces") },103 args: { workspaceId: v.id("workspaces") },
84 handler: async (ctx, args) => {104 handler: async (ctx, args) => {
85 const docs = await ctx.db105 const workspace = await ctx.db.get(args.workspaceId);
86 .query("toolSources")106 if (!workspace) {
87 .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))107 return [];
88 .order("desc")108 }
89 .collect();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
90 return docs.map(mapSource);129 return docs.map(mapSource);
91 },130 },
92});131});
@@ -94,20 +133,29 @@ export const listToolSources = internalQuery({ · 1 unmodified line
94export const deleteToolSource = internalMutation({133export const deleteToolSource = internalMutation({
95 args: { workspaceId: v.id("workspaces"), sourceId: v.string() },134 args: { workspaceId: v.id("workspaces"), sourceId: v.string() },
96 handler: async (ctx, args) => {135 handler: async (ctx, args) => {
136 const workspace = await ctx.db.get(args.workspaceId);
137 if (!workspace) {
138 return false;
139 }
140
97 const doc = await ctx.db141 const doc = await ctx.db
98 .query("toolSources")142 .query("toolSources")
99 .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId))143 .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId))
100 .unique();144 .unique();
101 145
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) {
103 return false;151 return false;
104 }152 }
105 153
106 const sourceKey = `source:${args.sourceId}`;154 const sourceKey = `source:${args.sourceId}`;
107 const bindings = await ctx.db155 const bindings = await ctx.db
108 .query("sourceCredentials")156 .query("sourceCredentials")
109 .withIndex("by_workspace_source_scope_key", (q) =>157 .withIndex("by_organization_source", (q) =>
110 q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey),158 q.eq("organizationId", workspace.organizationId).eq("sourceKey", sourceKey),
111 )159 )
112 .collect();160 .collect();
113 161
executor/packages/database/convex/migrations.ts+4−2
@@ -127,9 +127,11 @@ export const cleanupTaskEmptyStringSentinels = migrations.define({ · 126 unmodified lines
127export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({127export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({
128 table: "accessPolicies",128 table: "accessPolicies",
129 migrateOne: async (_ctx, policy) => {129 migrateOne: async (_ctx, policy) => {
130 const policyRecord = policy as Record<string, unknown>;
130 const patch: Record<string, undefined> = {};131 const patch: Record<string, undefined> = {};
131 if (policy.actorId === "") patch.actorId = undefined;132 if (Reflect.get(policyRecord, "actorId") === "") patch["actorId"] = undefined;
132 if (policy.clientId === "") patch.clientId = undefined;133 if (Reflect.get(policyRecord, "targetActorId") === "") patch["targetActorId"] = undefined;
134 if (policy.clientId === "") patch["clientId"] = undefined;
133 if (Object.keys(patch).length > 0) return patch;135 if (Object.keys(patch).length > 0) return patch;
134 },136 },
135});137});
executor/packages/database/convex/schema.ts+46−18
@@ -56,7 +56,12 @@ const toolCallStatus = v.union( · 55 unmodified lines
56 v.literal("denied"),56 v.literal("denied"),
57);57);
58const toolApprovalMode = v.union(v.literal("auto"), v.literal("required"));58const toolApprovalMode = v.union(v.literal("auto"), v.literal("required"));
59const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));59const policyScopeType = v.union(v.literal("organization"), v.literal("workspace"));
60const policyResourceType = v.literal("tool_path");
61const policyMatchType = v.union(v.literal("glob"), v.literal("exact"));
62const policyEffect = v.union(v.literal("allow"), v.literal("deny"));
63const policyApprovalMode = v.union(v.literal("inherit"), v.literal("auto"), v.literal("required"));
64const ownerScopeType = v.union(v.literal("organization"), v.literal("workspace"));
60const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));65const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
61const credentialProvider = v.union(66const credentialProvider = v.union(
62 v.literal("local-convex"),67 v.literal("local-convex"),
@@ -319,36 +324,46 @@ export default defineSchema({ · 256 unmodified lines
319 })324 })
320 .index("by_task_sequence", ["taskId", "sequence"]),325 .index("by_task_sequence", ["taskId", "sequence"]),
321 326
322 // Workspace access policy rules used by the approval / tool firewall.327 // Workspace + organization access policy rules used by the approval / tool firewall.
323 // `toolPathPattern` is matched against tool paths and combined with actor/client selectors.328 // Rules can target specific actors and clients, and can match tool paths either exactly
324 // Higher `priority` wins when multiple policies match.329 // or with a glob pattern.
325 accessPolicies: defineTable({330 accessPolicies: defineTable({
326 policyId: v.string(), // domain ID: policy_<uuid>331 policyId: v.string(), // domain ID: policy_<uuid>
327 workspaceId: v.id("workspaces"),332 scopeType: policyScopeType,
328 actorId: v.optional(v.string()), // account._id or anon_<uuid>333 organizationId: v.id("organizations"),
334 workspaceId: v.optional(v.id("workspaces")),
335 targetActorId: v.optional(v.string()), // account._id or anon_<uuid>
329 clientId: v.optional(v.string()), // client label: "web", "mcp", etc.336 clientId: v.optional(v.string()), // client label: "web", "mcp", etc.
330 toolPathPattern: v.string(),337 resourceType: policyResourceType,
331 decision: policyDecision,338 resourcePattern: v.string(),
339 matchType: policyMatchType,
340 effect: policyEffect,
341 approvalMode: policyApprovalMode,
332 priority: v.number(),342 priority: v.number(),
333 createdAt: v.number(),343 createdAt: v.number(),
334 updatedAt: v.number(),344 updatedAt: v.number(),
335 })345 })
336 .index("by_policy_id", ["policyId"])346 .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"]),
338 349
339 // Stored credentials for tool sources.350 // Stored credentials for tool sources.
340 //351 //
341 // A single credential "connection" (credentialId) can have multiple rows to support352 // A single credential "connection" (credentialId) can have multiple rows to support
342 // different bindings (workspace-wide and per-actor). `bindingId` exists as a stable handle353 // 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
343 // for UI/API operations that need an id before the connection id is known.356 // for UI/API operations that need an id before the connection id is known.
344 //357 //
345 // Primary access patterns:358 // Primary access patterns:
346 // - Resolve by (workspaceId, sourceKey, scopeKey).359 // - Resolve by (workspaceId|organizationId, sourceKey, scopeKey).
347 // - List all credentials in workspace by createdAt.360 // - List effective credentials for a workspace by createdAt.
348 sourceCredentials: defineTable({361 sourceCredentials: defineTable({
349 bindingId: v.string(), // domain ID: bind_<uuid>362 bindingId: v.string(), // domain ID: bind_<uuid>
350 credentialId: v.string(), // domain ID: conn_<uuid>363 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")),
352 sourceKey: v.string(),367 sourceKey: v.string(),
353 scope: credentialScope,368 scope: credentialScope,
354 scopeKey: v.string(), // "workspace" or actor:<actorId>369 scopeKey: v.string(), // "workspace" or actor:<actorId>
@@ -361,21 +376,30 @@ export default defineSchema({ · 6 unmodified lines
361 updatedAt: v.number(),376 updatedAt: v.number(),
362 })377 })
363 .index("by_workspace_created", ["workspaceId", "createdAt"])378 .index("by_workspace_created", ["workspaceId", "createdAt"])
379 .index("by_organization_created", ["organizationId", "createdAt"])
380 .index("by_organization_owner_created", ["organizationId", "ownerScopeType", "createdAt"])
364 .index("by_workspace_source_scope_key", ["workspaceId", "sourceKey", "scopeKey"])381 .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"])
365 .index("by_workspace_credential", ["workspaceId", "credentialId"])384 .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"])
366 .index("by_binding_id", ["bindingId"]),388 .index("by_binding_id", ["bindingId"]),
367 389
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).
369 // `specHash` enables cache invalidation when the definition changes.391 // `specHash` enables cache invalidation when the definition changes.
370 // `authFingerprint` is used to determine whether cached tool materialization is still valid.392 // `authFingerprint` is used to determine whether cached tool materialization is still valid.
371 //393 //
372 // Primary access patterns:394 // Primary access patterns:
373 // - Resolve by domain source id.395 // - Resolve by domain source id.
374 // - List sources by workspace, sorted by updatedAt.396 // - List effective sources by workspace, sorted by updatedAt.
375 // - Enforce name uniqueness per workspace.397 // - Enforce name uniqueness per owner scope.
376 toolSources: defineTable({398 toolSources: defineTable({
377 sourceId: v.string(), // domain ID: src_<uuid>399 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")),
379 name: v.string(),403 name: v.string(),
380 type: toolSourceType,404 type: toolSourceType,
381 config: jsonObject,405 config: jsonObject,
@@ -387,7 +411,11 @@ export default defineSchema({ · 5 unmodified lines
387 })411 })
388 .index("by_source_id", ["sourceId"])412 .index("by_source_id", ["sourceId"])
389 .index("by_workspace_updated", ["workspaceId", "updatedAt"])413 .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"]),
391 419
392 // Cached OpenAPI spec blobs stored in Convex storage.420 // Cached OpenAPI spec blobs stored in Convex storage.
393 // (specUrl, version) uniquely identifies a stored spec payload.421 // (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
176 },176 },
177});177});
178 178
179export 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
179export const getToolByPath = internalQuery({241export const getToolByPath = internalQuery({
180 args: {242 args: {
181 workspaceId: v.id("workspaces"),243 workspaceId: v.id("workspaces"),
executor/packages/database/convex/workspace.ts+51−1
@@ -2,11 +2,17 @@ import { v } from "convex/values"; · 1 unmodified line
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { mutation } from "./_generated/server";3import { mutation } from "./_generated/server";
4import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders";4import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders";
5import { getOrganizationMembership, isAdminRole } from "../../core/src/identity";
5import {6import {
6 credentialProviderValidator,7 credentialProviderValidator,
7 credentialScopeValidator,8 credentialScopeValidator,
8 jsonObjectValidator,9 jsonObjectValidator,
10 ownerScopeTypeValidator,
11 policyApprovalModeValidator,
9 policyDecisionValidator,12 policyDecisionValidator,
13 policyEffectValidator,
14 policyMatchTypeValidator,
15 policyScopeTypeValidator,
10 toolSourceTypeValidator,16 toolSourceTypeValidator,
11} from "../src/database/validators";17} from "../src/database/validators";
12 18
@@ -43,13 +49,24 @@ export const upsertAccessPolicy = workspaceMutation({ · 30 unmodified lines
43 requireAdmin: true,49 requireAdmin: true,
44 args: {50 args: {
45 id: v.optional(v.string()),51 id: v.optional(v.string()),
52 scopeType: v.optional(policyScopeTypeValidator),
46 actorId: v.optional(v.string()),53 actorId: v.optional(v.string()),
47 clientId: v.optional(v.string()),54 clientId: v.optional(v.string()),
48 toolPathPattern: v.string(),55 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),
50 priority: v.optional(v.number()),60 priority: v.optional(v.number()),
51 },61 },
52 handler: async (ctx, args) => {62 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
53 return await ctx.runMutation(internal.database.upsertAccessPolicy, {70 return await ctx.runMutation(internal.database.upsertAccessPolicy, {
54 ...args,71 ...args,
55 workspaceId: ctx.workspaceId,72 workspaceId: ctx.workspaceId,
@@ -70,6 +87,7 @@ export const upsertCredential = workspaceMutation({ · 14 unmodified lines
70 requireAdmin: true,87 requireAdmin: true,
71 args: {88 args: {
72 id: v.optional(v.string()),89 id: v.optional(v.string()),
90 ownerScopeType: v.optional(ownerScopeTypeValidator),
73 sourceKey: v.string(),91 sourceKey: v.string(),
74 scope: credentialScopeValidator,92 scope: credentialScopeValidator,
75 actorId: v.optional(v.string()),93 actorId: v.optional(v.string()),
@@ -78,6 +96,13 @@ export const upsertCredential = workspaceMutation({ · 2 unmodified lines
78 overridesJson: v.optional(jsonObjectValidator),96 overridesJson: v.optional(jsonObjectValidator),
79 },97 },
80 handler: async (ctx, args) => {98 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
81 return await ctx.runMutation(internal.database.upsertCredential, {106 return await ctx.runMutation(internal.database.upsertCredential, {
82 ...args,107 ...args,
83 workspaceId: ctx.workspaceId,108 workspaceId: ctx.workspaceId,
@@ -122,12 +147,20 @@ export const upsertToolSource = workspaceMutation({ · 38 unmodified lines
122 requireAdmin: true,147 requireAdmin: true,
123 args: {148 args: {
124 id: v.optional(v.string()),149 id: v.optional(v.string()),
150 ownerScopeType: v.optional(ownerScopeTypeValidator),
125 name: v.string(),151 name: v.string(),
126 type: toolSourceTypeValidator,152 type: toolSourceTypeValidator,
127 config: jsonObjectValidator,153 config: jsonObjectValidator,
128 enabled: v.optional(v.boolean()),154 enabled: v.optional(v.boolean()),
129 },155 },
130 handler: async (ctx, args) => {156 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
131 return await ctx.runMutation(internal.database.upsertToolSource, {164 return await ctx.runMutation(internal.database.upsertToolSource, {
132 ...args,165 ...args,
133 workspaceId: ctx.workspaceId,166 workspaceId: ctx.workspaceId,
@@ -150,6 +183,23 @@ export const deleteToolSource = workspaceMutation({ · 16 unmodified lines
150 sourceId: v.string(),183 sourceId: v.string(),
151 },184 },
152 handler: async (ctx, args) => {185 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
153 return await ctx.runMutation(internal.database.deleteToolSource, {203 return await ctx.runMutation(internal.database.deleteToolSource, {
154 ...args,204 ...args,
155 workspaceId: ctx.workspaceId,205 workspaceId: ctx.workspaceId,
executor/packages/database/src/accounts/delete-current-account.ts+24−0
@@ -135,6 +135,30 @@ async function deleteOrganizationData( · 134 unmodified lines
135 await ctx.db.delete(seatState._id);135 await ctx.db.delete(seatState._id);
136 }136 }
137 137
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
138 const workspaces = await ctx.db162 const workspaces = await ctx.db
139 .query("workspaces")163 .query("workspaces")
140 .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))164 .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId))
executor/packages/database/src/auth/identity.ts+7−3
@@ -1,4 +1,5 @@
1import { components } from "../../convex/_generated/api";1import { components } from "../../convex/_generated/api";
2import { z } from "zod";
2import type { RunQueryCtx } from "./types";3import type { RunQueryCtx } from "./types";
3 4
4type WorkosProfile = {5type WorkosProfile = {
@@ -8,17 +9,20 @@ type WorkosProfile = { · 3 unmodified lines
8 profilePictureUrl?: string | null;9 profilePictureUrl?: string | null;
9};10};
10 11
12const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0);
13
11function deriveFallbackUserLabel(workosUserId: string): string {14function deriveFallbackUserLabel(workosUserId: string): string {
12 return `User ${workosUserId.slice(-6)}`;15 return `User ${workosUserId.slice(-6)}`;
13}16}
14 17
15function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {18function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {
16 for (const key of keys) {19 for (const key of keys) {
17 const value = identity[key];20 const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]);
18 if (typeof value === "string" && value.trim().length > 0) {21 if (parsedValue.success) {
19 return value.trim();22 return parsedValue.data;
20 }23 }
21 }24 }
25
22 return undefined;26 return undefined;
23}27}
24 28
executor/packages/database/src/credentials-node/upsert-credential.ts+100−85
@@ -1,16 +1,43 @@
1import { WorkOS } from "@workos-inc/node";1import { Result } from "better-result";
2import { z } from "zod";
2import type { Id } from "../../convex/_generated/dataModel.d.ts";3import type { Id } from "../../convex/_generated/dataModel.d.ts";
3import type { ActionCtx } from "../../convex/_generated/server";4import type { ActionCtx } from "../../convex/_generated/server";
5import {
6 createWorkosClient,
7 extractWorkosVaultObjectId,
8 withWorkosVaultRetryResult,
9} from "../../../core/src/credentials/workos-vault";
4import {10import {
5 assertMatchesCanonicalActorId,11 assertMatchesCanonicalActorId,
6 canonicalActorIdForWorkspaceAccess,12 canonicalActorIdForWorkspaceAccess,
7} from "../auth/actor_identity";13} from "../auth/actor_identity";
8import { asRecord } from "../lib/object";
9 14
10type Internal = typeof import("../../convex/_generated/api").internal;15type Internal = typeof import("../../convex/_generated/api").internal;
11 16
12type SecretBackend = "local-convex" | "workos-vault";17type SecretBackend = "local-convex" | "workos-vault";
13 18
19const recordSchema = z.record(z.unknown());
20
21const secretPayloadSchema = z.object({
22 __headers: z.record(z.unknown()).optional(),
23}).catchall(z.unknown());
24
25const 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
34type ListedCredential = z.infer<typeof listedCredentialSchema>;
35
36function toRecordValue(value: unknown): Record<string, unknown> {
37 const parsed = recordSchema.safeParse(value);
38 return parsed.success ? parsed.data : {};
39}
40
14function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {41function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {
15 if (scope !== "actor") return "";42 if (scope !== "actor") return "";
16 if (typeof actorId !== "string") return "";43 if (typeof actorId !== "string") return "";
@@ -28,34 +55,40 @@ function configuredSecretBackend(): SecretBackend { · 11 unmodified lines
28 return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex";55 return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex";
29}56}
30 57
31function 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
39function extractHeaderOverrides(secretJson: Record<string, unknown>): {58function extractHeaderOverrides(secretJson: Record<string, unknown>): {
40 cleanSecret: Record<string, unknown>;59 cleanSecret: Record<string, unknown>;
41 overridesJson: Record<string, unknown>;60 overridesJson: Record<string, unknown>;
42} {61} {
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);
44 const headers: Record<string, string> = {};65 const headers: Record<string, string> = {};
45 for (const [name, value] of Object.entries(rawHeaders)) {66 for (const [name, value] of Object.entries(rawHeaders)) {
46 if (!name) continue;67 const headerName = name.trim();
68 if (!headerName) continue;
47 const text = typeof value === "string" ? value.trim() : String(value ?? "").trim();69 const text = typeof value === "string" ? value.trim() : String(value ?? "").trim();
48 if (!text) continue;70 if (!text) continue;
49 headers[name.trim()] = text;71 headers[headerName] = text;
50 }72 }
51 73
52 const { __headers: _headers, ...rest } = secretJson;74 const { __headers: _headers, ...rest } = normalizedSecret;
53 return {75 return {
54 cleanSecret: asRecord(rest),76 cleanSecret: toRecordValue(rest),
55 overridesJson: Object.keys(headers).length > 0 ? { headers } : {},77 overridesJson: Object.keys(headers).length > 0 ? { headers } : {},
56 };78 };
57}79}
58 80
81function 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
59function buildVaultObjectName(args: {92function buildVaultObjectName(args: {
60 workspaceId: string;93 workspaceId: string;
61 sourceKey: string;94 sourceKey: string;
@@ -71,56 +104,6 @@ function buildVaultObjectName(args: { · 9 unmodified lines
71 return `executor-conn-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`;104 return `executor-conn-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`;
72}105}
73 106
74function 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
82function 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
95async function sleep(ms: number): Promise<void> {
96 await new Promise((resolve) => setTimeout(resolve, ms));
97}
98
99async 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
124async function upsertVaultObject(args: {107async function upsertVaultObject(args: {
125 workspaceId: string;108 workspaceId: string;
126 sourceKey: string;109 sourceKey: string;
@@ -128,22 +111,36 @@ async function upsertVaultObject(args: { · 1 unmodified line
128 actorId: string;111 actorId: string;
129 existingObjectId: string | null;112 existingObjectId: string | null;
130 payload: Record<string, unknown>;113 payload: Record<string, unknown>;
131}): Promise<string> {114}): Promise<Result<string, Error>> {
132 const workos = workosClient();115 const workosResult = createWorkosClient();
116 if (workosResult.isErr()) {
117 return Result.err(workosResult.error);
118 }
119
120 const workos = workosResult.value;
133 const value = JSON.stringify(args.payload);121 const value = JSON.stringify(args.payload);
134 122
135 if (args.existingObjectId) {123 if (args.existingObjectId) {
136 const objectId: string = args.existingObjectId;124 const objectId: string = args.existingObjectId;
137 const updated = await withVaultRetry(async () => {125 const updatedResult = await withWorkosVaultRetryResult(async () => {
138 return await workos.vault.updateObject({126 return await workos.vault.updateObject({
139 id: objectId,127 id: objectId,
140 value,128 value,
141 });129 });
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.",
142 });135 });
143 return updated.id;136 if (updatedResult.isErr()) {
137 return Result.err(updatedResult.error);
138 }
139
140 return Result.ok(updatedResult.value.id);
144 }141 }
145 142
146 const created = await withVaultRetry(async () => {143 const createdResult = await withWorkosVaultRetryResult(async () => {
147 return await workos.vault.createObject({144 return await workos.vault.createObject({
148 name: buildVaultObjectName(args),145 name: buildVaultObjectName(args),
149 value,146 value,
@@ -151,9 +148,17 @@ async function upsertVaultObject(args: { · 1 unmodified line
151 workspace_id: args.workspaceId,148 workspace_id: args.workspaceId,
152 },149 },
153 });150 });
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.",
154 });156 });
157 if (createdResult.isErr()) {
158 return Result.err(createdResult.error);
159 }
155 160
156 return created.id;161 return Result.ok(createdResult.value.id);
157}162}
158 163
159export async function upsertCredentialHandler(164export async function upsertCredentialHandler(
@@ -163,6 +168,7 @@ export async function upsertCredentialHandler( · 3 unmodified lines
163 id?: string;168 id?: string;
164 workspaceId: Id<"workspaces">;169 workspaceId: Id<"workspaces">;
165 sessionId?: string;170 sessionId?: string;
171 ownerScopeType?: "organization" | "workspace";
166 sourceKey: string;172 sourceKey: string;
167 scope: "workspace" | "actor";173 scope: "workspace" | "actor";
168 actorId?: string;174 actorId?: string;
@@ -180,7 +186,8 @@ export async function upsertCredentialHandler( · 11 unmodified lines
180 }186 }
181 187
182 const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId);188 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);
184 const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret);191 const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret);
185 192
186 const existingBinding = await ctx.runQuery(internal.database.resolveCredential, {193 const existingBinding = await ctx.runQuery(internal.database.resolveCredential, {
@@ -190,34 +197,36 @@ export async function upsertCredentialHandler( · 3 unmodified lines
190 ...(args.scope === "actor" ? { actorId } : {}),197 ...(args.scope === "actor" ? { actorId } : {}),
191 });198 });
192 199
193 const allCredentials: Array<Record<string, unknown>> = await ctx.runQuery(internal.database.listCredentials, {200 const allCredentialsRaw = await ctx.runQuery(internal.database.listCredentials, {
194 workspaceId: args.workspaceId,201 workspaceId: args.workspaceId,
195 });202 });
203 const allCredentials = parseListedCredentials(allCredentialsRaw);
196 const requestedId = args.id?.trim();204 const requestedId = args.id?.trim();
197 const existingConnection = requestedId205 const existingConnection = requestedId
198 ? allCredentials.find((credential) => {206 ? allCredentials.find((credential) => {
199 const id = String(credential.id ?? "");207 const id = (credential.id ?? "").trim();
200 const bindingId = String(credential.bindingId ?? "");208 const bindingId = (credential.bindingId ?? "").trim();
201 if (id !== requestedId && bindingId !== requestedId) return false;209 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;
203 if (args.scope === "actor") {212 if (args.scope === "actor") {
204 return String(credential.actorId ?? "") === actorId;213 return (credential.actorId ?? "").trim() === actorId;
205 }214 }
206 return true;215 return true;
207 }) ?? allCredentials.find((credential) => {216 }) ?? allCredentials.find((credential) => {
208 const id = String(credential.id ?? "");217 const id = (credential.id ?? "").trim();
209 const bindingId = String(credential.bindingId ?? "");218 const bindingId = (credential.bindingId ?? "").trim();
210 return id === requestedId || bindingId === requestedId;219 return id === requestedId || bindingId === requestedId;
211 })220 })
212 : null;221 : null;
213 const connectionId = String(existingConnection?.id ?? requestedId ?? "").trim() || undefined;222 const connectionId = (existingConnection?.id ?? requestedId ?? "").trim() || undefined;
214 223
215 const backend = configuredSecretBackend();224 const backend = configuredSecretBackend();
216 225
217 if (backend === "local-convex") {226 if (backend === "local-convex") {
218 const finalSecret = Object.keys(submittedSecret).length > 0227 const finalSecret = Object.keys(submittedSecret).length > 0
219 ? submittedSecret228 ? submittedSecret
220 : asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson);229 : toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson);
221 if (Object.keys(finalSecret).length === 0) {230 if (Object.keys(finalSecret).length === 0) {
222 throw new Error("Credential values are required");231 throw new Error("Credential values are required");
223 }232 }
@@ -225,6 +234,7 @@ export async function upsertCredentialHandler( · 1 unmodified line
225 return await ctx.runMutation(internal.database.upsertCredential, {234 return await ctx.runMutation(internal.database.upsertCredential, {
226 id: connectionId,235 id: connectionId,
227 workspaceId: args.workspaceId,236 workspaceId: args.workspaceId,
237 ownerScopeType,
228 sourceKey: args.sourceKey,238 sourceKey: args.sourceKey,
229 scope: args.scope,239 scope: args.scope,
230 ...(args.scope === "actor" ? { actorId } : {}),240 ...(args.scope === "actor" ? { actorId } : {}),
@@ -234,18 +244,18 @@ export async function upsertCredentialHandler( · 3 unmodified lines
234 });244 });
235 }245 }
236 246
237 const submittedObjectId = extractObjectId(submittedSecret);247 const submittedObjectId = extractWorkosVaultObjectId(submittedSecret);
238 if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) {248 if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) {
239 throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field.");249 throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field.");
240 }250 }
241 251
242 const existingObjectId = extractObjectId(252 const existingObjectId = extractWorkosVaultObjectId(
243 asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson),253 toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson),
244 );254 );
245 255
246 let finalObjectId = submittedObjectId;256 let finalObjectId = submittedObjectId;
247 if (!finalObjectId && Object.keys(submittedSecret).length > 0) {257 if (!finalObjectId && Object.keys(submittedSecret).length > 0) {
248 finalObjectId = await upsertVaultObject({258 const upsertResult = await upsertVaultObject({
249 workspaceId: args.workspaceId,259 workspaceId: args.workspaceId,
250 sourceKey: args.sourceKey,260 sourceKey: args.sourceKey,
251 scope: args.scope,261 scope: args.scope,
@@ -253,6 +263,10 @@ export async function upsertCredentialHandler( · 1 unmodified line
253 existingObjectId,263 existingObjectId,
254 payload: submittedSecret,264 payload: submittedSecret,
255 });265 });
266 if (upsertResult.isErr()) {
267 throw upsertResult.error;
268 }
269 finalObjectId = upsertResult.value;
256 }270 }
257 271
258 if (!finalObjectId && existingObjectId) {272 if (!finalObjectId && existingObjectId) {
@@ -266,6 +280,7 @@ export async function upsertCredentialHandler( · 7 unmodified lines
266 return await ctx.runMutation(internal.database.upsertCredential, {280 return await ctx.runMutation(internal.database.upsertCredential, {
267 id: connectionId,281 id: connectionId,
268 workspaceId: args.workspaceId,282 workspaceId: args.workspaceId,
283 ownerScopeType,
269 sourceKey: args.sourceKey,284 sourceKey: args.sourceKey,
270 scope: args.scope,285 scope: args.scope,
271 ...(args.scope === "actor" ? { actorId } : {}),286 ...(args.scope === "actor" ? { actorId } : {}),
executor/packages/database/src/database/mappers.ts+59−13
@@ -1,13 +1,20 @@
1import type { Doc } from "../../convex/_generated/dataModel.d.ts";1import type { Doc } from "../../convex/_generated/dataModel.d.ts";
2import { asRecord } from "../lib/object";2import { z } from "zod";
3import { isRecord } from "../lib/object";
3import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";4import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
4 5
6const sourceAuthSchema = z.object({
7 type: z.string().optional(),
8 mode: z.enum(["workspace", "actor"]).optional(),
9 header: z.string().optional(),
10});
11
5function stableStringify(value: unknown): string {12function stableStringify(value: unknown): string {
6 if (Array.isArray(value)) {13 if (Array.isArray(value)) {
7 return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;14 return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
8 }15 }
9 if (value && typeof value === "object") {16 if (isRecord(value)) {
10 const record = asRecord(value);17 const record = value;
11 const keys = Object.keys(record).sort();18 const keys = Object.keys(record).sort();
12 return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;19 return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
13 }20 }
@@ -15,10 +22,11 @@ function stableStringify(value: unknown): string { · 1 unmodified line
15}22}
16 23
17export function normalizeSourceAuthFingerprint(value: unknown): string {24export function normalizeSourceAuthFingerprint(value: unknown): string {
18 const auth = asRecord(value);25 const parsedAuth = sourceAuthSchema.safeParse(value);
19 const type = typeof auth.type === "string" ? auth.type.trim() : "none";26 const auth = parsedAuth.success ? parsedAuth.data : {};
27 const type = (auth.type ?? "none").trim();
20 const mode = auth.mode === "actor" ? "actor" : "workspace";28 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();
22 return stableStringify({30 return stableStringify({
23 type: type || "none",31 type: type || "none",
24 mode,32 mode,
@@ -43,13 +51,14 @@ export function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", confi · 18 unmodified lines
43}51}
44 52
45export function mapTask(doc: Doc<"tasks">) {53export function mapTask(doc: Doc<"tasks">) {
54 const metadata = isRecord(doc.metadata) ? doc.metadata : {};
46 return {55 return {
47 id: doc.taskId,56 id: doc.taskId,
48 code: doc.code,57 code: doc.code,
49 runtimeId: doc.runtimeId,58 runtimeId: doc.runtimeId,
50 status: doc.status,59 status: doc.status,
51 timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS,60 timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS,
52 metadata: asRecord(doc.metadata),61 metadata,
53 workspaceId: doc.workspaceId,62 workspaceId: doc.workspaceId,
54 actorId: doc.actorId,63 actorId: doc.actorId,
55 clientId: doc.clientId,64 clientId: doc.clientId,
@@ -92,30 +101,64 @@ export function mapToolCall(doc: Doc<"toolCalls">) { · 36 unmodified lines
92}101}
93 102
94export function mapPolicy(doc: Doc<"accessPolicies">) {103export 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
95 return {125 return {
96 id: doc.policyId,126 id: doc.policyId,
127 scopeType,
128 organizationId: doc.organizationId,
97 workspaceId: doc.workspaceId,129 workspaceId: doc.workspaceId,
98 actorId: doc.actorId,130 targetActorId,
99 clientId: doc.clientId,131 clientId: doc.clientId,
100 toolPathPattern: doc.toolPathPattern,132 resourceType: doc.resourceType ?? "tool_path",
101 decision: doc.decision,133 resourcePattern,
134 matchType: doc.matchType ?? "glob",
135 effect,
136 approvalMode,
102 priority: doc.priority,137 priority: doc.priority,
103 createdAt: doc.createdAt,138 createdAt: doc.createdAt,
104 updatedAt: doc.updatedAt,139 updatedAt: doc.updatedAt,
140 // Legacy aliases retained during transition.
141 actorId: targetActorId,
142 toolPathPattern: resourcePattern,
143 decision,
105 };144 };
106}145}
107 146
108export function mapCredential(doc: Doc<"sourceCredentials">) {147export function mapCredential(doc: Doc<"sourceCredentials">) {
148 const secretJson = isRecord(doc.secretJson) ? doc.secretJson : {};
149 const overridesJson = isRecord(doc.overridesJson) ? doc.overridesJson : {};
109 return {150 return {
110 id: doc.credentialId,151 id: doc.credentialId,
111 bindingId: doc.bindingId,152 bindingId: doc.bindingId,
153 ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"),
154 organizationId: doc.organizationId,
112 workspaceId: doc.workspaceId,155 workspaceId: doc.workspaceId,
113 sourceKey: doc.sourceKey,156 sourceKey: doc.sourceKey,
114 scope: doc.scope,157 scope: doc.scope,
115 actorId: doc.actorId || undefined,158 actorId: doc.actorId || undefined,
116 provider: doc.provider,159 provider: doc.provider,
117 secretJson: asRecord(doc.secretJson),160 secretJson,
118 overridesJson: asRecord(doc.overridesJson),161 overridesJson,
119 boundAuthFingerprint: doc.boundAuthFingerprint,162 boundAuthFingerprint: doc.boundAuthFingerprint,
120 createdAt: doc.createdAt,163 createdAt: doc.createdAt,
121 updatedAt: doc.updatedAt,164 updatedAt: doc.updatedAt,
@@ -123,12 +166,15 @@ export function mapCredential(doc: Doc<"sourceCredentials">) { · 1 unmodified line
123}166}
124 167
125export function mapSource(doc: Doc<"toolSources">) {168export function mapSource(doc: Doc<"toolSources">) {
169 const config = isRecord(doc.config) ? doc.config : {};
126 return {170 return {
127 id: doc.sourceId,171 id: doc.sourceId,
172 ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"),
173 organizationId: doc.organizationId,
128 workspaceId: doc.workspaceId,174 workspaceId: doc.workspaceId,
129 name: doc.name,175 name: doc.name,
130 type: doc.type,176 type: doc.type,
131 config: asRecord(doc.config),177 config,
132 specHash: doc.specHash,178 specHash: doc.specHash,
133 authFingerprint: doc.authFingerprint,179 authFingerprint: doc.authFingerprint,
134 enabled: Boolean(doc.enabled),180 enabled: Boolean(doc.enabled),
executor/packages/database/src/database/readers.ts+18−3
@@ -1,7 +1,11 @@
1import type { Doc } from "../../convex/_generated/dataModel.d.ts";1import type { Doc } from "../../convex/_generated/dataModel.d.ts";
2import type { QueryCtx } from "../../convex/_generated/server";2import type { QueryCtx } from "../../convex/_generated/server";
3import { z } from "zod";
3import { normalizeSourceAuthFingerprint } from "./mappers";4import { normalizeSourceAuthFingerprint } from "./mappers";
4import { asRecord } from "../lib/object";5
6const toolSourceConfigSchema = z.object({
7 auth: z.unknown().optional(),
8});
5 9
6export function mapAnonymousContext(doc: Doc<"anonymousSessions">) {10export function mapAnonymousContext(doc: Doc<"anonymousSessions">) {
7 return {11 return {
@@ -69,9 +73,20 @@ export async function computeBoundAuthFingerprint( · 61 unmodified lines
69 .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))73 .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
70 .unique();74 .unique();
71 75
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) {
73 return normalizeSourceAuthFingerprint({ type: "none" });87 return normalizeSourceAuthFingerprint({ type: "none" });
74 }88 }
75 89
76 return normalizeSourceAuthFingerprint(asRecord(source.config).auth);90 const parsedConfig = toolSourceConfigSchema.safeParse(source.config);
91 return normalizeSourceAuthFingerprint(parsedConfig.success ? parsedConfig.data.auth : undefined);
77}92}
executor/packages/database/src/database/tool_source_config.ts+20−14
@@ -7,7 +7,6 @@ import type { · 6 unmodified lines
7 OpenApiToolSourceConfig,7 OpenApiToolSourceConfig,
8} from "../../../core/src/tool/source-types";8} from "../../../core/src/tool/source-types";
9import type { ToolApprovalMode } from "../../../core/src/types";9import type { ToolApprovalMode } from "../../../core/src/types";
10import { asRecord } from "../lib/object";
11 10
12export type ToolSourceType = "mcp" | "openapi" | "graphql";11export type ToolSourceType = "mcp" | "openapi" | "graphql";
13 12
@@ -39,6 +38,8 @@ const overrideEntrySchema = z.object({ · 25 unmodified lines
39});38});
40const unknownRecordSchema = z.record(z.unknown());39const unknownRecordSchema = z.record(z.unknown());
41const stringMapSchema = z.record(z.string());40const stringMapSchema = z.record(z.string());
41const trimmedStringSchema = z.string().transform((value) => value.trim());
42const nonEmptyTrimmedStringSchema = trimmedStringSchema.refine((value) => value.length > 0);
42const basicAuthSchema = z.object({43const basicAuthSchema = z.object({
43 mode: authModeSchema.optional(),44 mode: authModeSchema.optional(),
44 username: z.string().optional(),45 username: z.string().optional(),
@@ -55,20 +56,24 @@ const apiKeyAuthSchema = z.object({ · 10 unmodified lines
55});56});
56 57
57function optionalTrimmedString(value: unknown): string | undefined {58function optionalTrimmedString(value: unknown): string | undefined {
58 if (typeof value !== "string") return undefined;59 const parsed = trimmedStringSchema.safeParse(value);
59 const trimmed = value.trim();60 if (!parsed.success || parsed.data.length === 0) {
60 return trimmed.length > 0 ? trimmed : undefined;61 return undefined;
62 }
63
64 return parsed.data;
61}65}
62 66
63function requiredTrimmedString(67function requiredTrimmedString(
64 value: unknown,68 value: unknown,
65 fieldName: string,69 fieldName: string,
66): Result<string, Error> {70): Result<string, Error> {
67 const trimmed = optionalTrimmedString(value);71 const parsed = nonEmptyTrimmedStringSchema.safeParse(value);
68 if (!trimmed) {72 if (!parsed.success) {
69 return Result.err(new Error(`Tool source ${fieldName} is required`));73 return Result.err(new Error(`Tool source ${fieldName} is required`));
70 }74 }
71 return Result.ok(trimmed);75
76 return Result.ok(parsed.data);
72}77}
73 78
74function normalizeStringMap(79function normalizeStringMap(
@@ -151,7 +156,12 @@ function normalizeOverrides( · 76 unmodified lines
151function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> {156function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> {
152 if (value === undefined) return Result.ok(undefined);157 if (value === undefined) return Result.ok(undefined);
153 158
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;
155 const authType = optionalTrimmedString(auth.type);165 const authType = optionalTrimmedString(auth.type);
156 if (!authType) {166 if (!authType) {
157 return Result.err(new Error("Tool source auth.type is required when auth is provided"));167 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
217function normalizeSpec(227function normalizeSpec(
218 value: unknown,228 value: unknown,
219): Result<string | Record<string, unknown>, Error> {229): Result<string | Record<string, unknown>, Error> {
220 const stringSpec = z.string().safeParse(value);230 const stringSpec = nonEmptyTrimmedStringSchema.safeParse(value);
221 if (stringSpec.success) {231 if (stringSpec.success) {
222 const trimmed = stringSpec.data.trim();232 return Result.ok(stringSpec.data);
223 if (!trimmed) {
224 return Result.err(new Error("Tool source spec is required"));
225 }
226 return Result.ok(trimmed);
227 }233 }
228 234
229 const specObjectResult = unknownRecordSchema.safeParse(value);235 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
18);18);
19 19
20export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));20export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
21export const policyScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace"));
22export const policyMatchTypeValidator = v.union(v.literal("glob"), v.literal("exact"));
23export const policyEffectValidator = v.union(v.literal("allow"), v.literal("deny"));
24export const policyApprovalModeValidator = v.union(v.literal("inherit"), v.literal("auto"), v.literal("required"));
25export const ownerScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace"));
21 26
22export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));27export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
23 28
executor/packages/database/src/lib/object.ts+9−10
@@ -1,13 +1,12 @@
1export function asRecord(value: unknown): Record<string, unknown> {1import { z } from "zod";
2 if (value && typeof value === "object" && !Array.isArray(value)) {2
3 return value as Record<string, unknown>;3const recordSchema = z.record(z.unknown());
4 }4
5 return {};5export function isRecord(value: unknown): value is Record<string, unknown> {
6 return recordSchema.safeParse(value).success;
6}7}
7 8
8export function asPayload(value: unknown): Record<string, unknown> {9export function toRecord(value: unknown): Record<string, unknown> {
9 if (value && typeof value === "object") {10 const parsed = recordSchema.safeParse(value);
10 return value as Record<string, unknown>;11 return parsed.success ? parsed.data : {};
11 }
12 return { value };
13}12}
executor/packages/database/src/runtime/policy.ts+51−10
@@ -7,21 +7,55 @@ import type { · 6 unmodified lines
7 ToolDefinition,7 ToolDefinition,
8} from "../../../core/src/types";8} from "../../../core/src/types";
9 9
10function matchesToolPath(pattern: string, toolPath: string): boolean {10function matchesToolPath(pattern: string, toolPath: string, matchType: "glob" | "exact" = "glob"): boolean {
11 if (matchType === "exact") {
12 return pattern === toolPath;
13 }
14
11 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");15 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
12 const regex = new RegExp(`^${escaped}$`);16 const regex = new RegExp(`^${escaped}$`);
13 return regex.test(toolPath);17 return regex.test(toolPath);
14}18}
15 19
16function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number {20function 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
17 let score = 0;29 let score = 0;
18 if (policy.actorId && actorId && policy.actorId === actorId) score += 4;30 if (scopeType === "workspace" && policy.workspaceId === context.workspaceId) score += 16;
19 if (policy.clientId && clientId && policy.clientId === clientId) score += 2;31 if (targetActorId && context.actorId && targetActorId === context.actorId) score += 64;
20 score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length);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);
21 score += policy.priority;35 score += policy.priority;
22 return score;36 return score;
23}37}
24 38
39function 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
25export function getDecisionForContext(59export function getDecisionForContext(
26 tool: ToolDefinition,60 tool: ToolDefinition,
27 context: { workspaceId: string; actorId?: string; clientId?: string },61 context: { workspaceId: string; actorId?: string; clientId?: string },
@@ -34,17 +68,24 @@ export function getDecisionForContext( · 6 unmodified lines
34 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";68 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
35 const candidates = policies69 const candidates = policies
36 .filter((policy) => {70 .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;
38 if (policy.clientId && policy.clientId !== context.clientId) return false;79 if (policy.clientId && policy.clientId !== context.clientId) return false;
39 return matchesToolPath(policy.toolPathPattern, tool.path);80 return matchesToolPath(resourcePattern, tool.path, matchType);
40 })81 })
41 .sort(82 .sort(
42 (a, b) =>83 (a, b) =>
43 policySpecificity(b, context.actorId, context.clientId)84 policySpecificity(b, context)
44 - policySpecificity(a, context.actorId, context.clientId),85 - policySpecificity(a, context),
45 );86 );
46 87
47 return candidates[0]?.decision ?? defaultDecision;88 return candidates[0] ? resolvePolicyDecision(candidates[0], defaultDecision) : defaultDecision;
48}89}
49 90
50export function getToolDecision(91export function getToolDecision(
executor/packages/database/src/runtime/tool_call_credentials.ts+43−58
@@ -1,38 +1,21 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";3import { Result } from "better-result";
4import { z } from "zod";
5import type { ActionCtx } from "../../convex/_generated/server";4import type { ActionCtx } from "../../convex/_generated/server";
6import { internal } from "../../convex/_generated/api";5import { internal } from "../../convex/_generated/api";
7import { resolveCredentialPayload } from "../../../core/src/credential-providers";6import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers";
7import {
8 buildCredentialAuthHeaders,
9 readCredentialOverrideHeaders,
10} from "../../../core/src/tool/source-auth";
8import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";11import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
9import { ToolCallControlError } from "../../../core/src/tool-call-control";12import { ToolCallControlError } from "../../../core/src/tool-call-control";
10import { asPayload } from "../lib/object";
11 13
12const bearerSecretSchema = z.object({14export async function resolveCredentialHeadersResult(
13 token: z.coerce.string().optional(),15 ctx: Pick<ActionCtx, "runQuery">,
14});
15
16const apiKeySecretSchema = z.object({
17 headerName: z.coerce.string().optional(),
18 value: z.coerce.string().optional(),
19 token: z.coerce.string().optional(),
20});
21
22const basicSecretSchema = z.object({
23 username: z.coerce.string().optional(),
24 password: z.coerce.string().optional(),
25});
26
27const credentialOverrideHeadersSchema = z.object({
28 headers: z.record(z.coerce.string()).optional(),
29});
30
31export async function resolveCredentialHeaders(
32 ctx: ActionCtx,
33 spec: ToolCredentialSpec,16 spec: ToolCredentialSpec,
34 task: TaskRecord,17 task: TaskRecord,
35): Promise<ResolvedToolCredential | null> {18): Promise<Result<ResolvedToolCredential | null, Error>> {
36 const record = await ctx.runQuery(internal.database.resolveCredential, {19 const record = await ctx.runQuery(internal.database.resolveCredential, {
37 workspaceId: task.workspaceId,20 workspaceId: task.workspaceId,
38 sourceKey: spec.sourceKey,21 sourceKey: spec.sourceKey,
@@ -40,53 +23,55 @@ export async function resolveCredentialHeaders( · 1 unmodified line
40 actorId: task.actorId,23 actorId: task.actorId,
41 });24 });
42 25
43 const source = record26 const sourceResult = record
44 ? await resolveCredentialPayload(record)27 ? await resolveCredentialPayloadResult(record)
45 : spec.staticSecretJson ?? null;28 : Result.ok(spec.staticSecretJson ?? null);
46 if (!source) {29 if (sourceResult.isErr()) {
47 return null;30 return Result.err(
31 new Error(`Failed to resolve credential payload for '${spec.sourceKey}': ${sourceResult.error.message}`),
32 );
48 }33 }
49 const sourcePayload = asPayload(source);
50 34
51 const headers: Record<string, string> = {};35 const source = sourceResult.value;
52 if (spec.authType === "bearer") {36 if (!source) {
53 const parsedSecret = bearerSecretSchema.safeParse(sourcePayload);37 return Result.ok(null);
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 }
72 }38 }
73 39
74 const bindingOverrides = credentialOverrideHeadersSchema.safeParse(record?.overridesJson ?? {});40 const headers = buildCredentialAuthHeaders(
75 const overrideHeaders = bindingOverrides.success ? (bindingOverrides.data.headers ?? {}) : {};41 {
42 authType: spec.authType,
43 headerName: spec.headerName,
44 },
45 source,
46 );
47
48 const overrideHeaders = readCredentialOverrideHeaders(record?.overridesJson);
76 for (const [key, value] of Object.entries(overrideHeaders)) {49 for (const [key, value] of Object.entries(overrideHeaders)) {
77 if (!key) continue;
78 headers[key] = value;50 headers[key] = value;
79 }51 }
80 52
81 if (Object.keys(headers).length === 0) {53 if (Object.keys(headers).length === 0) {
82 return null;54 return Result.ok(null);
83 }55 }
84 56
85 return {57 return Result.ok({
86 sourceKey: spec.sourceKey,58 sourceKey: spec.sourceKey,
87 mode: spec.mode,59 mode: spec.mode,
88 headers,60 headers,
89 };61 });
62}
63
64export 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;
90}75}
91 76
92export function validatePersistedCallRunnable(77export function validatePersistedCallRunnable(
executor/packages/database/src/runtime/tool_call_resolution.test.ts+54−0
@@ -0,0 +1,54 @@
1import { describe, expect, test } from "bun:test";
2import type { TaskRecord, ToolDefinition } from "../../../core/src/types";
3import { getGraphqlDecision } from "./tool_call_resolution";
4
5const 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
19const 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
28describe("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
7import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";7import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
8import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";8import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
9import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization";9import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization";
10import { asPayload } from "../lib/object";
11import { getDecisionForContext, getToolDecision } from "./policy";10import { getDecisionForContext, getToolDecision } from "./policy";
12import { getReadyRegistryBuildId } from "./tool_registry_state";11import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
13import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";12import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
14import { baseTools } from "./workspace_tools";13import { baseTools } from "./workspace_tools";
15 14
@@ -27,6 +26,24 @@ const registrySerializedToolEntrySchema = z.object({ · 11 unmodified lines
27 serializedToolJson: z.string(),26 serializedToolJson: z.string(),
28});27});
29 28
29const graphqlDecisionInputSchema = z.union([
30 z.string(),
31 z.object({ query: z.string().optional() }).catchall(z.unknown()),
32]);
33
34function 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
30async function searchRegistryEntries(47async function searchRegistryEntries(
31 ctx: ActionCtx,48 ctx: ActionCtx,
32 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },49 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
@@ -66,9 +83,12 @@ export function getGraphqlDecision( · 33 unmodified lines
66 workspaceTools: Map<string, ToolDefinition> | undefined,83 workspaceTools: Map<string, ToolDefinition> | undefined,
67 policies: AccessPolicyRecord[],84 policies: AccessPolicyRecord[],
68): { decision: PolicyDecision; effectivePaths: string[] } {85): { decision: PolicyDecision; effectivePaths: string[] } {
69 const sourceName = tool._graphqlSource!;86 const sourceName = tool._graphqlSource;
70 const payload = asPayload(input);87 if (!sourceName) {
71 const queryString = typeof payload.query === "string" ? payload.query : "";88 return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
89 }
90
91 const queryString = getGraphqlQueryFromInput(input);
72 92
73 if (!queryString.trim()) {93 if (!queryString.trim()) {
74 return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };94 return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
@@ -153,12 +173,16 @@ export async function resolveToolForCall( · 78 unmodified lines
153 return Result.ok({ tool: builtin, resolvedToolPath: toolPath });173 return Result.ok({ tool: builtin, resolvedToolPath: toolPath });
154 }174 }
155 175
156 const buildId = await getReadyRegistryBuildId(ctx, {176 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
157 workspaceId: task.workspaceId,177 workspaceId: task.workspaceId,
158 actorId: task.actorId,178 actorId: task.actorId,
159 clientId: task.clientId,179 clientId: task.clientId,
160 refreshOnStale: true,180 refreshOnStale: true,
161 });181 });
182 if (buildIdResult.isErr()) {
183 return Result.err(buildIdResult.error);
184 }
185 const buildId = buildIdResult.value;
162 186
163 let resolvedToolPath = toolPath;187 let resolvedToolPath = toolPath;
164 let entry = await getRegistryToolByPath(ctx, {188 let entry = await getRegistryToolByPath(ctx, {
@@ -179,8 +203,12 @@ export async function resolveToolForCall( · 14 unmodified lines
179 if (hits.length > 0) {203 if (hits.length > 0) {
180 // Prefer exact match on preferred path formatting, otherwise shortest canonical path.204 // Prefer exact match on preferred path formatting, otherwise shortest canonical path.
181 const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath));205 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]!;206 const shortest = [...hits]
183 resolvedToolPath = entry.path;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 }
184 }212 }
185 }213 }
186 214
@@ -189,15 +217,16 @@ export async function resolveToolForCall( · 2 unmodified lines
189 return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions)));217 return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions)));
190 }218 }
191 219
192 const serializedResult = Result.try(() => {220 let parsedSerialized: unknown;
193 return JSON.parse(entry.serializedToolJson);221 try {
194 });222 parsedSerialized = JSON.parse(entry.serializedToolJson);
195 if (serializedResult.isErr()) {223 } catch (error) {
224 const message = error instanceof Error ? error.message : String(error);
196 return Result.err(225 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}`),
198 );227 );
199 }228 }
200 const serialized = parseSerializedTool(serializedResult.value);229 const serialized = parseSerializedTool(parsedSerialized);
201 if (serialized.isErr()) {230 if (serialized.isErr()) {
202 return Result.err(231 return Result.err(
203 new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serialized.error.message}`),232 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
18 decodeToolCallControlSignal,18 decodeToolCallControlSignal,
19 ToolCallControlError,19 ToolCallControlError,
20} from "../../../core/src/tool-call-control";20} from "../../../core/src/tool-call-control";
21import { asPayload } from "../lib/object";
22import { getToolDecision, getDecisionForContext } from "./policy";21import { getToolDecision, getDecisionForContext } from "./policy";
23import { baseTools } from "./workspace_tools";22import { baseTools } from "./workspace_tools";
24import { publishTaskEvent } from "./events";23import { publishTaskEvent } from "./events";
25import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";24import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
26import { resolveCredentialHeaders, validatePersistedCallRunnable } from "./tool_call_credentials";25import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials";
27import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";26import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
28import { getReadyRegistryBuildId } from "./tool_registry_state";27import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
28
29const payloadRecordSchema = z.record(z.unknown());
29 30
30function createApprovalId(): string {31function createApprovalId(): string {
31 return `approval_${crypto.randomUUID()}`;32 return `approval_${crypto.randomUUID()}`;
@@ -41,18 +42,6 @@ type RegistryToolEntry = { · 9 unmodified lines
41 displayOutput?: string;42 displayOutput?: string;
42};43};
43 44
44const 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
56const registryNamespaceSchema = z.object({45const registryNamespaceSchema = z.object({
57 namespace: z.string(),46 namespace: z.string(),
58 toolCount: z.number(),47 toolCount: z.number(),
@@ -85,6 +74,15 @@ const discoverInputSchema = z.object({ · 26 unmodified lines
85 compact: z.boolean().optional(),74 compact: z.boolean().optional(),
86});75});
87 76
77function 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
88async function upsertRequestedToolCall(86async function upsertRequestedToolCall(
89 ctx: ActionCtx,87 ctx: ActionCtx,
90 args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },88 args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },
@@ -98,8 +96,7 @@ async function listWorkspaceAccessPolicies( · 7 unmodified lines
98 workspaceId: TaskRecord["workspaceId"],96 workspaceId: TaskRecord["workspaceId"],
99): Promise<AccessPolicyRecord[]> {97): Promise<AccessPolicyRecord[]> {
100 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });98 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });
101 const parsed = z.array(accessPolicyRecordSchema).safeParse(policies);99 return policies as AccessPolicyRecord[];
102 return parsed.success ? parsed.data : [];
103}100}
104 101
105async function listRegistryNamespaces(102async function listRegistryNamespaces(
@@ -183,16 +180,20 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 77 unmodified lines
183 180
184 // Fast system tools are handled server-side from the registry.181 // Fast system tools are handled server-side from the registry.
185 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {182 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
186 const buildId = await getReadyRegistryBuildId(ctx, {183 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
187 workspaceId: task.workspaceId,184 workspaceId: task.workspaceId,
188 actorId: task.actorId,185 actorId: task.actorId,
189 clientId: task.clientId,186 clientId: task.clientId,
190 refreshOnStale: true,187 refreshOnStale: true,
191 });188 });
189 if (buildIdResult.isErr()) {
190 throw buildIdResult.error;
191 }
192 const buildId = buildIdResult.value;
192 193
193 const payload = typeof input === "string"194 const payload = typeof input === "string"
194 ? { query: input }195 ? { query: input }
195 : asPayload(input);196 : toInputPayload(input);
196 const isAllowed = (path: string, approval: ToolDefinition["approval"]) => {197 const isAllowed = (path: string, approval: ToolDefinition["approval"]) => {
197 const policyProbeTool: ToolDefinition = {198 const policyProbeTool: ToolDefinition = {
198 path,199 path,
@@ -339,7 +340,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 140 unmodified lines
339 340
340 let credential: ResolvedToolCredential | undefined;341 let credential: ResolvedToolCredential | undefined;
341 if (tool.credential) {342 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;
343 if (!resolved) {349 if (!resolved) {
344 throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);350 throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
345 }351 }
@@ -402,7 +408,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 56 unmodified lines
402 taskId: task.id,408 taskId: task.id,
403 callId,409 callId,
404 toolPath: approval.toolPath,410 toolPath: approval.toolPath,
405 input: asPayload(approval.input),411 input: toInputPayload(approval.input),
406 createdAt: approval.createdAt,412 createdAt: approval.createdAt,
407 });413 });
408 }414 }
executor/packages/database/src/runtime/tool_registry_state.ts+48−27
@@ -1,5 +1,6 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
3import { z } from "zod";4import { z } from "zod";
4import type { ActionCtx } from "../../convex/_generated/server";5import type { ActionCtx } from "../../convex/_generated/server";
5import type { Id } from "../../convex/_generated/dataModel.d.ts";6import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -21,12 +22,6 @@ type RegistryState = { · 15 unmodified lines
21 readyBuildId?: string;22 readyBuildId?: string;
22} | null;23} | null;
23 24
24type ToolSourceState = {
25 id: string;
26 updatedAt: number;
27 enabled: boolean;
28};
29
30const registryStateSchema = z.object({25const registryStateSchema = z.object({
31 signature: z.string(),26 signature: z.string(),
32 readyBuildId: z.string().optional(),27 readyBuildId: z.string().optional(),
@@ -34,9 +29,29 @@ const registryStateSchema = z.object({ · 1 unmodified line
34 29
35const toolSourceStateSchema = z.object({30const toolSourceStateSchema = z.object({
36 id: z.string(),31 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(),
37 updatedAt: z.number(),38 updatedAt: z.number(),
38 enabled: z.boolean().optional(),39 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
52type ToolSourceState = z.infer<typeof toolSourceStateSchema>;
53
54const toolSourceStateListSchema = z.array(toolSourceStateSchema);
40 55
41function toRegistryState(value: unknown): RegistryState {56function toRegistryState(value: unknown): RegistryState {
42 const parsed = registryStateSchema.safeParse(value);57 const parsed = registryStateSchema.safeParse(value);
@@ -51,23 +66,12 @@ function toRegistryState(value: unknown): RegistryState { · 8 unmodified lines
51}66}
52 67
53function toToolSourceStateList(value: unknown): ToolSourceState[] {68function toToolSourceStateList(value: unknown): ToolSourceState[] {
54 if (!Array.isArray(value)) {69 const parsed = toolSourceStateListSchema.safeParse(value);
70 if (!parsed.success) {
55 return [];71 return [];
56 }72 }
57 73
58 const normalized: ToolSourceState[] = [];74 return parsed.data;
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;
71}75}
72 76
73async function readRegistryState(77async function readRegistryState(
@@ -90,7 +94,7 @@ async function readRegistryState( · 16 unmodified lines
90 };94 };
91}95}
92 96
93export async function getReadyRegistryBuildId(97export async function getReadyRegistryBuildIdResult(
94 ctx: Pick<ActionCtx, "runQuery" | "runAction">,98 ctx: Pick<ActionCtx, "runQuery" | "runAction">,
95 args: {99 args: {
96 workspaceId: Id<"workspaces">;100 workspaceId: Id<"workspaces">;
@@ -98,10 +102,10 @@ export async function getReadyRegistryBuildId( · 1 unmodified line
98 clientId?: string;102 clientId?: string;
99 refreshOnStale?: boolean;103 refreshOnStale?: boolean;
100 },104 },
101): Promise<string> {105): Promise<Result<string, Error>> {
102 const initial = await readRegistryState(ctx, args.workspaceId);106 const initial = await readRegistryState(ctx, args.workspaceId);
103 if (initial.isReady && initial.buildId) {107 if (initial.isReady && initial.buildId) {
104 return initial.buildId;108 return Result.ok(initial.buildId);
105 }109 }
106 110
107 if (args.refreshOnStale) {111 if (args.refreshOnStale) {
@@ -113,11 +117,28 @@ export async function getReadyRegistryBuildId( · 5 unmodified lines
113 117
114 const refreshed = await readRegistryState(ctx, args.workspaceId);118 const refreshed = await readRegistryState(ctx, args.workspaceId);
115 if (refreshed.isReady && refreshed.buildId) {119 if (refreshed.isReady && refreshed.buildId) {
116 return refreshed.buildId;120 return Result.ok(refreshed.buildId);
117 }121 }
118 }122 }
119 123
120 throw new Error(124 return Result.err(
121 "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",125 new Error("Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild."),
122 );126 );
123}127}
128
129export 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
11 prepareOpenApiSpec,11 prepareOpenApiSpec,
12 type CompiledToolSourceArtifact,12 type CompiledToolSourceArtifact,
13} from "../../../core/src/tool-sources";13} from "../../../core/src/tool-sources";
14import { resolveCredentialPayload } from "../../../core/src/credential-providers";14import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers";
15import { buildStaticAuthHeaders } from "../../../core/src/tool/source-auth";15import {
16 buildCredentialAuthHeaders,
17 buildStaticAuthHeaders,
18 readCredentialOverrideHeaders,
19} from "../../../core/src/tool/source-auth";
16import type {20import type {
17 ExternalToolSourceConfig,21 ExternalToolSourceConfig,
18 GraphqlToolSourceConfig,22 GraphqlToolSourceConfig,
@@ -28,6 +32,7 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 9 unmodified lines
28 32
29/** Cache version - bump when tool snapshot/registry/type-hint semantics change. */33/** Cache version - bump when tool snapshot/registry/type-hint semantics change. */
30const TOOL_SOURCE_CACHE_VERSION = "v25";34const TOOL_SOURCE_CACHE_VERSION = "v25";
35const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v1";
31 36
32const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]);37const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]);
33 38
@@ -52,19 +57,6 @@ const openApiAuthSchema = z.union([ · 18 unmodified lines
52 }),57 }),
53]);58]);
54 59
55const bearerSecretSchema = z.object({ token: z.coerce.string().optional() });
56const apiKeySecretSchema = z.object({
57 value: z.coerce.string().optional(),
58 token: z.coerce.string().optional(),
59});
60const basicSecretSchema = z.object({
61 username: z.coerce.string().optional(),
62 password: z.coerce.string().optional(),
63});
64const credentialOverrideHeadersSchema = z.object({
65 headers: z.record(z.coerce.string()).optional(),
66});
67
68const preparedOpenApiSpecSchema = z.object({60const preparedOpenApiSpecSchema = z.object({
69 servers: z.array(z.string()),61 servers: z.array(z.string()),
70 paths: z.record(z.unknown()),62 paths: z.record(z.unknown()),
@@ -99,9 +91,31 @@ function toPreparedOpenApiSpec(value: unknown): PreparedOpenApiSpec | null { · 28 unmodified lines
99 };91 };
100}92}
101 93
102export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {94export 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 {
103 const parts = sources108 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 })
105 .sort();119 .sort();
106 return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`;120 return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`;
107}121}
@@ -189,36 +203,23 @@ export function normalizeExternalToolSource(raw: { · 81 unmodified lines
189 return Result.ok(result);203 return Result.ok(result);
190}204}
191 205
192function buildHeadersFromCredentialSecret(206function toCredentialHeaderSpec(auth: OpenApiAuth):
193 auth: OpenApiAuth,207 | { authType: "bearer" | "apiKey" | "basic"; headerName?: string }
194 secret: Record<string, unknown>,208 | null {
195): Record<string, string> {209 if (auth.type === "none") {
196 if (auth.type === "bearer") {210 return null;
197 const parsedSecret = bearerSecretSchema.safeParse(secret);
198 const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : "";
199 return token ? { authorization: `Bearer ${token}` } : {};
200 }211 }
201 212
202 if (auth.type === "apiKey") {213 if (auth.type === "apiKey") {
203 const parsedSecret = apiKeySecretSchema.safeParse(secret);214 return {
204 const value = parsedSecret.success215 authType: "apiKey",
205 ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim()216 headerName: auth.header,
206 : "";217 };
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}` };
219 }218 }
220 219
221 return {};220 return {
221 authType: auth.type,
222 };
222}223}
223 224
224async function resolveMcpDiscoveryHeaders(225async function resolveMcpDiscoveryHeaders(
@@ -258,38 +259,41 @@ async function resolveMcpDiscoveryHeaders( · 33 unmodified lines
258 };259 };
259 }260 }
260 261
261 try {262 const secretResult = await resolveCredentialPayloadResult(record);
262 const secret = await resolveCredentialPayload(record);263 if (secretResult.isErr()) {
263 if (!secret) {264 return {
264 return {265 headers: {},
265 headers: {},266 warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${secretResult.error.message}`],
266 warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`],267 };
267 };268 }
268 }
269 269
270 const headers = buildHeadersFromCredentialSecret(auth, secret);270 const secret = secretResult.value;
271 const parsedOverrides = credentialOverrideHeadersSchema.safeParse(record.overridesJson ?? {});271 if (!secret) {
272 const overrideHeaders = parsedOverrides.success ? (parsedOverrides.data.headers ?? {}) : {};272 return {
273 for (const [key, value] of Object.entries(overrideHeaders)) {273 headers: {},
274 if (!key) continue;274 warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`],
275 headers[key] = value;275 };
276 }276 }
277 277
278 if (Object.keys(headers).length === 0) {278 const authSpec = toCredentialHeaderSpec(auth);
279 return {279 if (!authSpec) {
280 headers: {},280 return { headers: {}, warnings: [] };
281 warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`],281 }
282 };
283 }
284 282
285 return { headers, warnings: [] };283 const headers = buildCredentialAuthHeaders(authSpec, secret);
286 } catch (error) {284 const overrideHeaders = readCredentialOverrideHeaders(record.overridesJson);
287 const message = error instanceof Error ? error.message : String(error);285 for (const [key, value] of Object.entries(overrideHeaders)) {
286 headers[key] = value;
287 }
288
289 if (Object.keys(headers).length === 0) {
288 return {290 return {
289 headers: {},291 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`],
291 };293 };
292 }294 }
295
296 return { headers, warnings: [] };
293}297}
294 298
295async function loadCachedOpenApiSpec(299async function loadCachedOpenApiSpec(
@@ -308,7 +312,7 @@ async function loadCachedOpenApiSpec( · 12 unmodified lines
308 try {312 try {
309 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {313 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
310 specUrl,314 specUrl,
311 version: TOOL_SOURCE_CACHE_VERSION,315 version: OPENAPI_PREPARED_CACHE_VERSION,
312 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,316 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
313 });317 });
314 318
@@ -316,9 +320,15 @@ async function loadCachedOpenApiSpec( · 1 unmodified line
316 const blob = await ctx.storage.get(entry.storageId);320 const blob = await ctx.storage.get(entry.storageId);
317 if (blob) {321 if (blob) {
318 const json = await blob.text();322 const json = await blob.text();
319 const parsedResult = Result.try(() => JSON.parse(json));323 let parsedJson: unknown;
320 if (parsedResult.isOk()) {324 try {
321 const prepared = toPreparedOpenApiSpec(parsedResult.value);325 parsedJson = JSON.parse(json);
326 } catch {
327 parsedJson = undefined;
328 }
329
330 if (parsedJson !== undefined) {
331 const prepared = toPreparedOpenApiSpec(parsedJson);
322 if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) {332 if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) {
323 return prepared;333 return prepared;
324 }334 }
@@ -338,7 +348,7 @@ async function loadCachedOpenApiSpec( · 13 unmodified lines
338 const storageId = await ctx.storage.store(blob);348 const storageId = await ctx.storage.store(blob);
339 await ctx.runMutation(internal.openApiSpecCache.putEntry, {349 await ctx.runMutation(internal.openApiSpecCache.putEntry, {
340 specUrl,350 specUrl,
341 version: TOOL_SOURCE_CACHE_VERSION,351 version: OPENAPI_PREPARED_CACHE_VERSION,
342 storageId,352 storageId,
343 sizeBytes: json.length,353 sizeBytes: json.length,
344 });354 });
@@ -354,7 +364,7 @@ export async function loadSourceArtifact( · 9 unmodified lines
354 ctx: ActionCtx,364 ctx: ActionCtx,
355 source: ExternalToolSourceConfig,365 source: ExternalToolSourceConfig,
356 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },366 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 }> {
358 const includeDts = options.includeDts ?? true;368 const includeDts = options.includeDts ?? true;
359 369
360 if (source.type === "openapi" && typeof source.spec === "string") {370 if (source.type === "openapi" && typeof source.spec === "string") {
@@ -364,7 +374,12 @@ export async function loadSourceArtifact( · 3 unmodified lines
364 const warnings = (prepared.warnings ?? []).map(374 const warnings = (prepared.warnings ?? []).map(
365 (warning) => `Source '${source.name}': ${warning}`,375 (warning) => `Source '${source.name}': ${warning}`,
366 );376 );
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 };
368 } catch (error) {383 } catch (error) {
369 const message = error instanceof Error ? error.message : String(error);384 const message = error instanceof Error ? error.message : String(error);
370 return {385 return {
executor/packages/database/src/runtime/workspace_tools.ts+65−31
@@ -30,7 +30,6 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 29 unmodified lines
30import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";30import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
31import { registrySignatureForWorkspace } from "./tool_registry_state";31import { registrySignatureForWorkspace } from "./tool_registry_state";
32import { normalizeToolPathForLookup } from "./tool_paths";32import { normalizeToolPathForLookup } from "./tool_paths";
33import { asPayload } from "../lib/object";
34 33
35const baseTools = new Map<string, ToolDefinition>();34const baseTools = new Map<string, ToolDefinition>();
36 35
@@ -39,6 +38,33 @@ const adminAnnouncementInputSchema = z.object({ · 2 unmodified lines
39 message: z.string().optional(),38 message: z.string().optional(),
40});39});
41 40
41const actorScopedMcpAuthSchema = z.object({
42 mode: z.literal("actor"),
43});
44
45const 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
52const payloadRecordSchema = z.record(z.unknown());
53
54function 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
63function toJsonSchema(value: unknown): JsonSchema {
64 const parsed = payloadRecordSchema.safeParse(value);
65 return parsed.success ? parsed.data : {};
66}
67
42async function listWorkspaceToolSources(68async function listWorkspaceToolSources(
43 ctx: ActionCtx,69 ctx: ActionCtx,
44 workspaceId: Id<"workspaces">,70 workspaceId: Id<"workspaces">,
@@ -56,17 +82,23 @@ async function listWorkspaceAccessPolicies( · 11 unmodified lines
56}82}
57 83
58async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> {84async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> {
59 const textResult = await Result.tryPromise(async () => await blob.text());85 let text: string;
60 if (textResult.isErr()) {86 try {
61 return Result.err(new Error(`Failed to read cache blob: ${textResult.error.message}`));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}`));
62 }91 }
63 92
64 const jsonResult = Result.try(() => JSON.parse(textResult.value));93 let parsedJson: unknown;
65 if (jsonResult.isErr()) {94 try {
66 return Result.err(new Error(`Failed to parse cache JSON: ${jsonResult.error.message}`));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}`));
67 }99 }
68 100
69 return parseWorkspaceToolSnapshot(jsonResult.value);101 return parseWorkspaceToolSnapshot(parsedJson);
70}102}
71 103
72// Minimal built-in tools used by tests/demos.104// Minimal built-in tools used by tests/demos.
@@ -98,7 +130,7 @@ baseTools.set("admin.send_announcement", { · 25 unmodified lines
98 },130 },
99 },131 },
100 run: async (input: unknown) => {132 run: async (input: unknown) => {
101 const parsedInput = adminAnnouncementInputSchema.safeParse(asPayload(input));133 const parsedInput = adminAnnouncementInputSchema.safeParse(toInputPayload(input));
102 const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : "";134 const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : "";
103 const message = parsedInput.success ? (parsedInput.data.message ?? "") : "";135 const message = parsedInput.success ? (parsedInput.data.message ?? "") : "";
104 return { ok: true, channel, message };136 return { ok: true, channel, message };
@@ -401,13 +433,6 @@ function normalizeHint(type?: string): string { · 296 unmodified lines
401 return type && type.trim().length > 0 ? type : "unknown";433 return type && type.trim().length > 0 ? type : "unknown";
402}434}
403 435
404function asJsonSchema(value: unknown): JsonSchema {
405 if (!value || typeof value !== "object" || Array.isArray(value)) {
406 return {};
407 }
408 return asPayload(value);
409}
410
411async function buildWorkspaceToolRegistry(436async function buildWorkspaceToolRegistry(
412 ctx: ActionCtx,437 ctx: ActionCtx,
413 args: {438 args: {
@@ -433,24 +458,24 @@ async function buildWorkspaceToolRegistry( · 19 unmodified lines
433 const normalizedPath = normalizeToolPathForLookup(st.path);458 const normalizedPath = normalizeToolPathForLookup(st.path);
434 const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();459 const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();
435 460
436 const inputSchema = asJsonSchema(st.typing?.inputSchema);461 const inputSchema = toJsonSchema(st.typing?.inputSchema);
437 const outputSchema = asJsonSchema(st.typing?.outputSchema);462 const outputSchema = toJsonSchema(st.typing?.outputSchema);
463 const parsedTyping = toolHintSchema.safeParse(st.typing);
464 const typing = parsedTyping.success ? parsedTyping.data : {};
438 465
439 const requiredInputKeys = Array.isArray(st.typing?.requiredInputKeys)466 const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema);
440 ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string")467 const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema);
441 : extractTopLevelRequiredKeys(inputSchema);468 const inputHint = typing.inputHint?.trim();
442 const previewInputKeys = Array.isArray(st.typing?.previewInputKeys)469 const outputHint = typing.outputHint?.trim();
443 ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string")
444 : buildPreviewKeys(inputSchema);
445 470
446 const displayInput = typeof st.typing?.inputHint === "string" && st.typing.inputHint.trim().length > 0471 const displayInput = inputHint && inputHint.length > 0
447 ? st.typing.inputHint.trim()472 ? inputHint
448 : (Object.keys(inputSchema).length === 0473 : (Object.keys(inputSchema).length === 0
449 ? "{}"474 ? "{}"
450 : normalizeHint(jsonSchemaTypeHintFallback(inputSchema)));475 : normalizeHint(jsonSchemaTypeHintFallback(inputSchema)));
451 476
452 const displayOutput = typeof st.typing?.outputHint === "string" && st.typing.outputHint.trim().length > 0477 const displayOutput = outputHint && outputHint.length > 0
453 ? st.typing.outputHint.trim()478 ? outputHint
454 : (Object.keys(outputSchema).length === 0479 : (Object.keys(outputSchema).length === 0
455 ? "unknown"480 ? "unknown"
456 : normalizeHint(jsonSchemaTypeHintFallback(outputSchema)));481 : normalizeHint(jsonSchemaTypeHintFallback(outputSchema)));
@@ -525,6 +550,11 @@ async function buildWorkspaceToolRegistry( · 68 unmodified lines
525 buildId,550 buildId,
526 });551 });
527 552
553 await ctx.runMutation(internal.toolRegistry.pruneBuilds, {
554 workspaceId: args.workspaceId,
555 maxRetainedBuilds: 2,
556 });
557
528 return { buildId };558 return { buildId };
529}559}
530 560
@@ -553,8 +583,9 @@ export async function getWorkspaceTools( · 22 unmodified lines
553 if (source.type !== "mcp") {583 if (source.type !== "mcp") {
554 return false;584 return false;
555 }585 }
556 const auth = asPayload(source.config.auth);586
557 return auth?.mode === "actor";587 const auth = actorScopedMcpAuthSchema.safeParse(source.config.auth);
588 return auth.success;
558 });589 });
559 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;590 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
560 const skipCacheWrite = hasActorScopedMcpSource;591 const skipCacheWrite = hasActorScopedMcpSource;
@@ -670,6 +701,7 @@ export async function getWorkspaceTools( · 109 unmodified lines
670 timedOut: boolean;701 timedOut: boolean;
671 sourceName: string;702 sourceName: string;
672 openApiDts?: string;703 openApiDts?: string;
704 openApiSourceKey?: string;
673 }>((resolve) => {705 }>((resolve) => {
674 timer = setTimeout(() => {706 timer = setTimeout(() => {
675 resolve({707 resolve({
@@ -678,6 +710,7 @@ export async function getWorkspaceTools( · 2 unmodified lines
678 timedOut: true,710 timedOut: true,
679 sourceName: config.name,711 sourceName: config.name,
680 openApiDts: undefined,712 openApiDts: undefined,
713 openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined,
681 });714 });
682 }, sourceTimeoutMs);715 }, sourceTimeoutMs);
683 });716 });
@@ -739,7 +772,8 @@ export async function getWorkspaceTools( · 55 unmodified lines
739 const openApiDtsBySource: Record<string, string> = {};772 const openApiDtsBySource: Record<string, string> = {};
740 for (const loaded of loadedSources) {773 for (const loaded of loadedSources) {
741 if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) {774 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;
743 }777 }
744 }778 }
745 const typeBundle = buildWorkspaceTypeBundle({779 const typeBundle = buildWorkspaceTypeBundle({
executor/packages/runner-sandbox-host/src/bridge.ts+42−35
@@ -1,6 +1,7 @@
1import { Result } from "better-result";1import { Result } from "better-result";
2import { api } from "@executor/database/convex/_generated/api";2import { api } from "@executor/database/convex/_generated/api";
3import { ConvexClient, ConvexHttpClient } from "convex/browser";3import { ConvexClient, ConvexHttpClient } from "convex/browser";
4import { z } from "zod";
4import type {5import type {
5 BridgeEntrypointContext,6 BridgeEntrypointContext,
6 BridgeProps,7 BridgeProps,
@@ -10,33 +11,41 @@ import type { · 3 unmodified lines
10 11
11const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000;12const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000;
12 13
13function asObject(value: unknown): Record<string, unknown> {14const bridgePropsSchema: z.ZodType<BridgeProps> = z.object({
14 if (value && typeof value === "object" && !Array.isArray(value)) {15 callbackConvexUrl: z.string(),
15 return value as Record<string, unknown>;16 callbackInternalSecret: z.string(),
16 }17 taskId: z.string(),
17 return {};18});
18}19
19 20const toolCallResultSchema: z.ZodType<ToolCallResult> = z.union([
20function isBridgeProps(value: unknown): value is BridgeProps {21 z.object({ ok: z.literal(true), value: z.unknown().optional() }),
21 const props = asObject(value);22 z.object({
22 return (23 ok: z.literal(false),
23 typeof props.callbackConvexUrl === "string"24 kind: z.literal("pending"),
24 && typeof props.callbackInternalSecret === "string"25 approvalId: z.string(),
25 && typeof props.taskId === "string"26 error: z.string().optional(),
26 );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
38const recordSchema = z.record(z.unknown());
39
40function hasToolBridgeExport(
41 value: Record<string, unknown>,
42): value is { ToolBridge: WorkerEntrypointExports["ToolBridge"] } {
43 return typeof value.ToolBridge === "function";
27}44}
28 45
29function isToolCallResult(value: unknown): value is ToolCallResult {46function asObject(value: unknown): Record<string, unknown> {
30 const record = asObject(value);47 const parsed = recordSchema.safeParse(value);
31 if (record.ok === true) {48 return parsed.success ? parsed.data : {};
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;
40}49}
41 50
42export function getBridgePropsFromContext(51export function getBridgePropsFromContext(
@@ -47,11 +56,12 @@ export function getBridgePropsFromContext( · 4 unmodified lines
47 throw new Error("WorkerEntrypoint context is unavailable");56 throw new Error("WorkerEntrypoint context is unavailable");
48 }57 }
49 58
50 if (!isBridgeProps(context.props)) {59 const parsedProps = bridgePropsSchema.safeParse(context.props);
60 if (!parsedProps.success) {
51 throw new Error("ToolBridge props are missing or invalid");61 throw new Error("ToolBridge props are missing or invalid");
52 }62 }
53 63
54 return context.props;64 return parsedProps.data;
55}65}
56 66
57export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExports {67export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExports {
@@ -63,11 +73,11 @@ export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExp · 5 unmodified lines
63 }73 }
64 74
65 const exportsObject = asObject(exportsValue);75 const exportsObject = asObject(exportsValue);
66 if (typeof exportsObject.ToolBridge !== "function") {76 if (!hasToolBridgeExport(exportsObject)) {
67 throw new Error("Execution context ToolBridge export is unavailable");77 throw new Error("Execution context ToolBridge export is unavailable");
68 }78 }
69 79
70 return { ToolBridge: exportsObject.ToolBridge as WorkerEntrypointExports["ToolBridge"] };80 return { ToolBridge: exportsObject.ToolBridge };
71}81}
72 82
73function createConvexClient(callbackConvexUrl: string): ConvexHttpClient {83function createConvexClient(callbackConvexUrl: string): ConvexHttpClient {
@@ -157,16 +167,13 @@ export async function callToolWithBridge( · 83 unmodified lines
157 return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` };167 return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` };
158 }168 }
159 169
160 const result = response.value;170 const parsedResult = toolCallResultSchema.safeParse(response.value);
161 if (!isToolCallResult(result)) {171 if (!parsedResult.success) {
162 return { ok: false, kind: "failed", error: "Tool callback returned invalid result payload" };172 return { ok: false, kind: "failed", error: "Tool callback returned invalid result payload" };
163 }173 }
174 const result = parsedResult.data;
164 175
165 if (!result.ok && result.kind === "pending") {176 if (!result.ok && result.kind === "pending") {
166 if (!result.approvalId) {
167 return { ok: false, kind: "failed", error: "Approval pending without approvalId" };
168 }
169
170 const approvalId = result.approvalId;177 const approvalId = result.approvalId;
171 const wait = await Result.tryPromise(() => waitForApprovalUpdate(props, approvalId));178 const wait = await Result.tryPromise(() => waitForApprovalUpdate(props, approvalId));
172 if (wait.isErr()) {179 if (wait.isErr()) {
executor/packages/runner-sandbox-host/src/request.ts+18−6
@@ -1,19 +1,31 @@
1import { Result } from "better-result";1import { z } from "zod";
2import type { RunRequest } from "./types";2import type { RunRequest } from "./types";
3 3
4const 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
4export async function parseRunRequest(request: Request): Promise<RunRequest | Response> {14export async function parseRunRequest(request: Request): Promise<RunRequest | Response> {
5 const parsed = await Result.tryPromise(() => request.json() as Promise<RunRequest>);15 let rawBody: unknown;
6 if (parsed.isErr()) {16 try {
17 rawBody = await request.json();
18 } catch {
7 return Response.json({ error: "Invalid JSON body" }, { status: 400 });19 return Response.json({ error: "Invalid JSON body" }, { status: 400 });
8 }20 }
9 21
10 const body = parsed.value;22 const body = runRequestSchema.safeParse(rawBody);
11 if (!body.taskId || !body.code || !body.callback?.convexUrl || !body.callback?.internalSecret) {23 if (!body.success) {
12 return Response.json(24 return Response.json(
13 { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" },25 { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" },
14 { status: 400 },26 { status: 400 },
15 );27 );
16 }28 }
17 29
18 return body;30 return body.data;
19}31}
executor/packages/runner-sandbox-host/src/sandbox.ts+32−15
@@ -1,7 +1,14 @@
1import { Result } from "better-result";1import { z } from "zod";
2import type { Env, RunRequest, RunResult } from "./types";2import type { Env, RunRequest, RunResult } from "./types";
3import { getEntrypointExports } from "./bridge";3import { getEntrypointExports } from "./bridge";
4 4
5const 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
5/**12/**
6 * Build the user code module. The code is wrapped in an exported async13 * Build the user code module. The code is wrapped in an exported async
7 * function `run(tools, console)` so the harness can call it with controlled14 * function `run(tools, console)` so the harness can call it with controlled
@@ -51,32 +58,42 @@ export async function executeSandboxRun( · 43 unmodified lines
51 const controller = new AbortController();58 const controller = new AbortController();
52 const timer = setTimeout(() => controller.abort(), timeoutMs);59 const timer = setTimeout(() => controller.abort(), timeoutMs);
53 60
54 const response = await Result.tryPromise(() =>61 let response: Response;
55 entrypoint.fetch("http://sandbox.internal/run", {62 try {
63 response = await entrypoint.fetch("http://sandbox.internal/run", {
56 method: "POST",64 method: "POST",
57 signal: controller.signal,65 signal: controller.signal,
58 }),66 });
59 );67 } catch (error) {
60 68 clearTimeout(timer);
61 clearTimeout(timer);69 if (error instanceof DOMException && error.name === "AbortError") {
62
63 if (response.isErr()) {
64 const cause = response.error.cause;
65 if (cause instanceof DOMException && cause.name === "AbortError") {
66 return {70 return {
67 status: "timed_out",71 status: "timed_out",
68 error: `Execution timed out after ${timeoutMs}ms`,72 error: `Execution timed out after ${timeoutMs}ms`,
69 };73 };
70 }74 }
71 throw cause;75 throw error;
72 }76 }
73 77
74 const body = await Result.tryPromise(() => response.value.json() as Promise<RunResult>);78 clearTimeout(timer);
75 if (body.isErr()) {79
80 let body: unknown;
81 try {
82 body = await response.json();
83 } catch {
76 return {84 return {
77 status: "failed",85 status: "failed",
78 error: "Sandbox isolate returned invalid JSON",86 error: "Sandbox isolate returned invalid JSON",
79 };87 };
80 }88 }
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;
82}99}
executor/packages/runner-sandbox-host/src/types.ts+20−9
@@ -28,7 +28,7 @@ export interface EntrypointStub { · 27 unmodified lines
28export interface RunRequest {28export interface RunRequest {
29 taskId: string;29 taskId: string;
30 code: string;30 code: string;
31 timeoutMs: number;31 timeoutMs?: number;
32 callback: {32 callback: {
33 convexUrl: string;33 convexUrl: string;
34 internalSecret: string;34 internalSecret: string;
@@ -42,14 +42,25 @@ export interface RunResult { · 7 unmodified lines
42 exitCode?: number;42 exitCode?: number;
43}43}
44 44
45export interface ToolCallResult {45export type ToolCallResult =
46 ok: true | false;46 | {
47 value?: unknown;47 ok: true;
48 error?: string;48 value?: unknown;
49 kind?: "pending" | "denied" | "failed";49 }
50 approvalId?: string;50 | {
51 retryAfterMs?: number;51 ok: false;
52}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 };
53 64
54export interface BridgeProps {65export interface BridgeProps {
55 callbackConvexUrl: string;66 callbackConvexUrl: string;
opencode.json+21−11
@@ -1,18 +1,18 @@
1{1{
2 "$schema": "https://opencode.ai/config.json",2 "$schema": "https://opencode.ai/config.json",
3 "mcp":{3 "mcp": {
4 "answeroverflow": {4 "answeroverflow": {
5 "type": "remote",5 "type": "remote",
6 "url": "https://www.answeroverflow.com/mcp",6 "url": "https://www.answeroverflow.com/mcp",
7 "enabled": true7 "enabled": true
8 },8 },
9 "oracle": {9 "oracle": {
10 "type": "local",10 "type": "local",
11 "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"],11 "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"],
12 "enabled": true12 "enabled": true
13 }13 }
14 },14 },
15 "agent":{15 "agent": {
16 "quick-build": {16 "quick-build": {
17 "mode": "primary",17 "mode": "primary",
18 "description": "A smaller context winder but extremely fast at working",18 "description": "A smaller context winder but extremely fast at working",
@@ -22,6 +22,16 @@ · 3 unmodified lines
22 "edit": true,22 "edit": true,
23 "bash": true23 "bash": true
24 }24 }
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 }
25 }35 }
26 }36 }
27}37}