diff --git a/package-lock.json b/package-lock.json index b1a86fccc52..fcb1b053f55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2551,6 +2551,12 @@ "version": "2.1.0", "license": "MIT" }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@colors/colors": { "version": "1.6.0", "license": "MIT", @@ -2558,6 +2564,25 @@ "node": ">=0.1.90" } }, + "node_modules/@connectrpc/connect": { + "version": "2.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.0-rc.3.tgz", + "integrity": "sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.2.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.0.0-rc.3.tgz", + "integrity": "sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.2.0", + "@connectrpc/connect": "2.0.0-rc.3" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "license": "MIT", @@ -4388,6 +4413,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -5026,6 +5063,12 @@ "node": ">= 8" } }, + "node_modules/@opencode-ai/sdk": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.13.tgz", + "integrity": "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA==", + "license": "MIT" + }, "node_modules/@openfeature/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.9.1.tgz", @@ -17936,6 +17979,12 @@ "version": "1.0.1", "license": "MIT" }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -19272,6 +19321,19 @@ "node": ">= 8.0" } }, + "node_modules/dockerfile-ast": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/dockerfile-ast/-/dockerfile-ast-0.7.1.tgz", + "integrity": "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.8", + "vscode-languageserver-types": "^3.17.3" + }, + "engines": { + "node": "*" + } + }, "node_modules/dockerode": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", @@ -19545,6 +19607,112 @@ "stream-shift": "^1.0.2" } }, + "node_modules/e2b": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-2.18.0.tgz", + "integrity": "sha512-umWvNhKx3dWUfzcLPLO5Ep1qB5cTLuJlAlnxfKVUnOwx3yaO+H1PaAE2fihT4etEu3BNs3pHvdwa1VM+uMxe0w==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.6.2", + "@connectrpc/connect": "2.0.0-rc.3", + "@connectrpc/connect-web": "2.0.0-rc.3", + "chalk": "^5.3.0", + "compare-versions": "^6.1.0", + "dockerfile-ast": "^0.7.1", + "glob": "^11.1.0", + "openapi-fetch": "^0.14.1", + "platform": "^1.3.6", + "tar": "^7.5.11" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/e2b/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/e2b/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/e2b/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/e2b/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/e2b/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/e2b/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -25290,6 +25458,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -26131,6 +26311,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.1.tgz", + "integrity": "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, "node_modules/openapi-sampler": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.7.0.tgz", @@ -26150,6 +26339,12 @@ "dev": true, "peer": true }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, "node_modules/openid-client": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.2.tgz", @@ -26808,6 +27003,12 @@ "pathe": "^2.0.1" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/pluralize": { "version": "8.0.0", "license": "MIT", @@ -30325,6 +30526,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -30350,6 +30567,24 @@ "streamx": "^2.15.0" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/tarn": { "version": "3.0.2", "license": "MIT", @@ -32580,6 +32815,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -34945,12 +35192,13 @@ "@nangohq/logs": "file:../logs", "@nangohq/nango-orchestrator": "file:../orchestrator", "@nangohq/nango-yaml": "file:../nango-yaml", - "@nangohq/providers": "file:.../providers", + "@nangohq/providers": "file:../providers", "@nangohq/pubsub": "file:../pubsub", "@nangohq/records": "file:../records", "@nangohq/shared": "file:../shared", "@nangohq/utils": "file:../utils", "@nangohq/webhooks": "file:../webhooks", + "@opencode-ai/sdk": "^1.3.13", "@workos-inc/node": "6.2.0", "axios": "1.13.5", "body-parser": "2.2.1", @@ -34959,6 +35207,7 @@ "cors": "2.8.5", "dd-trace": "5.52.0", "dotenv": "16.5.0", + "e2b": "2.18.0", "exponential-backoff": "3.1.1", "express": "5.1.0", "express-session": "1.18.2", @@ -35009,9 +35258,8 @@ "vitest": "3.2.4" } }, - "packages/server/.../providers": {}, "packages/server/node_modules/@nangohq/providers": { - "resolved": "packages/server/.../providers", + "resolved": "packages/providers", "link": true }, "packages/server/node_modules/@types/ws": { diff --git a/packages/server/lib/controllers/functions/compile/postCompile.ts b/packages/server/lib/controllers/functions/compile/postCompile.ts new file mode 100644 index 00000000000..8a39fddfcbf --- /dev/null +++ b/packages/server/lib/controllers/functions/compile/postCompile.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { configService } from '@nangohq/shared'; +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { CompilerError, invokeCompiler } from '../../../services/remote-function/compiler-client.js'; +import { sendStepError } from '../../../services/remote-function/helpers.js'; +import { asyncWrapper } from '../../../utils/asyncWrapper.js'; + +import type { PostRemoteFunctionCompile } from '@nangohq/types'; + +const bodySchema = z + .object({ + integration_id: z.string().min(1), + function_name: z.string().min(1), + function_type: z.enum(['action', 'sync']), + code: z.string().min(1) + }) + .strict(); + +export const postRemoteFunctionCompile = asyncWrapper(async (req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const valBody = bodySchema.safeParse(req.body); + if (!valBody.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); + return; + } + + const body = valBody.data; + const { environment } = res.locals; + + const providerConfig = await configService.getProviderConfig(body.integration_id, environment.id); + if (!providerConfig) { + res.status(404).send({ error: { code: 'integration_not_found', message: `Integration '${body.integration_id}' was not found` } }); + return; + } + + try { + const result = await invokeCompiler({ + integration_id: body.integration_id, + function_name: body.function_name, + function_type: body.function_type, + code: body.code + }); + + res.status(200).send({ + integration_id: body.integration_id, + function_name: body.function_name, + function_type: body.function_type, + bundle_size_bytes: result.bundleSizeBytes, + bundled_js: result.bundledJs, + compiled_at: new Date().toISOString() + }); + } catch (err) { + sendStepError({ + res, + step: 'compilation', + error: err, + status: err instanceof CompilerError ? 400 : 500 + }); + } +}); diff --git a/packages/server/lib/controllers/functions/deploy/postDeploy.ts b/packages/server/lib/controllers/functions/deploy/postDeploy.ts new file mode 100644 index 00000000000..ff82cad2def --- /dev/null +++ b/packages/server/lib/controllers/functions/deploy/postDeploy.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +import db from '@nangohq/database'; +import { configService, getApiUrl, getSyncConfigRaw, secretService } from '@nangohq/shared'; +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { invokeDeploy } from '../../../services/remote-function/deploy-client.js'; +import { sendStepError } from '../../../services/remote-function/helpers.js'; +import { asyncWrapper } from '../../../utils/asyncWrapper.js'; + +import type { PostRemoteFunctionDeploy } from '@nangohq/types'; + +const bodySchema = z + .object({ + integration_id: z.string().min(1), + function_name: z.string().min(1), + function_type: z.enum(['action', 'sync']), + code: z.string().min(1) + }) + .strict(); + +export const postRemoteFunctionDeploy = asyncWrapper(async (req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const valBody = bodySchema.safeParse(req.body); + if (!valBody.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); + return; + } + + const body = valBody.data; + const { environment } = res.locals; + + const providerConfig = await configService.getProviderConfig(body.integration_id, environment.id); + if (!providerConfig || !providerConfig.id) { + res.status(404).send({ error: { code: 'integration_not_found', message: `Integration '${body.integration_id}' was not found` } }); + return; + } + + // Guard: refuse to overwrite pre-built or public functions + // TODO: once agent-generated functions have a distinct identifier, relax this check to allow overwriting agent functions only + const existingSyncConfig = await getSyncConfigRaw({ + environmentId: environment.id, + config_id: providerConfig.id, + name: body.function_name, + isAction: body.function_type === 'action' + }); + if (existingSyncConfig && (existingSyncConfig.is_public || existingSyncConfig.pre_built)) { + res.status(400).send({ + error: { + code: 'invalid_request', + message: `Cannot overwrite pre-built function '${body.function_name}'` + } + }); + return; + } + + const defaultSecret = await secretService.getDefaultSecretForEnv(db.readOnly, environment.id); + if (defaultSecret.isErr()) { + sendStepError({ res, step: 'deployment', status: 500, error: defaultSecret.error }); + return; + } + + const result = await invokeDeploy({ + integration_id: body.integration_id, + function_name: body.function_name, + function_type: body.function_type, + code: body.code, + nango_secret_key: defaultSecret.value.secret, + nango_host: getApiUrl() + }); + + res.status(200).send({ + integration_id: body.integration_id, + function_name: body.function_name, + function_type: body.function_type, + output: result.output + }); +}); diff --git a/packages/server/lib/controllers/functions/dryrun/postDryrun.ts b/packages/server/lib/controllers/functions/dryrun/postDryrun.ts new file mode 100644 index 00000000000..d2d4a44fb1f --- /dev/null +++ b/packages/server/lib/controllers/functions/dryrun/postDryrun.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; + +import db from '@nangohq/database'; +import { connectionService, getApiUrl, secretService } from '@nangohq/shared'; +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { invokeDryrun } from '../../../services/remote-function/dryrun-client.js'; +import { sendStepError } from '../../../services/remote-function/helpers.js'; +import { asyncWrapper } from '../../../utils/asyncWrapper.js'; + +import type { PostRemoteFunctionDryrun } from '@nangohq/types'; + +const bodySchema = z + .object({ + integration_id: z.string().min(1), + function_name: z.string().min(1), + function_type: z.enum(['action', 'sync']), + code: z.string().min(1), + connection_id: z.string().min(1), + input: z.unknown().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + checkpoint: z.record(z.string(), z.unknown()).optional(), + last_sync_date: z.string().datetime().optional() + }) + .strict(); + +export const postRemoteFunctionDryrun = asyncWrapper(async (req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const valBody = bodySchema.safeParse(req.body); + if (!valBody.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); + return; + } + + const body = valBody.data; + const { environment } = res.locals; + + const connectionResult = await connectionService.getConnection(body.connection_id, body.integration_id, environment.id); + if (!connectionResult.success || !connectionResult.response) { + sendStepError({ + res, + step: 'lookup', + status: 404, + error: { type: 'connection_not_found', message: `Connection '${body.connection_id}' was not found for integration '${body.integration_id}'` } + }); + return; + } + + const defaultSecret = await secretService.getDefaultSecretForEnv(db.readOnly, environment.id); + if (defaultSecret.isErr()) { + sendStepError({ res, step: 'lookup', status: 500, error: defaultSecret.error }); + return; + } + + const startedAt = new Date(); + + const result = await invokeDryrun({ + integration_id: body.integration_id, + function_name: body.function_name, + function_type: body.function_type, + code: body.code, + connection_id: body.connection_id, + nango_secret_key: defaultSecret.value.secret, + nango_host: getApiUrl(), + ...(body.input !== undefined ? { input: body.input } : {}), + ...(body.metadata ? { metadata: body.metadata } : {}), + ...(body.checkpoint ? { checkpoint: body.checkpoint } : {}), + ...(body.last_sync_date ? { last_sync_date: body.last_sync_date } : {}) + }); + + const durationMs = Date.now() - startedAt.getTime(); + + res.status(200).send({ + integration_id: body.integration_id, + function_name: body.function_name, + function_type: body.function_type, + execution_timeout_at: new Date(startedAt.getTime() + 5 * 60 * 1000).toISOString(), + duration_ms: durationMs, + output: result.output + }); +}); diff --git a/packages/server/lib/controllers/v1/agent/session/sessionToken/getEvents.ts b/packages/server/lib/controllers/v1/agent/session/sessionToken/getEvents.ts new file mode 100644 index 00000000000..459f834714f --- /dev/null +++ b/packages/server/lib/controllers/v1/agent/session/sessionToken/getEvents.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { getSessionByToken, subscribeToSession } from '../../../../../services/agent/agent-session.service.js'; +import { asyncWrapper } from '../../../../../utils/asyncWrapper.js'; + +import type { GetAgentSessionEvents } from '@nangohq/types'; + +const paramsSchema = z + .object({ + sessionToken: z.string().min(1) + }) + .strict(); + +const heartbeatIntervalMs = 15_000; + +export const getAgentSessionEvents = asyncWrapper(async (req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const valParams = paramsSchema.safeParse(req.params); + if (!valParams.success) { + res.status(400).send({ error: { code: 'invalid_uri_params', errors: zodErrorToHTTP(valParams.error) } }); + return; + } + + const { sessionToken } = valParams.data; + const { environment } = res.locals; + + const session = await getSessionByToken(sessionToken, environment.id); + if (!session) { + res.status(404).send({ error: { code: 'not_found', message: 'Session not found or access denied' } }); + return; + } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const write = (event: string, data: unknown): void => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + const { backlog, unsubscribe } = subscribeToSession(session, (browserEvent) => { + write(browserEvent.event, browserEvent.data); + }); + + // Flush backlog to catch up any missed events + for (const e of backlog) { + write(e.event, e.data); + } + + const heartbeat = setInterval(() => { + res.write(': heartbeat\n\n'); + }, heartbeatIntervalMs); + + req.on('close', () => { + clearInterval(heartbeat); + unsubscribe(); + }); +}); diff --git a/packages/server/lib/controllers/v1/agent/session/sessionToken/postAnswer.ts b/packages/server/lib/controllers/v1/agent/session/sessionToken/postAnswer.ts new file mode 100644 index 00000000000..e901852ad65 --- /dev/null +++ b/packages/server/lib/controllers/v1/agent/session/sessionToken/postAnswer.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { answerSession, getSessionByToken } from '../../../../../services/agent/agent-session.service.js'; +import { asyncWrapper } from '../../../../../utils/asyncWrapper.js'; + +import type { PostAgentSessionAnswer } from '@nangohq/types'; + +const paramsSchema = z + .object({ + sessionToken: z.string().min(1) + }) + .strict(); + +const bodySchema = z + .object({ + question_id: z.string().min(1), + response: z.string().min(1) + }) + .strict(); + +export const postAgentSessionAnswer = asyncWrapper(async (req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const valParams = paramsSchema.safeParse(req.params); + if (!valParams.success) { + res.status(400).send({ error: { code: 'invalid_uri_params', errors: zodErrorToHTTP(valParams.error) } }); + return; + } + + const valBody = bodySchema.safeParse(req.body); + if (!valBody.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); + return; + } + + const { sessionToken } = valParams.data; + const body = valBody.data; + const { environment } = res.locals; + + const session = await getSessionByToken(sessionToken, environment.id); + if (!session) { + res.status(404).send({ error: { code: 'not_found', message: 'Session not found or access denied' } }); + return; + } + + try { + await answerSession(session, body); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).send({ error: { code: 'invalid_request', message } }); + return; + } + + res.status(200).send({ + success: true, + accepted_at: new Date().toISOString() + }); +}); diff --git a/packages/server/lib/controllers/v1/agent/session/start/postStart.ts b/packages/server/lib/controllers/v1/agent/session/start/postStart.ts new file mode 100644 index 00000000000..32ad469150e --- /dev/null +++ b/packages/server/lib/controllers/v1/agent/session/start/postStart.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { configService } from '@nangohq/shared'; +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { createAgentSession } from '../../../../../services/agent/agent-session.service.js'; +import { asyncWrapper } from '../../../../../utils/asyncWrapper.js'; + +import type { PostAgentSessionStart } from '@nangohq/types'; + +const bodySchema = z + .object({ + prompt: z.string().min(1), + integration_id: z.string().min(1), + connection_id: z.string().optional() + }) + .strict(); + +export const postAgentSessionStart = asyncWrapper(async (req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const valBody = bodySchema.safeParse(req.body); + if (!valBody.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); + return; + } + + const body = valBody.data; + const { environment } = res.locals; + + const providerConfig = await configService.getProviderConfig(body.integration_id, environment.id); + if (!providerConfig) { + res.status(404).send({ error: { code: 'not_found', message: `Integration '${body.integration_id}' was not found` } }); + return; + } + + const { token, executionTimeoutAt } = await createAgentSession(environment.id, { + prompt: body.prompt, + integration_id: body.integration_id, + ...(body.connection_id ? { connection_id: body.connection_id } : {}) + }); + + res.status(201).send({ + session_token: token, + execution_timeout_at: executionTimeoutAt.toISOString() + }); +}); diff --git a/packages/server/lib/routes.private.ts b/packages/server/lib/routes.private.ts index 6f850b299d8..802e98ecea3 100644 --- a/packages/server/lib/routes.private.ts +++ b/packages/server/lib/routes.private.ts @@ -29,6 +29,9 @@ import { postForgotPassword } from './controllers/v1/account/postForgotPassword. import { postLogout } from './controllers/v1/account/postLogout.js'; import { putResetPassword } from './controllers/v1/account/putResetPassword.js'; import { postImpersonate } from './controllers/v1/admin/impersonate/postImpersonate.js'; +import { getAgentSessionEvents } from './controllers/v1/agent/session/sessionToken/getEvents.js'; +import { postAgentSessionAnswer } from './controllers/v1/agent/session/sessionToken/postAnswer.js'; +import { postAgentSessionStart } from './controllers/v1/agent/session/start/postStart.js'; import { postInternalConnectSessions } from './controllers/v1/connect/sessions/postConnectSessions.js'; import { getConnectUISettings } from './controllers/v1/connectUISettings/getConnectUISettings.js'; import { putConnectUISettings } from './controllers/v1/connectUISettings/putConnectUISettings.js'; @@ -282,6 +285,11 @@ if (flagHasUsage) { web.route('/admin/impersonate').post(webAuth, postImpersonate); +// Agent Builder +web.route('/agent/session/start').post(webAuth, postAgentSessionStart); +web.route('/agent/session/:sessionToken/events').get(webAuth, getAgentSessionEvents); +web.route('/agent/session/:sessionToken/answer').post(webAuth, postAgentSessionAnswer); + // Hosted signin if (!isCloud && !isEnterprise) { web.route('/basic').get(webAuth, (_: Request, res: Response) => { diff --git a/packages/server/lib/routes.public.ts b/packages/server/lib/routes.public.ts index 61fe74b6e1a..20cc2fd655d 100644 --- a/packages/server/lib/routes.public.ts +++ b/packages/server/lib/routes.public.ts @@ -33,6 +33,9 @@ import { getPublicConnections } from './controllers/connection/getConnections.js import { postPublicConnection } from './controllers/connection/postConnection.js'; import connectionController from './controllers/connection.controller.js'; import { getPublicEnvironmentVariables } from './controllers/environment/getVariables.js'; +import { postRemoteFunctionCompile } from './controllers/functions/compile/postCompile.js'; +import { postRemoteFunctionDeploy } from './controllers/functions/deploy/postDeploy.js'; +import { postRemoteFunctionDryrun } from './controllers/functions/dryrun/postDryrun.js'; import { getPublicListIntegrations } from './controllers/integrations/getListIntegrations.js'; import { postPublicIntegration } from './controllers/integrations/postIntegration.js'; import { deletePublicIntegration } from './controllers/integrations/uniqueKey/deleteIntegration.js'; @@ -227,6 +230,11 @@ publicAPI.route('/connect/session').get(connectSessionAuth, getConnectSession); publicAPI.route('/connect/session').delete(connectSessionAuth, deleteConnectSession); publicAPI.route('/connect/telemetry').post(connectSessionAuthBody, postConnectTelemetry); +publicAPI.use('/remote-function', jsonContentTypeMiddleware); +publicAPI.route('/remote-function/compile').post(apiAuth, postRemoteFunctionCompile); +publicAPI.route('/remote-function/dryrun').post(apiAuth, postRemoteFunctionDryrun); +publicAPI.route('/remote-function/deploy').post(apiAuth, postRemoteFunctionDeploy); + publicAPI.use('/v1', jsonContentTypeMiddleware); publicAPI.route('/v1/*splat').all(apiAuth, allPublicV1); diff --git a/packages/server/lib/services/agent/agent-runtime.ts b/packages/server/lib/services/agent/agent-runtime.ts new file mode 100644 index 00000000000..2773537452b --- /dev/null +++ b/packages/server/lib/services/agent/agent-runtime.ts @@ -0,0 +1,66 @@ +import type { OpencodeClient } from '@opencode-ai/sdk/v2'; + +export const agentProjectPath = '/home/user/nango-integrations'; + +export const AGENT_MODEL = { providerID: 'opencode', modelID: 'kimi-k2.5', full: 'opencode/kimi-k2.5' } as const; + +export interface AgentSessionPayload { + prompt: string; + integration_id: string; + connection_id?: string; +} + +export interface AgentSessionResolvedPayload extends AgentSessionPayload { + nango_base_url: string; +} + +export function resolvePayload(payload: AgentSessionPayload): AgentSessionResolvedPayload { + const serverUrl = process.env['NANGO_SERVER_URL']; + return { + ...payload, + nango_base_url: serverUrl ?? 'https://api.nango.dev' + }; +} + +export interface AgentRuntimeHandle { + client: OpencodeClient; + baseUrl: string; + accessToken: string | undefined; + sandboxId: string; + serverPid: number; + resolvedPayload: AgentSessionResolvedPayload; + /** E2B: extend sandbox lifetime. Omitted for local Docker. */ + refreshTimeout?: (timeoutMs: number) => Promise; + destroy(): Promise; +} + +export function createRuntimeConfig(): Record { + const apiKey = process.env['OPENCODE_API_KEY']; + return { + model: AGENT_MODEL.full, + small_model: AGENT_MODEL.full, + enabled_providers: [AGENT_MODEL.providerID], + permission: { + '*': 'allow', + external_directory: { '/**': 'allow' } + }, + ...(apiKey ? { provider: { [AGENT_MODEL.providerID]: { options: { apiKey } } } } : {}) + }; +} + +export function createAgentPrompt(payload: AgentSessionResolvedPayload): string { + const { prompt, integration_id, connection_id, nango_base_url } = payload; + const context: Record = { integration_id, nango_base_url }; + if (connection_id) { + context['connection_id'] = connection_id; + } + return `${prompt}\n\nYou are working inside a prepared Nango project at ${agentProjectPath}. Use the installed skill named building-nango-functions-locally from .agents/skills when relevant.\n\nContext:\n${JSON.stringify(context, null, 2)}`; +} + +export function createAnswerPrompt(answer: string): string { + return `User response: ${answer}\n\nContinue from the current session state.`; +} + +export function createSessionTitle(payload: AgentSessionResolvedPayload): string { + return `Build ${payload.integration_id}`; +} diff --git a/packages/server/lib/services/agent/agent-session.service.ts b/packages/server/lib/services/agent/agent-session.service.ts new file mode 100644 index 00000000000..2bb640e463a --- /dev/null +++ b/packages/server/lib/services/agent/agent-session.service.ts @@ -0,0 +1,477 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; + +import { getKVStore } from '@nangohq/kvstore'; +import { getLogger } from '@nangohq/utils'; + +import { AGENT_MODEL, createAgentPrompt, createAnswerPrompt, createSessionTitle, resolvePayload } from './agent-runtime.js'; +import { agentSandboxTimeoutMs, createAgentSandbox, refreshAgentSandboxTimeout, shouldRefreshAgentSandboxTimeout } from '../e2b/agent-sandbox.service.js'; +import { createLocalAgentSandbox } from '../local/agent-sandbox.service.js'; + +import type { AgentRuntimeHandle, AgentSessionPayload, AgentSessionResolvedPayload } from './agent-runtime.js'; +import type { GlobalEvent, Message, PermissionRequest, QuestionRequest } from '@opencode-ai/sdk/v2'; + +const logger = getLogger('agent-session-service'); + +const sessionTtlMs = 30 * 60 * 1000; // 30 min per spec +const idleSessionCleanupDelayMs = 5 * 60 * 1000; +const errorSessionCleanupDelayMs = 15_000; + +function sessionKey(token: string): string { + return `agent:session:${token}`; +} + +function generateSessionToken(): string { + return `agt_${randomBytes(32).toString('hex')}`; +} + +interface AgentBrowserEvent { + id: number; + event: string; + data: Record; +} + +interface AgentSessionRecord { + sid: string; + token: string; + environmentId: number; + sandbox: AgentRuntimeHandle | null; + opencodeSessionId: string | null; + emitter: EventEmitter; + backlog: AgentBrowserEvent[]; + nextEventId: number; + pendingPermissions: Map; + pendingQuestionRequests: Map; + messageRoles: Map; + messageTexts: Map; + pendingQuestion: { id: string; message: string } | null; + cleanupTimer: NodeJS.Timeout | null; + lastSandboxRefreshAt: number; +} + +// In-memory store keyed by internal sid +const sessions = new Map(); + +function createSandbox(sessionId: string, payload: AgentSessionPayload, onProgress: (message: string) => void): Promise { + if (process.env['AGENT_RUNTIME'] === 'local') { + return createLocalAgentSandbox(sessionId, payload, onProgress); + } + return createAgentSandbox(sessionId, payload, onProgress); +} + +export interface CreateSessionResult { + token: string; + executionTimeoutAt: Date; +} + +export async function createAgentSession(environmentId: number, payload: AgentSessionPayload): Promise { + const token = generateSessionToken(); + const sid = randomUUID(); + + // Store token → {sid, environmentId} in Redis with TTL + const kv = await getKVStore(); + await kv.set(sessionKey(token), JSON.stringify({ sid, environmentId }), { ttlMs: sessionTtlMs }); + + const record: AgentSessionRecord = { + sid, + token, + environmentId, + sandbox: null, + opencodeSessionId: null, + emitter: new EventEmitter(), + backlog: [], + nextEventId: 1, + pendingPermissions: new Map(), + pendingQuestionRequests: new Map(), + messageRoles: new Map(), + messageTexts: new Map(), + pendingQuestion: null, + cleanupTimer: null, + lastSandboxRefreshAt: 0 + }; + + sessions.set(sid, record); + void setupSession(record, resolvePayload(payload)); + + return { + token, + executionTimeoutAt: new Date(Date.now() + agentSandboxTimeoutMs) + }; +} + +/** + * Validate a session token and return the session record. + * Returns null if the token is not found, expired, or the environment doesn't match. + */ +export async function getSessionByToken(token: string, environmentId: number): Promise { + const kv = await getKVStore(); + const raw = await kv.get(sessionKey(token)); + if (!raw) { + return null; + } + + let entry: { sid: string; environmentId: number }; + try { + entry = JSON.parse(raw) as { sid: string; environmentId: number }; + } catch { + return null; + } + + if (entry.environmentId !== environmentId) { + return null; + } + + const record = sessions.get(entry.sid) || null; + + // Refresh TTL on activity + if (record) { + await kv.set(sessionKey(token), raw, { ttlMs: sessionTtlMs }); + } + + return record; +} + +export function subscribeToSession( + record: AgentSessionRecord, + listener: (event: AgentBrowserEvent) => void +): { backlog: AgentBrowserEvent[]; unsubscribe: () => void } { + record.emitter.on('event', listener); + return { + backlog: [...record.backlog], + unsubscribe: () => record.emitter.off('event', listener) + }; +} + +export async function answerSession( + record: AgentSessionRecord, + answer: { question_id: string; response: string } +): Promise<{ ok: true; kind: 'message' | 'permission' }> { + if (!record.sandbox || !record.opencodeSessionId) { + throw new Error('Agent session is still initializing'); + } + + const { question_id, response } = answer; + + // Permission decision + const permissionId = resolvePermissionId(record, question_id); + if (permissionId) { + const reply = response as 'once' | 'always' | 'reject'; + await record.sandbox.client.permission.reply({ requestID: permissionId, reply }); + record.pendingPermissions.delete(permissionId); + emit(record, 'agent.permission.submitted', { sid: record.sid, permissionId, response }); + return { ok: true, kind: 'permission' }; + } + + // Question answer (v2: client.question.reply / reject) + const questionReq = record.pendingQuestionRequests.get(question_id); + if (questionReq) { + record.pendingQuestionRequests.delete(question_id); + record.pendingQuestion = null; + clearCleanup(record); + touchSandbox(record, true); + + if (response === 'reject') { + await record.sandbox.client.question.reject({ requestID: question_id }); + } else { + if (!response.trim()) { + throw new Error('Response cannot be empty'); + } + // answers is Array, one per QuestionInfo in the request + const answers = questionReq.questions.map(() => [response.trim()]); + await record.sandbox.client.question.reply({ requestID: question_id, answers }); + } + emit(record, 'agent.answer.accepted', { sid: record.sid }); + return { ok: true, kind: 'message' }; + } + + // Fallback: free-form message to session (no pending question or permission matched) + if (!response.trim()) { + throw new Error('Response cannot be empty'); + } + + record.pendingQuestion = null; + clearCleanup(record); + touchSandbox(record, true); + + await record.sandbox.client.session.promptAsync({ + sessionID: record.opencodeSessionId, + model: { providerID: AGENT_MODEL.providerID, modelID: AGENT_MODEL.modelID }, + parts: [{ type: 'text', text: createAnswerPrompt(response.trim()) }] + }); + emit(record, 'agent.answer.accepted', { sid: record.sid }); + return { ok: true, kind: 'message' }; +} + +// ------- +// Internal session lifecycle +// ------- + +async function setupSession(record: AgentSessionRecord, payload: AgentSessionResolvedPayload): Promise { + const { sid } = record; + + try { + emitLifecycle(record, 'workspace.creating', 'Spinning up workspace...'); + + const sandbox = await createSandbox(sid, payload, (message) => { + emitLifecycle(record, 'workspace.progress', message); + }); + + record.sandbox = sandbox; + record.lastSandboxRefreshAt = Date.now(); + + emitLifecycle(record, 'workspace.ready', 'Workspace ready'); + emitLifecycle(record, 'agent.starting', 'Starting agent...'); + + const created = await sandbox.client.session.create({ title: createSessionTitle(sandbox.resolvedPayload) }); + const session = created.data; + if (!session) { + throw new Error('OpenCode did not return a session'); + } + + record.opencodeSessionId = session.id; + + await startEventPump(record); + + emit(record, 'agent.session.started', { + sid, + sandboxId: sandbox.sandboxId, + sessionId: session.id, + previewUrl: sandbox.baseUrl + }); + + emitLifecycle(record, 'agent.ready', 'Agent ready, sending task...'); + + await sandbox.client.session.promptAsync({ + sessionID: session.id, + model: { providerID: AGENT_MODEL.providerID, modelID: AGENT_MODEL.modelID }, + parts: [{ type: 'text', text: createAgentPrompt(sandbox.resolvedPayload) }] + }); + + emit(record, 'agent.prompt.accepted', { sid, sessionId: session.id }); + } catch (err) { + logger.error('Failed to set up agent session', { sid, error: err }); + if (record.sandbox) { + await record.sandbox.destroy(); + } + emit(record, 'agent.error', { + sid, + message: err instanceof Error ? err.message : String(err) + }); + sessions.delete(sid); + } +} + +async function startEventPump(record: AgentSessionRecord): Promise { + if (!record.sandbox) { + return; + } + const subscription = await record.sandbox.client.global.event(); + void (async () => { + try { + for await (const event of subscription.stream) { + handleOpenCodeEvent(record, event); + } + } catch (err) { + logger.error('OpenCode event stream failed', { sid: record.sid, error: err }); + emit(record, 'agent.error', { + sid: record.sid, + message: err instanceof Error ? err.message : String(err) + }); + } + })(); +} + +function handleOpenCodeEvent(record: AgentSessionRecord, globalEvent: GlobalEvent): void { + const event = globalEvent.payload; + const sessionId = record.opencodeSessionId; + if (!sessionId) { + return; + } + + touchSandbox(record); + + switch (event.type) { + case 'message.part.delta': { + const { sessionID, messageID, field, delta } = event.properties; + if (sessionID !== sessionId || field !== 'text') { + return; + } + const role = record.messageRoles.get(messageID); + if (role && role !== 'assistant') { + return; + } + emit(record, 'agent.delta', { sid: record.sid, sessionId, messageId: messageID, delta }); + return; + } + case 'message.part.updated': { + const part = event.properties.part; + if (part.sessionID !== sessionId || part.type !== 'tool') { + return; + } + emit(record, 'agent.tool.updated', { + sid: record.sid, + sessionId, + messageId: part.messageID, + tool: part.tool, + state: part.state + }); + return; + } + case 'message.updated': { + const info = event.properties.info; + if (info.sessionID !== sessionId) { + return; + } + record.messageRoles.set(info.id, info.role); + emit(record, 'agent.message.updated', { sid: record.sid, sessionId, message: info }); + return; + } + case 'question.asked': { + const req = event.properties; + if (req.sessionID !== sessionId) { + return; + } + record.pendingQuestionRequests.set(req.id, req); + const firstQuestion = req.questions[0]; + if (firstQuestion) { + record.pendingQuestion = { id: req.id, message: firstQuestion.question }; + const options = firstQuestion.options?.map((o) => o.label) ?? []; + emit(record, 'agent.question', { + sid: record.sid, + sessionId, + questionId: req.id, + message: firstQuestion.question, + ...(options.length > 0 ? { options } : {}) + }); + clearCleanup(record); + } + return; + } + case 'permission.asked': { + const permission = event.properties; + if (permission.sessionID !== sessionId) { + return; + } + record.pendingPermissions.set(permission.id, permission); + emit(record, 'agent.permission.requested', { sid: record.sid, sessionId, permission }); + return; + } + case 'permission.replied': { + if (event.properties.sessionID !== sessionId) { + return; + } + record.pendingPermissions.delete(event.properties.requestID); + emit(record, 'agent.permission.replied', { + sid: record.sid, + sessionId, + permissionId: event.properties.requestID, + response: event.properties.reply + }); + return; + } + case 'session.status': { + if (event.properties.sessionID !== sessionId) { + return; + } + emit(record, 'agent.session.status', { sid: record.sid, sessionId, status: event.properties.status }); + return; + } + case 'session.idle': { + if (event.properties.sessionID !== sessionId) { + return; + } + + // If there's already a pending question (from the `question` tool), don't schedule cleanup + if (record.pendingQuestion) { + return; + } + + emit(record, 'agent.session.idle', { sid: record.sid, sessionId }); + scheduleCleanup(record, 'idle'); + return; + } + case 'session.error': { + if (event.properties.sessionID && event.properties.sessionID !== sessionId) { + return; + } + emit(record, 'agent.error', { sid: record.sid, sessionId, error: event.properties.error }); + scheduleCleanup(record, 'error'); + return; + } + default: + return; + } +} + +function emitLifecycle(record: AgentSessionRecord, stage: string, message: string): void { + emit(record, 'agent.lifecycle', { sid: record.sid, stage, message }); +} + +function emit(record: AgentSessionRecord, event: string, data: Record): void { + const payload: AgentBrowserEvent = { id: record.nextEventId, event, data }; + record.nextEventId += 1; + record.backlog.push(payload); + if (record.backlog.length > 200) { + record.backlog.shift(); + } + record.emitter.emit('event', payload); +} + +function clearCleanup(record: AgentSessionRecord): void { + if (record.cleanupTimer) { + clearTimeout(record.cleanupTimer); + record.cleanupTimer = null; + } +} + +function scheduleCleanup(record: AgentSessionRecord, reason: 'idle' | 'error'): void { + if (record.pendingQuestion || record.pendingPermissions.size > 0 || record.cleanupTimer) { + return; + } + + touchSandbox(record, true); + + record.cleanupTimer = setTimeout( + () => { + void cleanupSession(record.sid, reason); + }, + reason === 'idle' ? idleSessionCleanupDelayMs : errorSessionCleanupDelayMs + ); +} + +async function cleanupSession(sid: string, reason: 'idle' | 'error'): Promise { + const record = sessions.get(sid); + if (!record) { + return; + } + + clearCleanup(record); + sessions.delete(sid); + + if (record.sandbox) { + await record.sandbox.destroy(); + logger.info('Cleaned up agent sandbox', { sid, sandboxId: record.sandbox.sandboxId, reason }); + } +} + +function touchSandbox(record: AgentSessionRecord, force: boolean = false): void { + if (!record.sandbox) { + return; + } + const now = Date.now(); + if (!force && !shouldRefreshAgentSandboxTimeout(record.lastSandboxRefreshAt, now)) { + return; + } + record.lastSandboxRefreshAt = now; + void refreshAgentSandboxTimeout(record.sandbox, agentSandboxTimeoutMs); +} + +function resolvePermissionId(record: AgentSessionRecord, questionId: string): string | null { + if (record.pendingPermissions.has(questionId)) { + return questionId; + } + if (record.pendingPermissions.size === 1 && !record.pendingQuestionRequests.has(questionId)) { + return [...record.pendingPermissions.keys()][0] as string; + } + return null; +} diff --git a/packages/server/lib/services/e2b/agent-sandbox.service.ts b/packages/server/lib/services/e2b/agent-sandbox.service.ts new file mode 100644 index 00000000000..6e3445c0056 --- /dev/null +++ b/packages/server/lib/services/e2b/agent-sandbox.service.ts @@ -0,0 +1,111 @@ +import { createOpencodeClient } from '@opencode-ai/sdk/v2'; +import { Sandbox } from 'e2b'; + +import { getLogger } from '@nangohq/utils'; + +import { AGENT_MODEL, agentProjectPath, createAgentPrompt, createRuntimeConfig, resolvePayload } from '../agent/agent-runtime.js'; + +import type { AgentRuntimeHandle, AgentSessionPayload, AgentSessionResolvedPayload } from '../agent/agent-runtime.js'; + +export type { AgentRuntimeHandle }; + +const logger = getLogger('e2b-agent-sandbox'); + +const opencodePort = 4096; +export const agentSandboxTimeoutMs = 10 * 60 * 1000; // 10 minutes per spec +const timeoutRefreshThrottleMs = 60 * 1000; +const agentTemplate = process.env['SANDBOX_AGENT_TEMPLATE'] || 'nango-opencode-agent'; + +export async function createAgentSandbox(sessionId: string, payload: AgentSessionPayload, onProgress?: (message: string) => void): Promise { + if (!process.env['SANDBOX_API_KEY']) { + throw new Error('SANDBOX_API_KEY is required for the E2B agent runtime'); + } + + const resolvedPayload: AgentSessionResolvedPayload = resolvePayload(payload); + onProgress?.('Creating sandbox...'); + const sandbox = await Sandbox.create(agentTemplate, { + timeoutMs: agentSandboxTimeoutMs, + allowInternetAccess: true, + metadata: { purpose: 'nango-agent', sessionId, createdBy: 'nango-server' }, + network: { allowPublicTraffic: true }, + apiKey: process.env['SANDBOX_API_KEY'] + }); + + await sandbox.files.write(`${agentProjectPath}/opencode.json`, JSON.stringify(createRuntimeConfig(), null, 2)); + + const accessToken = sandbox.trafficAccessToken; + const baseUrl = `https://${sandbox.getHost(opencodePort)}`; + onProgress?.('Starting OpenCode server...'); + const serverHandle = await sandbox.commands.run(`opencode serve --hostname 0.0.0.0 --port ${opencodePort}`, { + cwd: agentProjectPath, + envs: { OPENCODE_API_KEY: process.env['OPENCODE_API_KEY'] ?? '' }, + background: true, + timeoutMs: 0 + }); + + const client = createOpencodeClient({ + baseUrl, + directory: agentProjectPath, + headers: accessToken ? { 'e2b-traffic-access-token': accessToken } : undefined, + fetch: async (input, init) => { + if (!accessToken) { + return fetch(input, init); + } + const merged = new Headers(input instanceof Request ? input.headers : init?.headers); + merged.set('e2b-traffic-access-token', accessToken); + const reqInit = { ...init, headers: merged }; + return fetch(input instanceof Request ? new Request(input, reqInit) : input, reqInit); + } + }); + + await waitForOpenCodeServer(baseUrl, accessToken, serverHandle); + + return { + client, + baseUrl, + accessToken, + sandboxId: sandbox.sandboxId, + serverPid: serverHandle.pid, + resolvedPayload, + refreshTimeout: async (timeoutMs: number) => { + await sandbox.setTimeout(timeoutMs).catch((err: unknown) => { + logger.warn('Failed to refresh E2B agent sandbox timeout', { error: err, sandboxId: sandbox.sandboxId, timeoutMs }); + }); + }, + destroy: async () => { + await sandbox.kill().catch((err: unknown) => { + logger.warn('Failed to kill E2B agent sandbox', { error: err }); + }); + } + }; +} + +export async function refreshAgentSandboxTimeout(handle: AgentRuntimeHandle, timeoutMs: number = agentSandboxTimeoutMs): Promise { + await handle.refreshTimeout?.(timeoutMs); +} + +export function shouldRefreshAgentSandboxTimeout(lastRefreshAt: number, now: number = Date.now()): boolean { + return now - lastRefreshAt >= timeoutRefreshThrottleMs; +} + +async function waitForOpenCodeServer(baseUrl: string, accessToken: string | undefined, serverHandle: { stdout: string; stderr: string }): Promise { + const maxAttempts = 40; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const init = accessToken ? { headers: { 'e2b-traffic-access-token': accessToken } } : {}; + const response = await fetch(`${baseUrl}/global/health`, init); + if (response.ok) { + return; + } + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + const logs = [serverHandle.stdout, serverHandle.stderr].filter(Boolean).join('\n'); + throw new Error(`Timed out waiting for OpenCode server to start in E2B sandbox${logs ? `: ${logs}` : ''}`); +} + +// Re-exported for use in session service +export { AGENT_MODEL, createAgentPrompt }; diff --git a/packages/server/lib/services/local/agent-sandbox.service.ts b/packages/server/lib/services/local/agent-sandbox.service.ts new file mode 100644 index 00000000000..f904c38ff80 --- /dev/null +++ b/packages/server/lib/services/local/agent-sandbox.service.ts @@ -0,0 +1,131 @@ +import { execFile, spawn } from 'node:child_process'; +import { createServer } from 'node:net'; +import { promisify } from 'node:util'; + +import { createOpencodeClient } from '@opencode-ai/sdk/v2'; + +import { getLogger } from '@nangohq/utils'; + +import { agentProjectPath, createRuntimeConfig, resolvePayload } from '../agent/agent-runtime.js'; + +import type { AgentRuntimeHandle, AgentSessionPayload, AgentSessionResolvedPayload } from '../agent/agent-runtime.js'; + +const execFileAsync = promisify(execFile); +const logger = getLogger('local-agent-sandbox'); + +const opencodePort = 4096; +export const localAgentImageName = process.env['LOCAL_AGENT_IMAGE'] || 'nango-local-agent'; + +export async function createLocalAgentSandbox( + sessionId: string, + payload: AgentSessionPayload, + onProgress?: (message: string) => void +): Promise { + const resolvedPayload: AgentSessionResolvedPayload = rewriteLocalhostUrl(resolvePayload(payload)); + const hostPort = await findFreePort(); + const containerName = `nango-agent-${sessionId.slice(0, 8)}`; + + onProgress?.('Starting container...'); + await execFileAsync('docker', [ + 'run', + '-d', + '--name', + containerName, + '-p', + `${hostPort}:${opencodePort}`, + '-e', + `OPENCODE_API_KEY=${process.env['OPENCODE_API_KEY'] ?? ''}`, + localAgentImageName + ]); + + await writeFileToContainer(containerName, `${agentProjectPath}/opencode.json`, JSON.stringify(createRuntimeConfig(), null, 2)); + + onProgress?.('Starting OpenCode server...'); + await execFileAsync('docker', [ + 'exec', + '-d', + '-w', + agentProjectPath, + containerName, + 'bash', + '-c', + `opencode serve --hostname 0.0.0.0 --port ${opencodePort} > /tmp/opencode.log 2>&1` + ]); + + const baseUrl = `http://localhost:${hostPort}`; + const client = createOpencodeClient({ baseUrl, directory: agentProjectPath }); + + await waitForOpenCodeServer(baseUrl, containerName); + + return { + client, + baseUrl, + accessToken: undefined, + sandboxId: containerName, + serverPid: 0, + resolvedPayload, + destroy: async () => { + await execFileAsync('docker', ['rm', '-f', containerName]).catch((err: unknown) => { + logger.warn('Failed to remove local agent container', { containerName, error: err }); + }); + } + }; +} + +async function writeFileToContainer(containerName: string, filePath: string, content: string): Promise { + await new Promise((resolve, reject) => { + const proc = spawn('docker', ['exec', '-i', containerName, 'bash', '-c', `cat > ${filePath}`]); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`docker exec write exited with code ${code}`)))); + proc.on('error', reject); + }); +} + +async function waitForOpenCodeServer(baseUrl: string, containerName: string): Promise { + const maxAttempts = 40; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await fetch(`${baseUrl}/global/health`); + if (response.ok) { + return; + } + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + const logs = await execFileAsync('docker', ['exec', containerName, 'cat', '/tmp/opencode.log']) + .then((r) => r.stdout) + .catch(() => ''); + throw new Error(`Timed out waiting for OpenCode server to start in local container${logs ? `: ${logs}` : ''}`); +} + +// Inside Docker, localhost/127.0.0.1 refers to the container itself, update to use host.docker.internal +function rewriteLocalhostUrl(payload: AgentSessionResolvedPayload): AgentSessionResolvedPayload { + return { + ...payload, + nango_base_url: payload.nango_base_url.replace( + /https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/g, + (_, _host, port) => `http://host.docker.internal${port ?? ''}` + ) + }; +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + server.close(() => { + if (addr && typeof addr === 'object') { + resolve(addr.port); + } else { + reject(new Error('Failed to find a free port')); + } + }); + }); + server.on('error', reject); + }); +} diff --git a/packages/server/lib/services/local/compiler-client.ts b/packages/server/lib/services/local/compiler-client.ts new file mode 100644 index 00000000000..69bcb9bbd57 --- /dev/null +++ b/packages/server/lib/services/local/compiler-client.ts @@ -0,0 +1,69 @@ +import { execFile, spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { promisify } from 'node:util'; + +import { agentProjectPath } from '../agent/agent-runtime.js'; +import { CompilerError, buildFlowConfig, buildIndexTs, buildNangoYaml, getFilePaths } from '../remote-function/compiler-client.js'; + +import type { CompileRequest, CompileResult } from '../remote-function/compiler-client.js'; + +const execFileAsync = promisify(execFile); + +const localCompilerImage = process.env['LOCAL_COMPILER_IMAGE'] || 'nango-local-compiler'; +const compilerTimeoutMs = 3 * 60 * 1000; + +export async function invokeLocalCompiler(request: CompileRequest): Promise { + const containerName = `nango-compiler-${randomUUID().slice(0, 8)}`; + + try { + await execFileAsync('docker', ['run', '-d', '--name', containerName, localCompilerImage, 'sleep', '300'], { + timeout: 10_000 + }); + + const { tsFilePath, cjsFilePath } = getFilePaths(request); + + await writeContainerFile(containerName, `${agentProjectPath}/${tsFilePath}`, request.code); + await writeContainerFile(containerName, `${agentProjectPath}/index.ts`, buildIndexTs(request)); + await writeContainerFile(containerName, `${agentProjectPath}/nango.yaml`, buildNangoYaml(request)); + + try { + await execFileAsync('docker', ['exec', '-w', agentProjectPath, '-e', 'NO_COLOR=1', containerName, 'nango', 'compile'], { + timeout: compilerTimeoutMs + }); + } catch (err) { + throw new CompilerError(err instanceof Error ? err.message : String(err), 'compilation'); + } + + const [bundledJs, yamlContent] = await Promise.all([ + readContainerFile(containerName, `${agentProjectPath}/${cjsFilePath}`), + readContainerFile(containerName, `${agentProjectPath}/nango.yaml`) + ]); + + const flow = await buildFlowConfig(yamlContent, request, bundledJs); + return { + bundledJs, + bundleSizeBytes: Buffer.byteLength(bundledJs, 'utf8'), + flow + }; + } finally { + await execFileAsync('docker', ['rm', '-f', containerName]).catch(() => {}); + } +} + +async function writeContainerFile(containerName: string, filePath: string, content: string): Promise { + const dir = filePath.substring(0, filePath.lastIndexOf('/')); + await execFileAsync('docker', ['exec', containerName, 'mkdir', '-p', dir]); + + await new Promise((resolve, reject) => { + const proc = spawn('docker', ['exec', '-i', containerName, 'bash', '-c', `cat > ${filePath}`]); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`docker exec write exited with code ${code}`)))); + proc.on('error', reject); + }); +} + +async function readContainerFile(containerName: string, filePath: string): Promise { + const { stdout } = await execFileAsync('docker', ['exec', containerName, 'cat', filePath]); + return stdout; +} diff --git a/packages/server/lib/services/local/deploy-client.ts b/packages/server/lib/services/local/deploy-client.ts new file mode 100644 index 00000000000..6797abb810a --- /dev/null +++ b/packages/server/lib/services/local/deploy-client.ts @@ -0,0 +1,85 @@ +import { execFile, spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { promisify } from 'node:util'; + +import { agentProjectPath } from '../agent/agent-runtime.js'; +import { buildIndexTs, buildNangoYaml, getFilePaths } from '../remote-function/compiler-client.js'; + +import type { DeployRequest, DeployResult } from '../remote-function/deploy-client.js'; + +const execFileAsync = promisify(execFile); + +const localCompilerImage = process.env['LOCAL_COMPILER_IMAGE'] || 'nango-local-compiler'; +const deployTimeoutMs = 5 * 60 * 1000; + +export async function invokeLocalDeploy(request: DeployRequest): Promise { + const containerName = `nango-deploy-${randomUUID().slice(0, 8)}`; + + const nangoHost = request.nango_host.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, (_, _h, port) => `http://host.docker.internal${port ?? ''}`); + + try { + await execFileAsync( + 'docker', + [ + 'run', + '-d', + '--name', + containerName, + '-e', + `NANGO_SECRET_KEY=${request.nango_secret_key}`, + '-e', + `NANGO_HOSTPORT=${nangoHost}`, + '-e', + 'NO_COLOR=1', + '-e', + 'NANGO_DEPLOY_AUTO_CONFIRM=true', + '--add-host', + 'host.docker.internal:host-gateway', + localCompilerImage, + 'sleep', + '300' + ], + { timeout: 10_000 } + ); + + const { tsFilePath } = getFilePaths(request); + + await writeContainerFile(containerName, `${agentProjectPath}/${tsFilePath}`, request.code); + await writeContainerFile(containerName, `${agentProjectPath}/index.ts`, buildIndexTs(request)); + await writeContainerFile(containerName, `${agentProjectPath}/nango.yaml`, buildNangoYaml(request)); + + const cmd = buildDeployCommand(request); + + let output: string; + try { + const { stdout, stderr } = await execFileAsync('docker', ['exec', '-w', agentProjectPath, containerName, 'bash', '-c', cmd], { + timeout: deployTimeoutMs + }); + output = stdout || stderr; + } catch (err) { + output = err instanceof Error ? err.message : String(err); + } + + return { output }; + } finally { + await execFileAsync('docker', ['rm', '-f', containerName]).catch(() => {}); + } +} + +function buildDeployCommand(request: DeployRequest): string { + const typeFlag = request.function_type === 'action' ? `--action ${request.function_name}` : `--sync ${request.function_name}`; + return `nango deploy ${typeFlag} --auto-confirm --allow-destructive`; +} + +async function writeContainerFile(containerName: string, filePath: string, content: string): Promise { + const dir = filePath.substring(0, filePath.lastIndexOf('/')); + await execFileAsync('docker', ['exec', containerName, 'mkdir', '-p', dir]); + + await new Promise((resolve, reject) => { + const proc = spawn('docker', ['exec', '-i', containerName, 'bash', '-c', `cat > ${filePath}`]); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`docker exec write exited with code ${code}`)))); + proc.on('error', reject); + }); +} diff --git a/packages/server/lib/services/local/dryrun-client.ts b/packages/server/lib/services/local/dryrun-client.ts new file mode 100644 index 00000000000..fb5bc428e98 --- /dev/null +++ b/packages/server/lib/services/local/dryrun-client.ts @@ -0,0 +1,119 @@ +import { execFile, spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { promisify } from 'node:util'; + +import { agentProjectPath } from '../agent/agent-runtime.js'; +import { buildIndexTs, buildNangoYaml, getFilePaths } from '../remote-function/compiler-client.js'; + +import type { DryrunRequest, DryrunResult } from '../remote-function/dryrun-client.js'; + +const execFileAsync = promisify(execFile); + +const localCompilerImage = process.env['LOCAL_COMPILER_IMAGE'] || 'nango-local-compiler'; +const compileTimeoutMs = 3 * 60 * 1000; +const dryrunTimeoutMs = 5 * 60 * 1000; + +export async function invokeLocalDryrun(request: DryrunRequest): Promise { + const containerName = `nango-dryrun-${randomUUID().slice(0, 8)}`; + + // Rewrite localhost so the container can reach the host Nango server + const nangoHost = request.nango_host.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, (_, _h, port) => `http://host.docker.internal${port ?? ''}`); + + try { + await execFileAsync( + 'docker', + [ + 'run', + '-d', + '--name', + containerName, + '-e', + `NANGO_SECRET_KEY=${request.nango_secret_key}`, + '-e', + `NANGO_HOSTPORT=${nangoHost}`, + '-e', + 'NO_COLOR=1', + '--add-host', + 'host.docker.internal:host-gateway', + localCompilerImage, + 'sleep', + '300' + ], + { timeout: 10_000 } + ); + + const { tsFilePath } = getFilePaths(request); + + await writeContainerFile(containerName, `${agentProjectPath}/${tsFilePath}`, request.code); + await writeContainerFile(containerName, `${agentProjectPath}/index.ts`, buildIndexTs(request)); + await writeContainerFile(containerName, `${agentProjectPath}/nango.yaml`, buildNangoYaml(request)); + + // Compile first + try { + await execFileAsync('docker', ['exec', '-w', agentProjectPath, '-e', 'NO_COLOR=1', containerName, 'nango', 'compile'], { + timeout: compileTimeoutMs + }); + } catch (err) { + return { output: err instanceof Error ? err.message : String(err) }; + } + + // Write optional JSON arg files + if (request.input !== undefined) { + await writeContainerFile(containerName, '/tmp/nango-dryrun-input.json', JSON.stringify(request.input)); + } + if (request.metadata) { + await writeContainerFile(containerName, '/tmp/nango-dryrun-metadata.json', JSON.stringify(request.metadata)); + } + if (request.checkpoint) { + await writeContainerFile(containerName, '/tmp/nango-dryrun-checkpoint.json', JSON.stringify(request.checkpoint)); + } + + const cmd = buildDryrunCommand(request); + + let output: string; + try { + const { stdout, stderr } = await execFileAsync('docker', ['exec', '-w', agentProjectPath, containerName, 'bash', '-c', cmd], { + timeout: dryrunTimeoutMs + }); + output = stdout || stderr; + } catch (err) { + output = err instanceof Error ? err.message : String(err); + } + + return { output }; + } finally { + await execFileAsync('docker', ['rm', '-f', containerName]).catch(() => {}); + } +} + +function buildDryrunCommand(request: DryrunRequest): string { + const parts = ['nango', 'dryrun', request.function_name, request.connection_id, `--integration-id ${request.integration_id}`, '--auto-confirm']; + + if (request.input !== undefined) { + parts.push('--input @/tmp/nango-dryrun-input.json'); + } + if (request.metadata) { + parts.push('--metadata @/tmp/nango-dryrun-metadata.json'); + } + if (request.checkpoint) { + parts.push('--checkpoint @/tmp/nango-dryrun-checkpoint.json'); + } + if (request.last_sync_date) { + parts.push(`--lastSyncDate ${request.last_sync_date}`); + } + + return parts.join(' '); +} + +async function writeContainerFile(containerName: string, filePath: string, content: string): Promise { + const dir = filePath.substring(0, filePath.lastIndexOf('/')); + await execFileAsync('docker', ['exec', containerName, 'mkdir', '-p', dir]); + + await new Promise((resolve, reject) => { + const proc = spawn('docker', ['exec', '-i', containerName, 'bash', '-c', `cat > ${filePath}`]); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`docker exec write exited with code ${code}`)))); + proc.on('error', reject); + }); +} diff --git a/packages/server/lib/services/remote-function/compiler-client.ts b/packages/server/lib/services/remote-function/compiler-client.ts new file mode 100644 index 00000000000..3dcfdf1c11a --- /dev/null +++ b/packages/server/lib/services/remote-function/compiler-client.ts @@ -0,0 +1,190 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { CommandExitError, Sandbox } from 'e2b'; + +import { loadNangoYaml } from '@nangohq/nango-yaml'; +import { isLocal } from '@nangohq/utils'; + +import { agentProjectPath } from '../agent/agent-runtime.js'; +import { invokeLocalCompiler } from '../local/compiler-client.js'; + +import type { CLIDeployFlowConfig, ParsedNangoAction, ParsedNangoSync } from '@nangohq/types'; + +export interface CompileRequest { + integration_id: string; + function_name: string; + function_type: 'action' | 'sync'; + code: string; +} + +export interface CompileResult { + bundledJs: string; + bundleSizeBytes: number; + flow: CLIDeployFlowConfig; +} + +export class CompilerError extends Error { + public readonly step: 'validation' | 'compilation'; + + constructor(message: string, step: 'validation' | 'compilation', remoteStack?: string) { + super(message); + this.name = 'CompilerError'; + this.step = step; + if (remoteStack !== undefined) { + this.stack = remoteStack; + } + } +} + +const compilerTimeoutMs = 3 * 60 * 1000; + +export async function invokeCompiler(request: CompileRequest): Promise { + if (isLocal) { + return invokeLocalCompiler(request); + } + + if (!process.env['SANDBOX_API_KEY']) { + throw new Error('SANDBOX_API_KEY is required for the E2B compiler runtime'); + } + + const sandbox = await Sandbox.create(process.env['SANDBOX_COMPILER_TEMPLATE'] || 'nango-sf-compiler', { + timeoutMs: compilerTimeoutMs, + allowInternetAccess: true, + metadata: { purpose: 'nango-compiler', requestId: randomUUID() }, + network: { allowPublicTraffic: true } + }); + + try { + const { tsFilePath, cjsFilePath } = getFilePaths(request); + + await sandbox.files.write(path.join(agentProjectPath, tsFilePath), request.code); + await sandbox.files.write(path.join(agentProjectPath, 'index.ts'), buildIndexTs(request)); + await sandbox.files.write(path.join(agentProjectPath, 'nango.yaml'), buildNangoYaml(request)); + + try { + await sandbox.commands.run('nango compile', { + cwd: agentProjectPath, + timeoutMs: compilerTimeoutMs, + envs: { NO_COLOR: '1' } + }); + } catch (err) { + if (err instanceof CommandExitError) { + throw new CompilerError(err.stderr || err.stdout, 'compilation'); + } + throw err; + } + + const [bundledJs, yamlContent] = await Promise.all([ + sandbox.files.read(path.join(agentProjectPath, cjsFilePath)), + sandbox.files.read(path.join(agentProjectPath, 'nango.yaml')) + ]); + + const bundledJsStr = String(bundledJs); + const flow = await buildFlowConfig(String(yamlContent), request, bundledJsStr); + return { + bundledJs: bundledJsStr, + bundleSizeBytes: Buffer.byteLength(bundledJsStr, 'utf8'), + flow + }; + } finally { + await sandbox.kill().catch(() => {}); + } +} + +/** + * Returns the source TS path and compiled CJS path relative to the project root. + */ +export function getFilePaths(request: Pick): { + tsFilePath: string; + cjsFilePath: string; +} { + const folder = request.function_type === 'action' ? 'actions' : 'syncs'; + const tsFilePath = `${request.integration_id}/${folder}/${request.function_name}.ts`; + const cjsFilePath = `build/${request.integration_id}_${folder}_${request.function_name}.cjs`; + return { tsFilePath, cjsFilePath }; +} + +/** + * Minimal index.ts referencing a single entry point. + */ +export function buildIndexTs(request: Pick): string { + const folder = request.function_type === 'action' ? 'actions' : 'syncs'; + return `import './${request.integration_id}/${folder}/${request.function_name}.js';\n`; +} + +/** + * Minimal nango.yaml so that `nango compile` has a valid project definition. + * The agent is expected to have written a more complete yaml; this is a fallback + * for cases where the compile endpoint is called without one in the sandbox. + */ +export function buildNangoYaml(request: Pick): string { + if (request.function_type === 'action') { + return `integrations:\n - providerConfigKey: ${request.integration_id}\n actions:\n - name: ${request.function_name}\n`; + } + return `integrations:\n - providerConfigKey: ${request.integration_id}\n syncs:\n - name: ${request.function_name}\n runs: every 30min\n`; +} + +/** + * Parse nango.yaml content (from sandbox) and build the CLIDeployFlowConfig the deploy endpoint needs. + * Writes to a temp dir so loadNangoYaml can read it from disk. + */ +export async function buildFlowConfig(yamlContent: string, request: CompileRequest, bundledJs: string): Promise { + const tempDir = path.join(tmpdir(), `nango-compile-${randomUUID()}`); + try { + await mkdir(tempDir, { recursive: true }); + await writeFile(path.join(tempDir, 'nango.yaml'), yamlContent, 'utf8'); + + const parser = loadNangoYaml({ fullPath: tempDir }); + parser.parse(); + + const integration = parser.parsed?.integrations.find((i) => i.providerConfigKey === request.integration_id); + const scriptDef = + request.function_type === 'action' + ? integration?.actions.find((a) => a.name === request.function_name) + : integration?.syncs.find((s) => s.name === request.function_name); + + return buildFlowFromDef(scriptDef, request, bundledJs); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +export function buildFlowFromDef(scriptDef: ParsedNangoSync | ParsedNangoAction | undefined, request: CompileRequest, bundledJs: string): CLIDeployFlowConfig { + const base: CLIDeployFlowConfig = { + type: request.function_type, + syncName: request.function_name, + providerConfigKey: request.integration_id, + models: scriptDef?.output || [], + runs: null, + track_deletes: false, + attributes: {}, + fileBody: { js: bundledJs, ts: '' }, + endpoints: [], + input: scriptDef?.input ?? undefined, + models_json_schema: scriptDef?.json_schema, + features: scriptDef?.features + }; + + if (scriptDef?.type === 'sync') { + return { + ...base, + runs: scriptDef.runs || null, + track_deletes: scriptDef.track_deletes, + auto_start: scriptDef.auto_start, + endpoints: scriptDef.endpoints, + webhookSubscriptions: scriptDef.webhookSubscriptions + }; + } + + if (scriptDef?.type === 'action') { + return { + ...base, + endpoints: scriptDef.endpoint ? [scriptDef.endpoint] : [] + }; + } + + return base; +} diff --git a/packages/server/lib/services/remote-function/deploy-client.ts b/packages/server/lib/services/remote-function/deploy-client.ts new file mode 100644 index 00000000000..982549a7b2b --- /dev/null +++ b/packages/server/lib/services/remote-function/deploy-client.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; + +import { CommandExitError, Sandbox } from 'e2b'; + +import { isLocal } from '@nangohq/utils'; + +import { buildIndexTs, buildNangoYaml, getFilePaths } from './compiler-client.js'; +import { agentProjectPath } from '../agent/agent-runtime.js'; +import { invokeLocalDeploy } from '../local/deploy-client.js'; + +export interface DeployRequest { + integration_id: string; + function_name: string; + function_type: 'action' | 'sync'; + code: string; + nango_secret_key: string; + nango_host: string; +} + +export interface DeployResult { + output: string; +} + +const deployTimeoutMs = 5 * 60 * 1000; + +export async function invokeDeploy(request: DeployRequest): Promise { + if (isLocal) { + return invokeLocalDeploy(request); + } + + if (!process.env['SANDBOX_API_KEY']) { + throw new Error('SANDBOX_API_KEY is required for the E2B deploy runtime'); + } + + const sandbox = await Sandbox.create(process.env['SANDBOX_COMPILER_TEMPLATE'] || 'nango-sf-compiler', { + timeoutMs: deployTimeoutMs, + allowInternetAccess: true, + metadata: { purpose: 'nango-deploy', requestId: randomUUID() }, + network: { allowPublicTraffic: true } + }); + + try { + const { tsFilePath } = getFilePaths(request); + + await sandbox.files.write(`${agentProjectPath}/${tsFilePath}`, request.code); + await sandbox.files.write(`${agentProjectPath}/index.ts`, buildIndexTs(request)); + await sandbox.files.write(`${agentProjectPath}/nango.yaml`, buildNangoYaml(request)); + + const cmd = buildDeployCommand(request); + const envs = { + NO_COLOR: '1', + NANGO_SECRET_KEY: request.nango_secret_key, + NANGO_HOSTPORT: request.nango_host, + NANGO_DEPLOY_AUTO_CONFIRM: 'true' + }; + + let output: string; + try { + const result = await sandbox.commands.run(cmd, { + cwd: agentProjectPath, + timeoutMs: deployTimeoutMs, + envs + }); + output = result.stdout; + } catch (err) { + if (err instanceof CommandExitError) { + output = err.stdout || err.stderr || JSON.stringify(err); + } else { + throw err; + } + } + + return { output }; + } finally { + await sandbox.kill().catch(() => {}); + } +} + +function buildDeployCommand(request: DeployRequest): string { + const typeFlag = request.function_type === 'action' ? `--action ${request.function_name}` : `--sync ${request.function_name}`; + return `nango deploy ${typeFlag} --auto-confirm --allow-destructive`; +} diff --git a/packages/server/lib/services/remote-function/dryrun-client.ts b/packages/server/lib/services/remote-function/dryrun-client.ts new file mode 100644 index 00000000000..bb7eb1a8706 --- /dev/null +++ b/packages/server/lib/services/remote-function/dryrun-client.ts @@ -0,0 +1,126 @@ +import { randomUUID } from 'node:crypto'; + +import { CommandExitError, Sandbox } from 'e2b'; + +import { isLocal } from '@nangohq/utils'; + +import { buildIndexTs, buildNangoYaml, getFilePaths } from './compiler-client.js'; +import { agentProjectPath } from '../agent/agent-runtime.js'; +import { invokeLocalDryrun } from '../local/dryrun-client.js'; + +export interface DryrunRequest { + integration_id: string; + function_name: string; + function_type: 'action' | 'sync'; + code: string; + connection_id: string; + nango_secret_key: string; + nango_host: string; + input?: unknown; + metadata?: Record; + checkpoint?: Record; + last_sync_date?: string; +} + +export interface DryrunResult { + output: string; +} + +const compileTimeoutMs = 3 * 60 * 1000; +const dryrunTimeoutMs = 5 * 60 * 1000; + +export async function invokeDryrun(request: DryrunRequest): Promise { + if (isLocal) { + return invokeLocalDryrun(request); + } + + if (!process.env['SANDBOX_API_KEY']) { + throw new Error('SANDBOX_API_KEY is required for the E2B dryrun runtime'); + } + + const sandbox = await Sandbox.create(process.env['SANDBOX_COMPILER_TEMPLATE'] || 'nango-sf-compiler', { + timeoutMs: dryrunTimeoutMs, + allowInternetAccess: true, + metadata: { purpose: 'nango-dryrun', requestId: randomUUID() }, + network: { allowPublicTraffic: true } + }); + + try { + const { tsFilePath } = getFilePaths(request); + + await sandbox.files.write(`${agentProjectPath}/${tsFilePath}`, request.code); + await sandbox.files.write(`${agentProjectPath}/index.ts`, buildIndexTs(request)); + await sandbox.files.write(`${agentProjectPath}/nango.yaml`, buildNangoYaml(request)); + + // Compile first + try { + await sandbox.commands.run('nango compile', { + cwd: agentProjectPath, + timeoutMs: compileTimeoutMs, + envs: { NO_COLOR: '1' } + }); + } catch (err) { + if (err instanceof CommandExitError) { + return { output: err.stderr || err.stdout || 'Compilation failed' }; + } + throw err; + } + + // Write optional JSON arg files to avoid shell-quoting issues + if (request.input !== undefined) { + await sandbox.files.write('/tmp/nango-dryrun-input.json', JSON.stringify(request.input)); + } + if (request.metadata) { + await sandbox.files.write('/tmp/nango-dryrun-metadata.json', JSON.stringify(request.metadata)); + } + if (request.checkpoint) { + await sandbox.files.write('/tmp/nango-dryrun-checkpoint.json', JSON.stringify(request.checkpoint)); + } + + const cmd = buildDryrunCommand(request); + const envs = { + NO_COLOR: '1', + NANGO_SECRET_KEY: request.nango_secret_key, + NANGO_HOSTPORT: request.nango_host + }; + + let output: string; + try { + const result = await sandbox.commands.run(cmd, { + cwd: agentProjectPath, + timeoutMs: dryrunTimeoutMs, + envs + }); + output = result.stdout; + } catch (err) { + if (err instanceof CommandExitError) { + output = err.stdout || err.stderr || JSON.stringify(err); + } else { + throw err; + } + } + + return { output }; + } finally { + await sandbox.kill().catch(() => {}); + } +} + +function buildDryrunCommand(request: DryrunRequest): string { + const parts = ['nango', 'dryrun', request.function_name, request.connection_id, `--integration-id ${request.integration_id}`, '--auto-confirm']; + + if (request.input !== undefined) { + parts.push('--input @/tmp/nango-dryrun-input.json'); + } + if (request.metadata) { + parts.push('--metadata @/tmp/nango-dryrun-metadata.json'); + } + if (request.checkpoint) { + parts.push('--checkpoint @/tmp/nango-dryrun-checkpoint.json'); + } + if (request.last_sync_date) { + parts.push(`--lastSyncDate ${request.last_sync_date}`); + } + + return parts.join(' '); +} diff --git a/packages/server/lib/services/remote-function/helpers.ts b/packages/server/lib/services/remote-function/helpers.ts new file mode 100644 index 00000000000..58cb24c9a73 --- /dev/null +++ b/packages/server/lib/services/remote-function/helpers.ts @@ -0,0 +1,47 @@ +import { stringifyError } from '@nangohq/utils'; + +import type { FunctionErrorCode } from '@nangohq/types'; +import type { Response } from 'express'; + +export type RemoteFunctionStep = 'compilation' | 'deployment' | 'lookup' | 'execution'; + +export function sendStepError({ res, step: _step, error, status }: { res: Response; step: RemoteFunctionStep; error: unknown; status?: number }): void { + const normalized = normalizeError(error); + + res.status(status ?? normalized.status ?? 500).send({ + error: { + code: (normalized.code ?? 'server_error') as FunctionErrorCode, + message: normalized.message, + ...(normalized.payload !== undefined ? { payload: normalized.payload } : {}) + } + }); +} + +function normalizeError(error: unknown): { + message: string; + code?: string; + payload?: unknown; + status?: number; +} { + if (error && typeof error === 'object') { + const err = error as Record; + const message = typeof err['message'] === 'string' ? sanitizeMessage(err['message']) : sanitizeMessage(stringifyError(error)); + const code = typeof err['type'] === 'string' ? err['type'] : typeof err['code'] === 'string' ? err['code'] : undefined; + const payload = 'payload' in err ? err['payload'] : undefined; + const status = typeof err['status'] === 'number' ? err['status'] : undefined; + return { message, ...(code ? { code } : {}), ...(payload !== undefined ? { payload } : {}), ...(status ? { status } : {}) }; + } + return { message: sanitizeMessage(stringifyError(error)) }; +} + +/** + * Strip internal details from error messages before sending to the client. + * Removes: absolute paths, localhost/private URLs, env var names, stack traces. + */ +function sanitizeMessage(message: string): string { + return message + .replace(/\/[^\s"']+\/[^\s"']*/g, '') // absolute paths + .replace(/https?:\/\/(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+)(:\d+)?[^\s]*/g, '') // internal URLs + .replace(/\b[A-Z][A-Z0-9_]{4,}\b/g, (m) => (process.env[m] !== undefined ? '' : m)) // env var names that exist + .slice(0, 500); // cap length +} diff --git a/packages/server/package.json b/packages/server/package.json index 46b48ec67fa..98a69c6c1d4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,8 +20,8 @@ "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY", "dependencies": { "@modelcontextprotocol/sdk": "1.26.0", - "@nangohq/authz": "file:../authz", "@nangohq/account-usage": "file:../account-usage", + "@nangohq/authz": "file:../authz", "@nangohq/billing": "file:../billing", "@nangohq/database": "file:../database", "@nangohq/email": "file:../email", @@ -31,12 +31,13 @@ "@nangohq/logs": "file:../logs", "@nangohq/nango-orchestrator": "file:../orchestrator", "@nangohq/nango-yaml": "file:../nango-yaml", - "@nangohq/providers": "file:.../providers", + "@nangohq/providers": "file:../providers", + "@opencode-ai/sdk": "^1.3.13", + "@nangohq/pubsub": "file:../pubsub", "@nangohq/records": "file:../records", "@nangohq/shared": "file:../shared", "@nangohq/utils": "file:../utils", "@nangohq/webhooks": "file:../webhooks", - "@nangohq/pubsub": "file:../pubsub", "@workos-inc/node": "6.2.0", "axios": "1.13.5", "body-parser": "2.2.1", @@ -45,6 +46,7 @@ "cors": "2.8.5", "dd-trace": "5.52.0", "dotenv": "16.5.0", + "e2b": "2.18.0", "exponential-backoff": "3.1.1", "express": "5.1.0", "express-session": "1.18.2", diff --git a/packages/types/lib/agent/api.ts b/packages/types/lib/agent/api.ts new file mode 100644 index 00000000000..ab5839e88c7 --- /dev/null +++ b/packages/types/lib/agent/api.ts @@ -0,0 +1,90 @@ +import type { ApiError, Endpoint } from '../api.js'; + +export type PostAgentSessionStart = Endpoint<{ + Method: 'POST'; + Path: '/api/v1/agent/session/start'; + Body: { + prompt: string; + integration_id: string; + connection_id?: string | undefined; + }; + Error: ApiError<'invalid_request'> | ApiError<'server_error'>; + Success: { + session_token: string; + execution_timeout_at: string; + }; +}>; + +export type GetAgentSessionEvents = Endpoint<{ + Method: 'GET'; + Path: '/api/v1/agent/session/:sessionToken/events'; + Params: { sessionToken: string }; + Error: ApiError<'not_found'> | ApiError<'server_error'>; + // SSE streams events indefinitely; no structured JSON success body + Success: never; +}>; + +export type PostAgentSessionAnswer = Endpoint<{ + Method: 'POST'; + Path: '/api/v1/agent/session/:sessionToken/answer'; + Params: { sessionToken: string }; + Body: { + question_id: string; + /** For regular questions: any string. For permissions: "once" | "always" | "reject" */ + response: string; + }; + Error: ApiError<'invalid_request'> | ApiError<'not_found'> | ApiError<'server_error'>; + Success: { + success: true; + accepted_at: string; + }; +}>; + +export interface AgentEventProgress { + type: 'progress'; + message: string; +} + +export interface AgentEventSession { + type: 'session'; + session_id: string; +} + +export interface AgentEventDone { + type: 'done'; + message: string; +} + +export interface AgentEventDebug { + type: 'debug'; + message: string; +} + +export interface AgentEventQuestion { + type: 'question'; + question_id: string; + message: string; +} + +export interface AgentEventError { + type: 'error'; + message: string; +} + +export type AgentEventData = AgentEventProgress | AgentEventSession | AgentEventDone | AgentEventDebug | AgentEventQuestion | AgentEventError; + +export type AgentEventName = + | 'agent.lifecycle' + | 'agent.session.started' + | 'agent.delta' + | 'agent.tool.updated' + | 'agent.message.updated' + | 'agent.question' + | 'agent.permission.requested' + | 'agent.session.idle' + | 'agent.error'; + +export interface AgentSseEvent { + event: AgentEventName; + data: AgentEventData; +} diff --git a/packages/types/lib/functions/api.ts b/packages/types/lib/functions/api.ts new file mode 100644 index 00000000000..92c492aa736 --- /dev/null +++ b/packages/types/lib/functions/api.ts @@ -0,0 +1,106 @@ +import type { ApiError, Endpoint } from '../api.js'; + +export type FunctionType = 'action' | 'sync'; + +export type FunctionErrorCode = + | 'invalid_request' + | 'integration_not_found' + | 'compilation_error' + | 'dryrun_error' + | 'deployment_error' + | 'connection_not_found' + | 'function_disabled' + | 'timeout' + | 'validation_error'; + +export interface ProxyCall { + method: string; + endpoint: string; + status: number; + request: { + params?: Record; + headers?: Record; + data?: unknown; + }; + response: unknown; + headers: Record; +} + +// ------- +// POST /remote-function/compile +// ------- + +export type PostRemoteFunctionCompile = Endpoint<{ + Method: 'POST'; + Path: '/remote-function/compile'; + Body: { + integration_id: string; + function_name: string; + function_type: FunctionType; + code: string; + }; + Error: ApiError; + Success: { + integration_id: string; + function_name: string; + function_type: FunctionType; + bundle_size_bytes: number; + bundled_js: string; + compiled_at: string; + }; +}>; + +// ------- +// POST /remote-function/dryrun +// ------- + +export type PostRemoteFunctionDryrun = Endpoint<{ + Method: 'POST'; + Path: '/remote-function/dryrun'; + Body: { + integration_id: string; + function_name: string; + function_type: FunctionType; + code: string; + connection_id: string; + /** Required when function_type is 'action' */ + input?: unknown; + /** Optional for syncs */ + metadata?: Record | undefined; + checkpoint?: Record | undefined; + last_sync_date?: string | undefined; + }; + Error: ApiError; + Success: { + integration_id: string; + function_name: string; + function_type: FunctionType; + execution_timeout_at: string; + duration_ms: number; + /** Raw stdout from nango dryrun */ + output: string; + }; +}>; + +// ------- +// POST /remote-function/deploy +// ------- + +export type PostRemoteFunctionDeploy = Endpoint<{ + Method: 'POST'; + Path: '/remote-function/deploy'; + Body: { + integration_id: string; + function_name: string; + function_type: FunctionType; + code: string; + }; + Error: ApiError; + Success: { + integration_id: string; + function_name: string; + function_type: FunctionType; + /** Raw stdout from nango deploy */ + output: string; + }; +}>; diff --git a/packages/types/lib/index.ts b/packages/types/lib/index.ts index c512972b0ef..2c25cc936d3 100644 --- a/packages/types/lib/index.ts +++ b/packages/types/lib/index.ts @@ -98,3 +98,6 @@ export type * from './mcp/api.js'; export type * from './lambda/index.js'; export type * from './authz/types.js'; + +export type * from './agent/api.js'; +export type * from './functions/api.js'; diff --git a/packages/utils/lib/environment/parse.ts b/packages/utils/lib/environment/parse.ts index 09992654fb6..3552f6c6bf0 100644 --- a/packages/utils/lib/environment/parse.ts +++ b/packages/utils/lib/environment/parse.ts @@ -504,6 +504,14 @@ export const ENVS = z.object({ NANGO_WEBHOOK_CIRCUIT_BREAKER_COOLDOWN_DURATION_SECS: z.coerce.number().optional().default(60), NANGO_WEBHOOK_CIRCUIT_BREAKER_AUTO_RESET_SECS: z.coerce.number().optional().default(3600), + // ----- Agent Builder + SANDBOX_API_KEY: z.string().optional(), + SANDBOX_COMPILER_TEMPLATE: z.string().optional().default('nango-sf-compiler'), + SANDBOX_AGENT_TEMPLATE: z.string().optional().default('nango-opencode-agent'), + AGENT_RUNTIME: z.enum(['e2b', 'local']).optional().default('local'), + LOCAL_AGENT_IMAGE: z.string().optional().default('nango-local-agent'), + LOCAL_COMPILER_IMAGE: z.string().optional().default('nango-local-compiler'), + // ----- Others SERVER_RUN_MODE: z.enum(['DOCKERIZED', '']).optional(), NANGO_CLOUD: z.stringbool().optional().default(false),