From 19a3b56a7001d6986b48d9bdccc4f0f3debe36ae Mon Sep 17 00:00:00 2001 From: Mohammad AbuAboud Date: Thu, 20 Aug 2026 08:11:37 +0200 Subject: [PATCH 1/7] chore(sandbox): build code steps through the esbuild JS API Compile code steps by invoking esbuild via its JS API instead of spawning the CLI, and keep step bundling scoped to the step folder. Claude-Session: https://claude.ai/code/session_01RDmrm5ftUEpJUKFNB8LcW5 --- Dockerfile.worker | 6 +- bun.lock | 53 ++++++++ packages/server/sandbox/package.json | 1 + .../src/lib/cache/flow/code/code-builder.ts | 5 +- .../cache/flow/code/package-dependencies.ts | 22 +++ .../sandbox/src/lib/utils/bun-runner.ts | 61 ++++++--- .../src/lib/utils/esbuild-build-options.ts | 57 ++++++++ .../flow/code/package-dependencies.test.ts | 55 ++++++++ .../lib/utils/esbuild-build-options.test.ts | 126 ++++++++++++++++++ 9 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 packages/server/sandbox/src/lib/cache/flow/code/package-dependencies.ts create mode 100644 packages/server/sandbox/src/lib/utils/esbuild-build-options.ts create mode 100644 packages/server/sandbox/test/lib/cache/flow/code/package-dependencies.test.ts create mode 100644 packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts diff --git a/Dockerfile.worker b/Dockerfile.worker index 31ca2be24de5..09d4a70b314b 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -35,7 +35,7 @@ RUN npx turbo run build --filter=@activepieces/engine && \ esbuild packages/server/worker/src/bootstrap.ts \ --bundle --platform=node --format=cjs --target=node20 \ --tsconfig=tsconfig.base.json \ - --external:isolated-vm --external:bufferutil --external:utf-8-validate \ + --external:isolated-vm --external:bufferutil --external:utf-8-validate --external:esbuild \ --outfile=/out/worker.js # Prebuilt isolated-vm (the engine child resolves it at runtime via NODE_PATH). @@ -49,8 +49,8 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates procps && \ rm -rf /var/lib/apt/lists/* -# esbuild compiles CODE steps; the code-builder spawns it by name from PATH. -RUN npm install -g --no-fund --no-audit esbuild@0.25.0 && npm cache clean --force +# esbuild (JS API) compiles CODE steps; at /node_modules so --external:esbuild worker.js resolves it, like isolated-vm. +RUN npm install --prefix / --no-fund --no-audit --no-save esbuild@0.25.0 && npm cache clean --force WORKDIR /app diff --git a/bun.lock b/bun.lock index e1b7ded2424a..e7820ee69efe 100644 --- a/bun.lock +++ b/bun.lock @@ -10808,6 +10808,7 @@ "@activepieces/core-utils": "workspace:*", "@activepieces/server-utils": "workspace:*", "@activepieces/shared": "workspace:*", + "esbuild": "0.25.0", "nanoid": "3.3.17", "socket.io": "4.7.5", "tree-kill": "1.2.2", @@ -18544,6 +18545,8 @@ "@activepieces/pieces-framework/ai": ["ai@6.0.170", "", { "dependencies": { "@ai-sdk/gateway": "3.0.105", "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWTKeGGDRcYJtPWIrdZDSuvOW5LCjI2NZUJmaml8OTOaPEsXnFdFvmawCXbT+wTGxyWKJTgZ9sZtCjbJsmjM2Q=="], + "@activepieces/sandbox/esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="], + "@activepieces/server-utils/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], "@actual-app/core/csv-parse": ["csv-parse@6.2.1", "", {}, "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ=="], @@ -20792,6 +20795,56 @@ "@activepieces/pieces-framework/ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-/ngMKqKdL9dSlY/eQ3NFDzzFyw0Hix+cbFFlyuKEKcOgpHdBt/spKUvX/i0wGrDLFPYJeVvv3N0j92LxWRL7yQ=="], + "@activepieces/sandbox/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="], + + "@activepieces/sandbox/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.0", "", { "os": "android", "cpu": "arm" }, "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="], + + "@activepieces/sandbox/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.0", "", { "os": "android", "cpu": "arm64" }, "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="], + + "@activepieces/sandbox/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.0", "", { "os": "android", "cpu": "x64" }, "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="], + + "@activepieces/sandbox/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="], + + "@activepieces/sandbox/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="], + + "@activepieces/sandbox/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="], + + "@activepieces/sandbox/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="], + + "@activepieces/sandbox/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="], + + "@activepieces/sandbox/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="], + + "@activepieces/sandbox/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.0", "", { "os": "none", "cpu": "x64" }, "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="], + + "@activepieces/sandbox/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="], + + "@activepieces/sandbox/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="], + + "@activepieces/sandbox/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="], + + "@activepieces/sandbox/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="], + + "@activepieces/sandbox/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="], + + "@activepieces/sandbox/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="], + "@activepieces/server-utils/@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="], "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], diff --git a/packages/server/sandbox/package.json b/packages/server/sandbox/package.json index d42ebd3aa182..9e8d77d635dd 100644 --- a/packages/server/sandbox/package.json +++ b/packages/server/sandbox/package.json @@ -13,6 +13,7 @@ "@activepieces/core-utils": "workspace:*", "@activepieces/server-utils": "workspace:*", "@activepieces/shared": "workspace:*", + "esbuild": "0.25.0", "nanoid": "3.3.17", "socket.io": "4.7.5", "tree-kill": "1.2.2", diff --git a/packages/server/sandbox/src/lib/cache/flow/code/code-builder.ts b/packages/server/sandbox/src/lib/cache/flow/code/code-builder.ts index f9be765b00a4..d86a1d31649d 100644 --- a/packages/server/sandbox/src/lib/cache/flow/code/code-builder.ts +++ b/packages/server/sandbox/src/lib/cache/flow/code/code-builder.ts @@ -8,6 +8,7 @@ import { CodeArtifact, SandboxSettings } from '../../../types' import { bunRunner } from '../../../utils/bun-runner' import { cacheState } from '../../cache-state' import { codeCache } from './code-cache' +import { packageDependencies } from './package-dependencies' const TS_CONFIG_CONTENT = ` { @@ -146,11 +147,11 @@ function getPackageJson(packageJson: string, getSettings: () => SandboxSettings) } const { data: parsedPackageJson, error: parseError } = tryCatchSync(() => JSON.parse(packageJson)) const packageJsonObject = parseError ? {} : (parsedPackageJson as Record) + const requestedDependencies = packageDependencies.sanitize(packageJsonObject?.['dependencies']) return JSON.stringify({ - ...packageJsonObject, dependencies: { '@types/node': '18.17.1', - ...(packageJsonObject?.['dependencies'] ?? {}), + ...requestedDependencies, }, }) } diff --git a/packages/server/sandbox/src/lib/cache/flow/code/package-dependencies.ts b/packages/server/sandbox/src/lib/cache/flow/code/package-dependencies.ts new file mode 100644 index 000000000000..f1fb1cb192aa --- /dev/null +++ b/packages/server/sandbox/src/lib/cache/flow/code/package-dependencies.ts @@ -0,0 +1,22 @@ +function isRegistryDependency(name: string, version: unknown): boolean { + if (typeof version !== 'string') { + return false + } + const trimmed = version.trim() + if (trimmed.length === 0) { + return false + } + const nameAllowed = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i + const versionAllowed = /^[\w.\-+~^><=|* ]+$/ + return nameAllowed.test(name) && versionAllowed.test(trimmed) +} + +function sanitize(dependencies: unknown): Record { + if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) { + return {} + } + const entries = Object.entries(dependencies).filter(([name, version]) => isRegistryDependency(name, version)) + return Object.fromEntries(entries.map(([name, version]) => [name, String(version).trim()])) +} + +export const packageDependencies = { sanitize, isRegistryDependency } diff --git a/packages/server/sandbox/src/lib/utils/bun-runner.ts b/packages/server/sandbox/src/lib/utils/bun-runner.ts index 8cfc19593e1d..98f273a0a90a 100644 --- a/packages/server/sandbox/src/lib/utils/bun-runner.ts +++ b/packages/server/sandbox/src/lib/utils/bun-runner.ts @@ -1,6 +1,9 @@ +import path from 'node:path' import { tryCatch } from '@activepieces/core-utils' import { apDayjsDuration, fileSystemUtils } from '@activepieces/server-utils' import { type ApLogger } from '@activepieces/server-utils' +import { type BuildFailure, build as esbuildBuild, type Message } from 'esbuild' +import { stepFolderResolvePlugin } from './esbuild-build-options' import { CommandOutput, spawnWithKill } from './exec' export const bunRunner = (log: ApLogger) => ({ @@ -30,22 +33,23 @@ export const bunRunner = (log: ApLogger) => ({ } return data }, - async build({ path, entryFile, outputFile }: BuildParams): Promise { - const args = [ - entryFile, - '--bundle', - '--platform=node', - '--format=cjs', - `--outfile=${outputFile}`, - ] - log.debug({ path, entryFile, outputFile, args }, '[bunRunner#build]') - return spawnWithKill({ - cmd: 'esbuild', - args, - options: { cwd: path }, - printOutput: false, - timeoutMs: apDayjsDuration(5, 'minutes').asMilliseconds(), - }) + async build({ path: buildPath, entryFile, outputFile }: BuildParams): Promise { + log.debug({ path: buildPath, entryFile, outputFile }, '[bunRunner#build]') + const { error } = await tryCatch(async () => esbuildBuild({ + entryPoints: [entryFile], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: outputFile, + absWorkingDir: buildPath, + tsconfig: path.join(buildPath, 'tsconfig.json'), + logLevel: 'silent', + plugins: [stepFolderResolvePlugin(buildPath)], + })) + if (error) { + throw new Error(formatBuildError({ error, entryFile })) + } + return { stdout: '', stderr: '' } }, }) @@ -57,6 +61,26 @@ function sanitizeFilterPath(filterPath: string): string { return filterPath } +function isBuildFailure(error: unknown): error is BuildFailure { + return error instanceof Error && Array.isArray((error as Partial).errors) +} + +function formatMessageLocation(message: Message, entryFile: string): string { + const location = message.location + if (location === null || path.basename(location.file) !== path.basename(entryFile)) { + return '' + } + return ` (index.ts:${location.line}:${location.column})` +} + +function formatBuildError({ error, entryFile }: FormatBuildErrorParams): string { + if (!isBuildFailure(error)) { + return 'Failed to compile code step' + } + const lines = error.errors.map((message) => `${message.text}${formatMessageLocation(message, entryFile)}`) + return lines.length > 0 ? lines.join('\n') : 'Failed to compile code step' +} + type InstallParams = { path: string filtersPath: string[] @@ -67,3 +91,8 @@ type BuildParams = { entryFile: string outputFile: string } + +type FormatBuildErrorParams = { + error: unknown + entryFile: string +} diff --git a/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts b/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts new file mode 100644 index 000000000000..3963fa1b3065 --- /dev/null +++ b/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts @@ -0,0 +1,57 @@ +import { realpathSync } from 'node:fs' +import path from 'node:path' +import type { Plugin } from 'esbuild' + +function isPathInside(candidate: string, root: string): boolean { + return candidate === root || candidate.startsWith(root + path.sep) +} + +function isRealpathInside(candidate: string, root: string): boolean { + try { + return isPathInside(realpathSync(candidate), root) + } + catch { + return false + } +} + +function isBareSpecifier(specifier: string): boolean { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('\\')) { + return false + } + if (path.isAbsolute(specifier)) { + return false + } + return !/^[a-z][a-z0-9+.-]*:/i.test(specifier) +} + +export function stepFolderResolvePlugin(rootDir: string): Plugin { + const root = realpathSync(rootDir) + return { + name: 'ap-filesystem-jail', + setup(build): void { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === 'entry-point' || isBareSpecifier(args.path)) { + return null + } + if (/^[a-z][a-z0-9+.-]*:/i.test(args.path)) { + return { errors: [{ text: IMPORT_OUT_OF_SCOPE_MESSAGE }] } + } + const base = args.resolveDir === '' ? root : args.resolveDir + const target = path.resolve(base, args.path) + if (!isPathInside(target, root)) { + return { errors: [{ text: IMPORT_OUT_OF_SCOPE_MESSAGE }] } + } + return null + }) + build.onLoad({ filter: /.*/ }, (args) => { + if (!isRealpathInside(args.path, root)) { + return { errors: [{ text: IMPORT_OUT_OF_SCOPE_MESSAGE }] } + } + return null + }) + }, + } +} + +export const IMPORT_OUT_OF_SCOPE_MESSAGE = 'Importing files outside the step folder is not allowed' diff --git a/packages/server/sandbox/test/lib/cache/flow/code/package-dependencies.test.ts b/packages/server/sandbox/test/lib/cache/flow/code/package-dependencies.test.ts new file mode 100644 index 000000000000..0e2aa94ffc16 --- /dev/null +++ b/packages/server/sandbox/test/lib/cache/flow/code/package-dependencies.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { packageDependencies } from '../../../../../src/lib/cache/flow/code/package-dependencies' + +describe('packageDependencies.sanitize', () => { + it('keeps registry-style dependencies', () => { + const result = packageDependencies.sanitize({ + 'lodash': '^4.17.21', + '@activepieces/piece-slack': '0.1.0', + 'dayjs': '1.11.10', + 'zod': '>=3 <4', + 'left-pad': '*', + }) + expect(result).toEqual({ + 'lodash': '^4.17.21', + '@activepieces/piece-slack': '0.1.0', + 'dayjs': '1.11.10', + 'zod': '>=3 <4', + 'left-pad': '*', + }) + }) + + it.each([ + ['file:../../etc'], + ['file:/etc/passwd'], + ['link:/proc/self/root'], + ['git+ssh://git@host/x.git'], + ['github:user/repo'], + ['user/repo'], + ['https://evil.example/pkg.tgz'], + ['npm:other@1.0.0'], + ['workspace:*'], + ['../local/path'], + ])('drops non-registry spec %s', (spec) => { + expect(packageDependencies.sanitize({ evil: spec })).toEqual({}) + }) + + it('drops entries whose version is not a string', () => { + expect(packageDependencies.sanitize({ a: 1, b: null, c: { from: '/etc' } })).toEqual({}) + }) + + it('drops malformed package names', () => { + expect(packageDependencies.sanitize({ '../evil': '1.0.0', '.hidden': '1.0.0' })).toEqual({}) + }) + + it('returns empty for non-object inputs', () => { + expect(packageDependencies.sanitize(undefined)).toEqual({}) + expect(packageDependencies.sanitize(null)).toEqual({}) + expect(packageDependencies.sanitize('lodash')).toEqual({}) + expect(packageDependencies.sanitize(['lodash'])).toEqual({}) + }) + + it('trims surrounding whitespace from kept versions', () => { + expect(packageDependencies.sanitize({ lodash: ' 4.17.21 ' })).toEqual({ lodash: '4.17.21' }) + }) +}) diff --git a/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts b/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts new file mode 100644 index 000000000000..b5b0f24d5397 --- /dev/null +++ b/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts @@ -0,0 +1,126 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build } from 'esbuild' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { stepFolderResolvePlugin, IMPORT_OUT_OF_SCOPE_MESSAGE } from '../../../src/lib/utils/esbuild-build-options' + +const SECRET = `SECRET_${randomUUID()}` +let secretDir: string +const codePaths: string[] = [] + +beforeAll(() => { + secretDir = mkdtempSync(path.join(tmpdir(), 'jail-secret-')) + writeFileSync(path.join(secretDir, 'secret.txt'), SECRET) + writeFileSync(path.join(secretDir, 'secret.json'), JSON.stringify({ token: SECRET })) + writeFileSync(path.join(secretDir, 'secret.js'), `module.exports = ${JSON.stringify(SECRET)}`) +}) + +afterAll(() => { + rmSync(secretDir, { recursive: true, force: true }) + for (const codePath of codePaths) { + rmSync(codePath, { recursive: true, force: true }) + } +}) + +async function compile({ code, prepare }: CompileParams): Promise { + const codePath = mkdtempSync(path.join(tmpdir(), 'jail-code-')) + codePaths.push(codePath) + mkdirSync(path.join(codePath, 'node_modules'), { recursive: true }) + prepare?.(codePath) + writeFileSync(path.join(codePath, 'index.ts'), code) + const outFile = path.join(codePath, 'index.js') + try { + await build({ + entryPoints: [path.join(codePath, 'index.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: outFile, + absWorkingDir: codePath, + logLevel: 'silent', + plugins: [stepFolderResolvePlugin(codePath)], + }) + return { built: true, bundle: readFileSync(outFile, 'utf8'), error: '' } + } + catch (error) { + const errors = (error as { errors?: { text: string, location?: { lineText?: string } }[] }).errors ?? [] + const message = errors.map((e) => `${e.text} ${e.location?.lineText ?? ''}`).join(' | ') + return { built: false, bundle: '', error: message } + } +} + +describe('stepFolderResolvePlugin', () => { + it('blocks an absolute import of a file outside the step folder', async () => { + const result = await compile({ code: `export const code = async () => await import('${secretDir}/secret.txt')` }) + expect(result.built).toBe(false) + expect(result.error).toContain(IMPORT_OUT_OF_SCOPE_MESSAGE) + expect(result.error).not.toContain(SECRET) + }) + + it('blocks a relative-traversal import that escapes the step folder', async () => { + const escape = '../'.repeat(24) + secretDir.replace(/^\//, '') + '/secret.json' + const result = await compile({ code: `export const code = async () => await import('${escape}')` }) + expect(result.built).toBe(false) + expect(result.error).toContain(IMPORT_OUT_OF_SCOPE_MESSAGE) + expect(result.error).not.toContain(SECRET) + }) + + it('blocks a symlink inside the step folder that points outside', async () => { + const result = await compile({ + code: `import s from './leak.js'; export const code = async () => ({ s })`, + prepare: (codePath) => symlinkSync(path.join(secretDir, 'secret.js'), path.join(codePath, 'leak.js')), + }) + expect(result.built).toBe(false) + expect(result.error).toContain(IMPORT_OUT_OF_SCOPE_MESSAGE) + expect(result.bundle).not.toContain(SECRET) + }) + + it('blocks a node_modules package whose entry escapes the step folder', async () => { + const result = await compile({ + code: `import s from 'evil'; export const code = async () => ({ s })`, + prepare: (codePath) => { + const dir = path.join(codePath, 'node_modules', 'evil') + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'evil', main: path.join(secretDir, 'secret.js') })) + }, + }) + expect(result.built).toBe(false) + expect(result.bundle).not.toContain(SECRET) + }) + + it('allows a legitimate relative import inside the step folder', async () => { + const result = await compile({ + code: `import s from './helper'; export const code = async () => ({ s })`, + prepare: (codePath) => writeFileSync(path.join(codePath, 'helper.ts'), 'export default 42'), + }) + expect(result.built).toBe(true) + expect(result.bundle).toContain('42') + }) + + it('allows a bundled node_modules dependency', async () => { + const result = await compile({ + code: `import s from 'friendly'; export const code = async () => ({ s })`, + prepare: (codePath) => { + const dir = path.join(codePath, 'node_modules', 'friendly') + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'friendly', main: 'index.js' })) + writeFileSync(path.join(dir, 'index.js'), 'module.exports = "friendly-value"') + }, + }) + expect(result.built).toBe(true) + expect(result.bundle).toContain('friendly-value') + }) +}) + +type CompileParams = { + code: string + prepare?: (codePath: string) => void +} + +type CompileResult = { + built: boolean + bundle: string + error: string +} From 8301b4ed00f9870e427bd9caafc5cf7ecdc47a3a Mon Sep 17 00:00:00 2001 From: Ibrahim Abuznaid <135799669+ibrahim-abuznaid@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:40:33 +0300 Subject: [PATCH 2/7] fix(ci): don't let one failed piece publish block the whole release (#14920) Co-authored-by: Claude Fable 5 --- tools/scripts/pieces/publish-pieces-to-npm.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/scripts/pieces/publish-pieces-to-npm.ts b/tools/scripts/pieces/publish-pieces-to-npm.ts index ddf61832151a..643dc07a848f 100644 --- a/tools/scripts/pieces/publish-pieces-to-npm.ts +++ b/tools/scripts/pieces/publish-pieces-to-npm.ts @@ -17,11 +17,23 @@ const main = async () => { console.info(`[publishPieces] publishing ${piecesSource.length} pieces${changedPaths ? ' (scoped to changed)' : ' (all)'}`) const piecesSourceChunks = chunk(piecesSource, 30) + const failedPaths: string[] = [] for (const chunk of piecesSourceChunks) { - await Promise.all(chunk.map((path) => publishNpmPackage(path))) + const results = await Promise.allSettled(chunk.map((path) => publishNpmPackage(path))) + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.error(`[publishPieces] FAILED path=${chunk[index]}`, result.reason) + failedPaths.push(chunk[index]) + } + }) await new Promise(resolve => setTimeout(resolve, 5000)) } + + if (failedPaths.length > 0) { + console.error(`[publishPieces] ${failedPaths.length}/${piecesSource.length} piece(s) failed to publish:\n ${failedPaths.join('\n ')}`) + process.exitCode = 1 + } } main() \ No newline at end of file From 43320560fad381c7e1115c29ff95200e53ceadbe Mon Sep 17 00:00:00 2001 From: Chaker Atallah <74781393+MrChaker@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:48:48 +0100 Subject: [PATCH 3/7] fix(ci): stop setup-environment resurrecting previews on PR close (#14951) --- .github/workflows/setup-environment.yml | 2 +- brain/knowledge/engineering/ci-pr-review-hygiene.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/setup-environment.yml b/.github/workflows/setup-environment.yml index e2404899192e..51ba4deaed40 100644 --- a/.github/workflows/setup-environment.yml +++ b/.github/workflows/setup-environment.yml @@ -2,7 +2,7 @@ name: Setup Environment on: pull_request: - types: [opened, reopened, synchronize, edited, closed, labeled] + types: [opened, reopened, synchronize, edited, labeled] workflow_dispatch: concurrency: diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 91cf99bfaeba..724d63efdf58 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -27,5 +27,7 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **`redis-memory-server` compiles Redis from source during `bun install`, so its version must stay pinned.** It is in `trustedDependencies`, and with no version configured it defaults to `stable` — whatever `download.redis.io/redis-stable.tar.gz` points at today. When that moved to Redis 8.10.0 (2026-07-29), the bundled module tree (redisearch, redistimeseries, LibMR) started failing to build on runners and took `bun install` down across every branch: 8.10.0 vendors the module sources into the tarball and changes the default make goal to `build`, which compiles every module under `modules/*/src` regardless of `BUILD_WITH_MODULES`. It reads as flakiness because `ci.yml` caches `~/.bun/install/cache` but not the compiled binary, so each run recompiles and only sometimes survives. Root `package.json` pins `redisMemoryServer.version` to **8.8.1**, the newest release that still builds core-only — treat it as a ceiling, bump it deliberately, and never go back to `stable`. - **Retargeting a stacked PR to `main` does not drop its base branch — it merges the whole thing.** A PR opened against a long-lived feature branch shows a small diff *relative to that base*, but `gh pr edit --base main` only moves the target; the branch still contains every commit of its old base. [#14593](https://github.com/activepieces/activepieces/pull/14593) read as 2 docs files against `feat/autumn-billing-integration` and as 198 commits / 211 files / +12k lines against `main`. Check with `git diff --stat origin/main...` **before** retargeting, and if it disagrees with the PR page, cherry-pick that PR's own commits onto `main` and force-push instead. A "conflict" on such a PR is often against the feature base only — those same commits can apply to `main` cleanly. - **A decision authored on a long-lived branch will collide on its number.** `brain/decisions/` numbers are assigned once and never reused, but the next free number is only knowable against `main` — two branches in flight both grab it. #14593 carried a `000024` that `main` had since filled, and `000025` too, so it landed as `000026`. Renumber against `main` at merge time and update every referring link; nothing in CI catches a duplicate number or a dead decision link. +- **Preview environments resurrect on PR close because `setup-environment.yml` also triggers on `closed`.** Both workflows fire on the same close event; Remove Environment tears the env down correctly (compose down, nginx, repo), then Setup Environment sees the `preview` label (labels survive merge) and re-provisions the whole thing minutes later — verified on #14832: remove finished 11:20, setup rebuilt it by 11:30. This is why merged PRs kept live zombie environments on the preview box. Both workflows are thin SSH wrappers; the real setup/remove logic lives in `/root/environments` on the preview server (`secrets.PREVIEW_HOST`), not in this repo. Fixed by dropping `closed` from setup's trigger list. +- **The preview-server remove tool can't clean containers once the repo dir is gone.** Its `stop()` skips `docker compose down` when `repos//docker-compose.yml` doesn't exist, so an env whose repo folder was deleted first leaves containers running forever — re-running `remove` is a no-op for them. Clean those manually via compose labels: `docker ps -aq --filter "label=com.docker.compose.project="` (same filter works for `docker volume ls`). When auditing envs against PR state: read the real branch from the clone's HEAD (`git -C repos/ symbolic-ref --short HEAD`) since subdomains flatten `/` to `-`; a clone sitting on `main` means the branch was deleted after merge; and an env with **no PR at all** is a manual `workflow_dispatch` preview — don't auto-delete those (bulk cleanup 2026-08-20 removed 27 closed-PR envs, reclaimed 32.5GB). - **`tools/scripts/` is outside the lint and test wiring.** ESLint ignores it, and `npm run test-unit` only covers engine/shared/web. A script there with real policy logic must run its own tests from its own workflow — `pr-size.yml` runs `bun test tools/scripts/pr-size-check.test.ts` as a step before the check itself. - **A branch that predates the `brain/` → `brain/knowledge/` move cannot edit a brain page in place — GitHub will call the PR conflicting even when `git merge` is clean locally.** Git follows the rename and merges the modification into the new path; GitHub's mergeability check does not, so it reports `modify/delete` on the old path and the PR goes `dirty`. Local `git merge-tree --write-tree` exits 0 and hides the problem; reproduce what GitHub sees with `git merge -X no-renames origin/main`. Fix: merge `origin/main` into the branch first, which lands the edit at the new path, then push. From 6288b13d6b575efae094267e8663c68a41335f0d Mon Sep 17 00:00:00 2001 From: Othman Abu Ajamieh <52608229+othmanemad@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:37:13 +0300 Subject: [PATCH 4/7] fix(web): gate Piece Sets behind managePiecesEnabled instead of a 402 toast (#14948) Co-authored-by: Claude Opus 5 (1M context) --- brain/knowledge/pieces-engine/piece-sets.md | 1 + .../routes/platform/setup/pieces/index.tsx | 97 ++++++++++--------- .../piece-sets/create-piece-set-dialog.tsx | 5 +- .../piece-sets/piece-set-details-page.tsx | 8 +- 4 files changed, 61 insertions(+), 50 deletions(-) diff --git a/brain/knowledge/pieces-engine/piece-sets.md b/brain/knowledge/pieces-engine/piece-sets.md index 7600c7e002ed..8e0d950e6b3e 100644 --- a/brain/knowledge/pieces-engine/piece-sets.md +++ b/brain/knowledge/pieces-engine/piece-sets.md @@ -20,6 +20,7 @@ A named, reusable piece/action/trigger visibility configuration a platform admin ### Gotchas - EE/Cloud only, gated behind `platform.plan.managePiecesEnabled`. On CE / flag off, piece sets are inert and filtering falls back to legacy project-plan allow/block lists. +- The **whole** `/v1/piece-sets` module is behind that flag, `GET` included — so on a locked plan the web list query is `enabled: false`, the table is simply empty, and row actions never render. Only toolbar/entry points need a UI guard. The `LockedAlert` + `RequestTrial featureKey="ENTERPRISE_PIECES"` lives once on `PlatformPiecesPage`, above the tabs, since the same flag gates both the Pieces and Piece Sets tabs; the details route redirects back to the tab rather than hanging on a spinner waiting for a query that will never run. - There is **no** install-time sync and no `onPieceCreated` hook — resolution is purely read-time. See ADR 0001 (visibility derived, not materialized). - Embed auth: a v4 JWT carries a `pieceSet` key claim; legacy v2/v3 tokens carry `piecesTags` (only the first tag honored, resolved to `key = tag`, else Default). Enforcement (`applyProjectPieceAccess`) runs unconditionally, not gated by the flag. - Migration is three ordered steps: create table + backfill (`1807...`), then `CREATE INDEX CONCURRENTLY` (`1808...`, non-transactional), then the breaking drop of legacy platform piece-filter columns (`1809...`). Legacy `tag`/`piece_tag` tables are kept only because the backfill reads them once via raw SQL. diff --git a/packages/web/src/app/routes/platform/setup/pieces/index.tsx b/packages/web/src/app/routes/platform/setup/pieces/index.tsx index 5622d6ac2786..47153594e937 100644 --- a/packages/web/src/app/routes/platform/setup/pieces/index.tsx +++ b/packages/web/src/app/routes/platform/setup/pieces/index.tsx @@ -177,59 +177,44 @@ const PiecesListTab = () => { ); return ( - <> - {!isEnabled && ( - - } - /> + } - columns={columns} - filters={[ - { - type: 'input', - title: t('Piece Name'), - accessorKey: 'name', - icon: CheckIcon, - }, - ]} - page={{ - data: pieces ?? [], - next: null, - previous: null, - }} - isLoading={isLoading} - toolbarButtons={[ - , - , - refetchPieces()} - scope={PieceScope.PLATFORM} - />, - ]} - virtualizeRows={true} - hidePagination={true} - /> - + emptyStateIcon={} + columns={columns} + filters={[ + { + type: 'input', + title: t('Piece Name'), + accessorKey: 'name', + icon: CheckIcon, + }, + ]} + page={{ + data: pieces ?? [], + next: null, + previous: null, + }} + isLoading={isLoading} + toolbarButtons={[ + , + , + refetchPieces()} + scope={PieceScope.PLATFORM} + />, + ]} + virtualizeRows={true} + hidePagination={true} + /> ); }; const PlatformPiecesPage = () => { + const { platform } = platformHooks.useCurrentPlatform(); const [searchParams, setSearchParams] = useSearchParams(); const activeTab = (searchParams.get('tab') as TabValue) || 'pieces'; @@ -250,6 +235,22 @@ const PlatformPiecesPage = () => { title={t('Pieces')} />
+ {!platform.plan.managePiecesEnabled && ( +
+ + } + /> +
+ )} setTab(v as TabValue)} diff --git a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/create-piece-set-dialog.tsx b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/create-piece-set-dialog.tsx index abcfc38f23ef..7ba65488db91 100644 --- a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/create-piece-set-dialog.tsx +++ b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/create-piece-set-dialog.tsx @@ -25,6 +25,7 @@ import { } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { pieceSetMutations } from '@/features/piece-sets'; +import { platformHooks } from '@/hooks/platform-hooks'; const formSchema = CreatePieceSetRequestBody; @@ -100,11 +101,13 @@ const CreatePieceSetForm = ({ export const CreatePieceSetDialog = ({ onCreated, }: CreatePieceSetDialogProps) => { + const { platform } = platformHooks.useCurrentPlatform(); + const isEnabled = platform.plan.managePiecesEnabled; const [open, setOpen] = useState(false); return ( - diff --git a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-details-page.tsx b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-details-page.tsx index f78e0226a8fe..7412faedd839 100644 --- a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-details-page.tsx +++ b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-details-page.tsx @@ -1,7 +1,7 @@ import { PieceSelection, PieceSelectionMode } from '@activepieces/shared'; import { t } from 'i18next'; import { ArrowLeft, Layers, Loader2 } from 'lucide-react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { DashboardPageHeader } from '@/app/components/dashboard-page-header'; import { Badge } from '@/components/ui/badge'; @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { pieceSetMutations, pieceSetQueries } from '@/features/piece-sets'; import { piecesHooks } from '@/features/pieces'; +import { platformHooks } from '@/hooks/platform-hooks'; import { cn } from '@/lib/utils'; import { PieceSetPiecesTab } from './piece-set-pieces-tab'; @@ -35,6 +36,7 @@ function flipSelectionMode({ const PieceSetDetailsPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { platform } = platformHooks.useCurrentPlatform(); const { data: pieceSet, isLoading } = pieceSetQueries.usePieceSet(id ?? ''); const { pieces, isLoading: piecesLoading } = piecesHooks.usePieces({ includeHidden: true, @@ -58,6 +60,10 @@ const PieceSetDetailsPage = () => { }); }; + if (!platform.plan.managePiecesEnabled) { + return ; + } + if (isLoading || !pieceSet) { return (
From d34f3a7563d42d5aebd74c398c1abe0db803d844 Mon Sep 17 00:00:00 2001 From: Chaker Atallah <74781393+MrChaker@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:04:16 +0100 Subject: [PATCH 5/7] chore(release): v0.88.3 (#14930) --- docker-compose.yml | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 84cfdc5efaca..116df299f7c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: ghcr.io/activepieces/activepieces:0.88.1 + image: ghcr.io/activepieces/activepieces:0.88.3 container_name: activepieces-app restart: unless-stopped ports: @@ -16,7 +16,7 @@ services: networks: - activepieces worker: - image: ghcr.io/activepieces/activepieces:0.88.1 + image: ghcr.io/activepieces/activepieces:0.88.3 restart: unless-stopped depends_on: - app diff --git a/package.json b/package.json index 2da8e9664ee6..82716d5fc510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activepieces", - "version": "0.88.1", + "version": "0.88.3", "packageManager": "bun@1.3.3", "trustedDependencies": [ "sqlite3", From aa151e077adc3ff3a7c840cc0ef7178b86f2a580 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 20 Aug 2026 16:44:18 +0300 Subject: [PATCH 6/7] fix(agents): hide the agents surface behind a flag that is off by default (#14956) --- packages/core/shared/package.json | 2 +- .../core/shared/src/lib/core/flag/flag.ts | 1 + .../server/api/src/app/flags/flag.service.ts | 6 ++++++ .../api/src/app/helper/system-validator.ts | 1 + .../api/src/app/helper/system/system-props.ts | 1 + .../components/sidebar/dashboard/index.tsx | 7 ++++++- .../web/src/app/guards/agents-flag-guard.tsx | 18 ++++++++++++++++ packages/web/src/app/guards/index.tsx | 21 +++++++++++-------- .../web/src/app/routes/project-routes.tsx | 21 +++++++++++-------- 9 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 packages/web/src/app/guards/agents-flag-guard.tsx diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 131bfef10444..d884384a3f4d 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.138.1", + "version": "0.139.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/core/flag/flag.ts b/packages/core/shared/src/lib/core/flag/flag.ts index 90d8a0bfff8e..f609ff5e17be 100755 --- a/packages/core/shared/src/lib/core/flag/flag.ts +++ b/packages/core/shared/src/lib/core/flag/flag.ts @@ -22,6 +22,7 @@ export enum ApFlagId { SHOW_POWERED_BY_IN_FORM = 'SHOW_POWERED_BY_IN_FORM', CLOUD_AUTH_ENABLED = 'CLOUD_AUTH_ENABLED', AGENTS_CONFIGURED = 'AGENTS_CONFIGURED', + AGENTS_ENABLED = 'AGENTS_ENABLED', CURRENT_VERSION = 'CURRENT_VERSION', EDITION = 'EDITION', EMAIL_AUTH_ENABLED = 'EMAIL_AUTH_ENABLED', diff --git a/packages/server/api/src/app/flags/flag.service.ts b/packages/server/api/src/app/flags/flag.service.ts index 819d127a8ef2..8d5ff30b127c 100644 --- a/packages/server/api/src/app/flags/flag.service.ts +++ b/packages/server/api/src/app/flags/flag.service.ts @@ -188,6 +188,12 @@ export const flagService = (log: FastifyBaseLogger) => ({ created, updated, }, + { + id: ApFlagId.AGENTS_ENABLED, + value: system.getBoolean(AppSystemProp.AGENTS_ENABLED) ?? false, + created, + updated, + }, { id: ApFlagId.TOOL_SEARCH_ENABLED, value: isToolSearchEnabled(), diff --git a/packages/server/api/src/app/helper/system-validator.ts b/packages/server/api/src/app/helper/system-validator.ts index c7486a140b5e..57cef6459fda 100644 --- a/packages/server/api/src/app/helper/system-validator.ts +++ b/packages/server/api/src/app/helper/system-validator.ts @@ -150,6 +150,7 @@ const systemPropValidators: { [AppSystemProp.SMTP_SENDER_NAME]: stringValidator, [AppSystemProp.SMTP_TLS_REJECT_UNAUTHORIZED]: booleanValidator, [AppSystemProp.SMTP_USERNAME]: stringValidator, + [AppSystemProp.AGENTS_ENABLED]: booleanValidator, [AppSystemProp.TELEMETRY_ENABLED]: booleanValidator, [AppSystemProp.TOOL_SEARCH_ENABLED]: booleanValidator, [AppSystemProp.TRIGGER_DEFAULT_POLL_INTERVAL]: numberValidator, diff --git a/packages/server/api/src/app/helper/system/system-props.ts b/packages/server/api/src/app/helper/system/system-props.ts index 5730e1ad9e17..5cf5694bca5a 100644 --- a/packages/server/api/src/app/helper/system/system-props.ts +++ b/packages/server/api/src/app/helper/system/system-props.ts @@ -121,6 +121,7 @@ export enum AppSystemProp { TELEMETRY_ENABLED = 'TELEMETRY_ENABLED', TURNSTILE_SECRET_KEY = 'TURNSTILE_SECRET_KEY', TURNSTILE_SITE_KEY = 'TURNSTILE_SITE_KEY', + AGENTS_ENABLED = 'AGENTS_ENABLED', TOOL_SEARCH_ENABLED = 'TOOL_SEARCH_ENABLED', TRIGGER_DEFAULT_POLL_INTERVAL = 'TRIGGER_DEFAULT_POLL_INTERVAL', TRIGGER_HOOKS_TIMEOUT_SECONDS = 'TRIGGER_HOOKS_TIMEOUT_SECONDS', diff --git a/packages/web/src/app/components/sidebar/dashboard/index.tsx b/packages/web/src/app/components/sidebar/dashboard/index.tsx index 2480a57611bf..7c0e6ae88fe9 100644 --- a/packages/web/src/app/components/sidebar/dashboard/index.tsx +++ b/packages/web/src/app/components/sidebar/dashboard/index.tsx @@ -1,5 +1,6 @@ import { Permission, isNil } from '@activepieces/core-utils'; import { + ApFlagId, PROJECT_COLOR_PALETTE, PlatformRole, ProjectType, @@ -48,6 +49,7 @@ import { useAuthorization, useIsPlatformAdmin, } from '@/hooks/authorization-hooks'; +import { flagsHooks } from '@/hooks/flags-hooks'; import { platformHooks } from '@/hooks/platform-hooks'; import { userHooks } from '@/hooks/user-hooks'; import { cn } from '@/lib/utils'; @@ -64,6 +66,9 @@ import { SidebarUser } from '../sidebar-user'; export function ProjectDashboardSidebar({ className, }: { className?: string } = {}) { + const { data: agentsEnabledFlag } = flagsHooks.useFlag( + ApFlagId.AGENTS_ENABLED, + ); const { data: projects } = projectCollectionUtils.useAll(); const { embedState } = useEmbedding(); const { state } = useSidebar(); @@ -168,7 +173,7 @@ export function ProjectDashboardSidebar({ type: 'link', to: '/agents', label: t('Agents'), - show: platform.plan.agentsEnabled, + show: platform.plan.agentsEnabled && agentsEnabledFlag === true, icon: BotIcon, hasPermission: checkAccess(Permission.READ_AGENT), isSubItem: false, diff --git a/packages/web/src/app/guards/agents-flag-guard.tsx b/packages/web/src/app/guards/agents-flag-guard.tsx new file mode 100644 index 000000000000..8fa526cd41f4 --- /dev/null +++ b/packages/web/src/app/guards/agents-flag-guard.tsx @@ -0,0 +1,18 @@ +import { ApFlagId } from '@activepieces/shared'; +import { Navigate } from 'react-router-dom'; + +import { flagsHooks } from '@/hooks/flags-hooks'; + +type AgentsFlagGuardProps = { + children: React.ReactNode; +}; + +export const AgentsFlagGuard = ({ children }: AgentsFlagGuardProps) => { + const { data: agentsEnabled } = flagsHooks.useFlag( + ApFlagId.AGENTS_ENABLED, + ); + if (agentsEnabled !== true) { + return ; + } + return children; +}; diff --git a/packages/web/src/app/guards/index.tsx b/packages/web/src/app/guards/index.tsx index 850dec3615c0..62aaaf761e32 100644 --- a/packages/web/src/app/guards/index.tsx +++ b/packages/web/src/app/guards/index.tsx @@ -19,6 +19,7 @@ import { AllowOnlyLoggedInUserOnlyGuard } from '../components/allow-logged-in-us import { RouteErrorBoundary } from '../components/global-error-boundary'; import { ProjectDashboardLayout } from '../components/project-layout'; +import { AgentsFlagGuard } from './agents-flag-guard'; import { DefaultRoute } from './default-route'; import { RoutePermissionGuard } from './permission-guard'; import { TokenCheckerWrapper } from './project-route-wrapper'; @@ -62,15 +63,17 @@ const agentRoutes = [ path: '/agents', element: ( - - - - }> - - - - - + + + + + }> + + + + + + ), }, diff --git a/packages/web/src/app/routes/project-routes.tsx b/packages/web/src/app/routes/project-routes.tsx index 2c027957fb29..40162e50a50f 100644 --- a/packages/web/src/app/routes/project-routes.tsx +++ b/packages/web/src/app/routes/project-routes.tsx @@ -12,6 +12,7 @@ import { routesThatRequireProjectId } from '@/lib/route-utils'; import { BuilderLayout } from '../components/builder-layout'; import { ProjectDashboardLayout } from '../components/project-layout'; import { AfterImportFlowRedirect } from '../guards/after-import-flow-redirect'; +import { AgentsFlagGuard } from '../guards/agents-flag-guard'; import { RoutePermissionGuard } from '../guards/permission-guard'; import { ProjectRouterWrapper } from '../guards/project-route-wrapper'; @@ -90,15 +91,17 @@ export const projectRoutes = [ ...ProjectRouterWrapper({ path: routesThatRequireProjectId.singleAgent, element: ( - - - - - - - - - + + + + + + + + + + + ), }), ...ProjectRouterWrapper({ From 362389e8262f4ece88fe95203415e40674a9b195 Mon Sep 17 00:00:00 2001 From: Amr Elmohamady Date: Thu, 20 Aug 2026 19:02:09 +0300 Subject: [PATCH 7/7] fix(platform): add file(platformId) index to unblock platform delete (#14911) Co-authored-by: Claude Opus 4.7 --- .../engineering/server-module-anatomy.md | 1 + .../1829000000000-AddFilePlatformIdIndex.ts | 35 +++++++++++++++++++ .../src/app/database/postgres-connection.ts | 2 ++ .../app/ee/platform/platform-teardown-jobs.ts | 3 +- .../server/api/src/app/file/file.entity.ts | 5 +++ 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 packages/server/api/src/app/database/migration/postgres/1829000000000-AddFilePlatformIdIndex.ts diff --git a/brain/knowledge/engineering/server-module-anatomy.md b/brain/knowledge/engineering/server-module-anatomy.md index a20aad6a803d..e30ea388f624 100644 --- a/brain/knowledge/engineering/server-module-anatomy.md +++ b/brain/knowledge/engineering/server-module-anatomy.md @@ -137,6 +137,7 @@ Verify with `npm run lint-dev` and `npm run test-api`. - **`npm run check-migrations` can pass while your database is untouched.** It sources `.env.tests` (not `.env.dev`) and pipes `migration:run` to `/dev/null`, so it reported "No changes in database schema were found" against a database still holding the pre-migration table. Treat a green run as "the entity and *some* database agree", not as proof your migration executed. To actually verify, run `migration:run` against the dev DB with an explicit `AP_POSTGRES_HOST` and then inspect `information_schema.columns`, `pg_indexes` and `pg_constraint`. - **Migration timestamps collide across unmerged branches.** `migrations` is keyed by class name, so two branches can both claim `1824000000000` and only conflict at merge. Before picking a timestamp, check the applied ledger (`select name from migrations order by id desc limit 5`) as well as the files on `main` — a timestamp can already be in use by a branch you cannot see. - **PGlite has one connection, so `CONCURRENTLY` breaks it.** Guard on `system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE` and issue a plain `CREATE INDEX` on that branch. When you do use `CONCURRENTLY`, set `transaction = false` on the migration class — PostgreSQL requires it outside a transaction. +- **`EntitySchema` supports partial-index `where`, but not expression columns.** For a partial index on a bare column (e.g. `ON file(platformId) WHERE projectId IS NULL`), pass `where: '"projectId" IS NULL'` alongside `columns: ['platformId']` — TypeORM 0.3.x's `EntitySchemaIndexOptions.where` is honored by the Postgres driver (`PostgresQueryRunner` line 2442: `${where ? "WHERE " + where : ""}`), so `synchronize` can stay on and `migration:generate` tracks the index correctly. Reserve `synchronize: false` for **expression indexes** — `columns` is `string[]` of bare column names with no expression syntax, so an index like `ON file(type, (metadata->>'flowId'))` (see `idx_file_sample_data_flow_id`) genuinely can't be expressed and needs the opt-out. Blindly using `synchronize: false` for every hand-written index (which I did once and got called on) leaves TypeORM blind to the index — future `migration:generate` won't drop it if you remove it from the entity, and drift can silently accumulate. - **`UpdateResult.affected` is `undefined` on PGlite — never branch on it.** TypeORM's Postgres driver sets `affected` from `raw.rowCount`, and `typeorm-pglite` returns PGlite's `Results` (`{ rows, fields, affectedRows }`) with no `rowCount`. So the compare-and-set idiom `if (result.affected === 0) return null` is *always false* on PGlite and every predicate in the `WHERE` becomes decorative — the guard silently passes. This is not test-only: `AP_DB_TYPE=PGLITE` is the documented one-line Docker install (`docs/install/options/docker.mdx`). It hit MCP OAuth (`mcpOAuthCodeService.consume`), where it made authorization codes replayable, unbound to their client and redirect_uri, and immune to expiry. Use `.returning('*')` and test `updateResult.raw` for emptiness instead — that works on both drivers. Confirmed against the pinned `@electric-sql/pglite` 0.3.14: a plain `UPDATE` answers `{ rows, fields, affectedRows }` with **`rowCount: undefined`**, while the same statement with `RETURNING *` fills `rows` correctly (0 on no match, 1 on match). Note PGlite *does* report `affectedRows` — it is only `rowCount`, the field TypeORM reads, that is missing, so "PGlite loses the count" is the wrong mental model. The remaining call sites were converted in 2026-08 (`ee/agent/agent-rpc-handlers.ts`, `ee/projects/platform-project-service.ts`); a `.affected` that only feeds a log line was left alone. **Integration tests here run on Postgres** (`.env.tests` points at a real server), so they cannot catch this class at all — run the suite with `AP_DB_TYPE=PGLITE` prefixed to exercise it, which works today and is how the fix was proven red-to-green. Prefer `.returning('id')` over `.returning('*')`: on a table like `agent_conversation` the star form hauls the whole `messages` jsonb back on every write, and a row only has to be counted, not read. - **`breaking = true` is the rollback-safety flag, not the customer-facing one.** It marks destructive DDL (`DROP TABLE`/`DROP COLUMN`, `ADD ... NOT NULL` without a default) for `rollback-migrations.ts`. It does *not* by itself mean the PR needs the `⛓️‍💥 breaking-change` label — decide that from upgrade impact on self-hosters and API consumers. - **A new `AppSystemProp` needs three edits, not one.** Add the enum entry in `system-props.ts`, a default in `systemPropDefaultValues` (`system.ts`), *and* a validator in `systemPropValidators` (`system-validator.ts`). Miss the validator and `validateEnvPropsOnStartup` throws `systemPropValidators[prop] is not a function` at boot — every API test fails on setup, not just the new one. Document the var in `docs/install/reference/environment-variables.mdx` too. diff --git a/packages/server/api/src/app/database/migration/postgres/1829000000000-AddFilePlatformIdIndex.ts b/packages/server/api/src/app/database/migration/postgres/1829000000000-AddFilePlatformIdIndex.ts new file mode 100644 index 000000000000..f2ccad8d2d76 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1829000000000-AddFilePlatformIdIndex.ts @@ -0,0 +1,35 @@ +import { QueryRunner } from 'typeorm' +import { system } from '../../../helper/system/system' +import { AppSystemProp } from '../../../helper/system/system-props' +import { DatabaseType } from '../../database-type' +import { Migration } from '../../migration' + +export class AddFilePlatformIdIndex1829000000000 implements Migration { + name = 'AddFilePlatformIdIndex1829000000000' + breaking = false + release = '0.88.1' + transaction = false + + public async up(queryRunner: QueryRunner): Promise { + if (isPGlite()) { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_file_platform_id_null_project" + ON "file" ("platformId") + WHERE "projectId" IS NULL + `) + } + else { + await queryRunner.query(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_file_platform_id_null_project" + ON "file" ("platformId") + WHERE "projectId" IS NULL + `) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX IF EXISTS "idx_file_platform_id_null_project"') + } +} + +const isPGlite = (): boolean => system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index f0b250abefec..918b00b2732b 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -419,6 +419,7 @@ import { AddAgentTable1825000000000 } from './migration/postgres/1825000000000-A import { AddAgentIdToAgentConversation1826000000000 } from './migration/postgres/1826000000000-AddAgentIdToAgentConversation' import { AddVersionToOtp1827000000000 } from './migration/postgres/1827000000000-AddVersionToOtp' import { DropChatbot1828000000000 } from './migration/postgres/1828000000000-DropChatbot' +import { AddFilePlatformIdIndex1829000000000 } from './migration/postgres/1829000000000-AddFilePlatformIdIndex' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -853,6 +854,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAgentIdToAgentConversation1826000000000, AddVersionToOtp1827000000000, DropChatbot1828000000000, + AddFilePlatformIdIndex1829000000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/platform/platform-teardown-jobs.ts b/packages/server/api/src/app/ee/platform/platform-teardown-jobs.ts index 4566ddbdce9c..a2062ad35afd 100644 --- a/packages/server/api/src/app/ee/platform/platform-teardown-jobs.ts +++ b/packages/server/api/src/app/ee/platform/platform-teardown-jobs.ts @@ -1,6 +1,7 @@ import { isNil, unique } from '@activepieces/core-utils' import { Flow, FlowOperationType, FlowStatus, UserStatus } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { IsNull } from 'typeorm' import { appConnectionsRepo } from '../../app-connection/app-connection-service/app-connection-service' import { userIdentityRepository } from '../../authentication/user-identity/user-identity-service' import { repoFactory } from '../../core/db/repo-factory' @@ -55,7 +56,7 @@ export const platformTeardownJobs = (log: FastifyBaseLogger) => ({ await signingKeyRepo().delete({ platformId }) - await fileRepo().delete({ platformId }) + await fileRepo().delete({ platformId, projectId: IsNull() }) await projectRoleRepo().delete({ platformId }) await userInvitationRepo().delete({ platformId }) await mcpOAuthTokenRepo().delete({ platformId }) diff --git a/packages/server/api/src/app/file/file.entity.ts b/packages/server/api/src/app/file/file.entity.ts index 73e59f23c31e..94fd8c4b3fda 100644 --- a/packages/server/api/src/app/file/file.entity.ts +++ b/packages/server/api/src/app/file/file.entity.ts @@ -59,6 +59,11 @@ export const FileEntity = new EntitySchema({ name: 'idx_file_type_created_desc', columns: ['type', 'created'], }, + { + name: 'idx_file_platform_id_null_project', + columns: ['platformId'], + where: '"projectId" IS NULL', + }, { // Real index is a partial expression index on (type, (metadata->>'flowId')), // created in 1815000000000-AddSampleDataFlowIdIndexToFile. EntitySchema can't