From a8b4e4ebfb8d8d52cb357a1a7161f342970b6a31 Mon Sep 17 00:00:00 2001 From: Ashutosh Saxena <182192262+AlgoArtist06@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:09:01 +0530 Subject: [PATCH 1/5] fix(apps): write Deno runtime config to temp directory --- .changeset/tidy-snails-smile.md | 5 +++ .../runtime/deno/AppsEngineDenoRuntime.ts | 35 ++++++++++++++----- .../DenoRuntimeSubprocessController.test.ts | 34 +++++++++++++++--- 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 .changeset/tidy-snails-smile.md diff --git a/.changeset/tidy-snails-smile.md b/.changeset/tidy-snails-smile.md new file mode 100644 index 0000000000000..934677239386b --- /dev/null +++ b/.changeset/tidy-snails-smile.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/apps": patch +--- + +Fix Deno app startup for non-root containers by writing the generated runtime configuration to the writable temporary directory. diff --git a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts index 430185a94ca77..f4df9013ea514 100644 --- a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts +++ b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts @@ -18,7 +18,7 @@ function getDenoConfigPath(): string { } /** - * Generates a runtime deno.jsonc at `/deno_runtime.jsonc` by reading + * Generates a runtime deno.jsonc in the controller's temporary directory by reading * the static config and injecting the resolved absolute path for * `@rocket.chat/apps-engine/`. This makes deno-runtime location-independent: * the path is always correct regardless of where this package is installed. @@ -37,7 +37,16 @@ function generateEphemeralDenoConfig(targetPath: string, denoConfigPath: string, const runtimeConfig = { ...staticConfig, imports: { - ...staticConfig.imports, + ...Object.fromEntries( + Object.entries(staticConfig.imports ?? {}).map(([specifier, target]) => { + if (!target.startsWith('.')) { + return [specifier, target]; + } + + const resolvedTarget = path.resolve(path.dirname(denoConfigPath), target).split(path.sep).join(path.posix.sep); + return [specifier, `${resolvedTarget}${target.endsWith('/') ? '/' : ''}`]; + }), + ), '@rocket.chat/apps-engine/': `${appsEnginePath}/`, }, }; @@ -50,6 +59,8 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro private readonly denoRuntimePath: string; + private readonly runtimeTempPath: string; + private readonly denoConfigPath: string; private readonly denoEphemeralConfigPath: string; @@ -66,8 +77,9 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro this.packagePath = path.join(path.dirname(this.denoConfigPath), '..'); this.denoDir = process.env.DENO_DIR ?? path.join(this.packagePath, '.deno-cache'); - this.denoRuntimePath = path.join(this.tempFilePath, 'deno-runtime', 'main.ts'); - this.denoEphemeralConfigPath = this.denoConfigPath.replace('.jsonc', '.runtime.jsonc'); + this.runtimeTempPath = fs.mkdtempSync(path.join(this.tempFilePath, 'deno-runtime-')); + this.denoRuntimePath = path.join(this.runtimeTempPath, 'runtime', 'main.ts'); + this.denoEphemeralConfigPath = path.join(this.runtimeTempPath, 'deno.runtime.jsonc'); /** * Deno 2.x refuses to run scripts inside the node_modules, so we create a symlink to the deno runtime files in the temp directory @@ -75,14 +87,19 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro */ try { fs.symlinkSync(path.dirname(this.denoConfigPath), path.dirname(this.denoRuntimePath), 'dir'); + generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath); } catch (reason: unknown) { - if ((reason as NodeJS.ErrnoException).code !== 'EEXIST') { - throw reason; - } + fs.rmSync(this.runtimeTempPath, { recursive: true, force: true }); + throw reason; } + } - // Generate a runtime config with the resolved absolute path for @rocket.chat/apps-engine/ - generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath); + public override async stopApp(): Promise { + try { + await super.stopApp(); + } finally { + fs.rmSync(this.runtimeTempPath, { recursive: true, force: true }); + } } protected buildProcessConfiguration(): ProcessConfiguration { diff --git a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts index d569ca5627d65..50cce6a6ba5b1 100644 --- a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts +++ b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts @@ -38,8 +38,6 @@ describe('DenoRuntimeSubprocessController', () => { const appPackageBuffer = await fs.readFile(path.join(__dirname, '../../test-data/apps/hello-world-test_0.0.1.zip')); appPackage = await manager.getParser().unpackageApp(appPackageBuffer); - await fs.unlink(path.join(manager.getTempFilePath(), 'deno-runtime')).catch(function noop() {}); - appStorageItem = { id: 'hello-world-test', status: AppStatus.MANUALLY_ENABLED, @@ -58,13 +56,39 @@ describe('DenoRuntimeSubprocessController', () => { after( async () => { await controller?.stopApp(); - await fs.unlink(path.join(manager.getTempFilePath(), 'deno-runtime')).catch((reason) => { - console.warn('Failed to delete temporary Deno runtime symlink', reason); - }); }, { timeout: 30_000 }, ); + it('generates the Deno config in the writable host temp directory', async () => { + // eslint-disable-next-line prefer-destructuring -- accessing a private field for testing + const runtimeTempPath = controller['runtimeTempPath']; + const configPath = path.join(runtimeTempPath, 'deno.runtime.jsonc'); + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + const { args } = controller['buildProcessConfiguration'](); + const packagePath = controller['packagePath'].split(path.sep).join(path.posix.sep); + + assert.strictEqual(controller['denoEphemeralConfigPath'], configPath); + assert.strictEqual(path.dirname(runtimeTempPath), manager.getTempFilePath()); + assert.strictEqual(config.imports['@rocket.chat/apps-engine/'], `${controller['appsEnginePath']}/`); + assert.strictEqual(config.imports['@rocket.chat/apps/base-runtime/'], `${packagePath}/base-runtime/src/`); + assert.strictEqual(config.imports['@rocket.chat/apps/'], `${packagePath}/`); + assert.strictEqual(config.imports['@std/io'], 'jsr:@std/io@^0.225.3'); + assert.ok(args.includes(`--config=${configPath}`)); + }); + + it('uses an isolated runtime directory for each controller and removes it on stop', async () => { + const secondController = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); + // eslint-disable-next-line prefer-destructuring -- accessing a private field for testing + const runtimeTempPath = secondController['runtimeTempPath']; + + assert.notStrictEqual(runtimeTempPath, controller['runtimeTempPath']); + assert.strictEqual((await fs.stat(runtimeTempPath)).isDirectory(), true); + + await secondController.stopApp(); + await assert.rejects(fs.stat(runtimeTempPath), { code: 'ENOENT' }); + }); + it('correctly identifies a call to the HTTP accessor', { timeout: 15_000 }, async () => { const httpBridge = manager.getBridges().getHttpBridge(); const doCallSpy = mock.method(httpBridge, 'doCall'); From 034c88ad14d0655d81185d88a3e6bd980abcaf38 Mon Sep 17 00:00:00 2001 From: Ashutosh Saxena <182192262+AlgoArtist06@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:17:59 +0530 Subject: [PATCH 2/5] test(apps): guarantee Deno runtime cleanup --- .../runtime/DenoRuntimeSubprocessController.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts index 50cce6a6ba5b1..0d043a6ffd08b 100644 --- a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts +++ b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts @@ -82,10 +82,13 @@ describe('DenoRuntimeSubprocessController', () => { // eslint-disable-next-line prefer-destructuring -- accessing a private field for testing const runtimeTempPath = secondController['runtimeTempPath']; - assert.notStrictEqual(runtimeTempPath, controller['runtimeTempPath']); - assert.strictEqual((await fs.stat(runtimeTempPath)).isDirectory(), true); + try { + assert.notStrictEqual(runtimeTempPath, controller['runtimeTempPath']); + assert.strictEqual((await fs.stat(runtimeTempPath)).isDirectory(), true); + } finally { + await secondController.stopApp(); + } - await secondController.stopApp(); await assert.rejects(fs.stat(runtimeTempPath), { code: 'ENOENT' }); }); From aa292008d351981b53273f3686704ea1ed0f5b56 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 15 Jul 2026 21:03:31 -0300 Subject: [PATCH 3/5] fix: simplify implementation --- .changeset/tidy-snails-smile.md | 2 +- .../runtime/deno/AppsEngineDenoRuntime.ts | 40 ++++++------------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/.changeset/tidy-snails-smile.md b/.changeset/tidy-snails-smile.md index 934677239386b..78f38452a04cd 100644 --- a/.changeset/tidy-snails-smile.md +++ b/.changeset/tidy-snails-smile.md @@ -2,4 +2,4 @@ "@rocket.chat/apps": patch --- -Fix Deno app startup for non-root containers by writing the generated runtime configuration to the writable temporary directory. +Fixes app startup failing with EACCESS errors in docker deployments running with specified UIDs diff --git a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts index f4df9013ea514..960bcfa84135e 100644 --- a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts +++ b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts @@ -18,14 +18,14 @@ function getDenoConfigPath(): string { } /** - * Generates a runtime deno.jsonc in the controller's temporary directory by reading + * Generates a runtime deno.jsonc at `/deno_runtime.jsonc` by reading * the static config and injecting the resolved absolute path for * `@rocket.chat/apps-engine/`. This makes deno-runtime location-independent: * the path is always correct regardless of where this package is installed. * * Returns the path to the generated config file. */ -function generateEphemeralDenoConfig(targetPath: string, denoConfigPath: string, appsEnginePath: string): void { +function generateEphemeralDenoConfig(targetPath: string, denoConfigPath: string, appsEnginePath: string, packagePath: string): void { let staticConfig: DenoConfigurationFileSchema; try { @@ -37,17 +37,10 @@ function generateEphemeralDenoConfig(targetPath: string, denoConfigPath: string, const runtimeConfig = { ...staticConfig, imports: { - ...Object.fromEntries( - Object.entries(staticConfig.imports ?? {}).map(([specifier, target]) => { - if (!target.startsWith('.')) { - return [specifier, target]; - } - - const resolvedTarget = path.resolve(path.dirname(denoConfigPath), target).split(path.sep).join(path.posix.sep); - return [specifier, `${resolvedTarget}${target.endsWith('/') ? '/' : ''}`]; - }), - ), + ...staticConfig.imports, '@rocket.chat/apps-engine/': `${appsEnginePath}/`, + '@rocket.chat/apps/base-runtime/': `${path.join(packagePath, 'base-runtime', 'src')}/`, + '@rocket.chat/apps/': `${packagePath}/`, }, }; @@ -59,8 +52,6 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro private readonly denoRuntimePath: string; - private readonly runtimeTempPath: string; - private readonly denoConfigPath: string; private readonly denoEphemeralConfigPath: string; @@ -77,9 +68,9 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro this.packagePath = path.join(path.dirname(this.denoConfigPath), '..'); this.denoDir = process.env.DENO_DIR ?? path.join(this.packagePath, '.deno-cache'); - this.runtimeTempPath = fs.mkdtempSync(path.join(this.tempFilePath, 'deno-runtime-')); - this.denoRuntimePath = path.join(this.runtimeTempPath, 'runtime', 'main.ts'); - this.denoEphemeralConfigPath = path.join(this.runtimeTempPath, 'deno.runtime.jsonc'); + this.denoRuntimePath = path.join(this.tempFilePath, 'deno-runtime', 'main.ts'); + // this.denoEphemeralConfigPath = this.denoConfigPath.replace('.jsonc', '.runtime.jsonc'); + this.denoEphemeralConfigPath = path.join(this.tempFilePath, 'deno.runtime.jsonc'); /** * Deno 2.x refuses to run scripts inside the node_modules, so we create a symlink to the deno runtime files in the temp directory @@ -87,19 +78,14 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro */ try { fs.symlinkSync(path.dirname(this.denoConfigPath), path.dirname(this.denoRuntimePath), 'dir'); - generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath); } catch (reason: unknown) { - fs.rmSync(this.runtimeTempPath, { recursive: true, force: true }); - throw reason; + if ((reason as NodeJS.ErrnoException).code !== 'EEXIST') { + throw reason; + } } - } - public override async stopApp(): Promise { - try { - await super.stopApp(); - } finally { - fs.rmSync(this.runtimeTempPath, { recursive: true, force: true }); - } + // Generate a runtime config with the resolved absolute path for @rocket.chat/apps-engine/ and @rocket.chat/apps/ paths + generateEphemeralDenoConfig(this.denoEphemeralConfigPath, this.denoConfigPath, this.appsEnginePath, this.packagePath); } protected buildProcessConfiguration(): ProcessConfiguration { From 87a4fb49d5a8f6ef824b1724e57a3d57538400d4 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 15 Jul 2026 21:18:37 -0300 Subject: [PATCH 4/5] test: revert isolation tests --- .../runtime/deno/AppsEngineDenoRuntime.ts | 1 - .../DenoRuntimeSubprocessController.test.ts | 37 +++---------------- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts index 960bcfa84135e..8641bdcabe73a 100644 --- a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts +++ b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts @@ -69,7 +69,6 @@ export class DenoRuntimeSubprocessController extends BaseRuntimeSubprocessContro this.denoDir = process.env.DENO_DIR ?? path.join(this.packagePath, '.deno-cache'); this.denoRuntimePath = path.join(this.tempFilePath, 'deno-runtime', 'main.ts'); - // this.denoEphemeralConfigPath = this.denoConfigPath.replace('.jsonc', '.runtime.jsonc'); this.denoEphemeralConfigPath = path.join(this.tempFilePath, 'deno.runtime.jsonc'); /** diff --git a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts index 0d043a6ffd08b..d569ca5627d65 100644 --- a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts +++ b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts @@ -38,6 +38,8 @@ describe('DenoRuntimeSubprocessController', () => { const appPackageBuffer = await fs.readFile(path.join(__dirname, '../../test-data/apps/hello-world-test_0.0.1.zip')); appPackage = await manager.getParser().unpackageApp(appPackageBuffer); + await fs.unlink(path.join(manager.getTempFilePath(), 'deno-runtime')).catch(function noop() {}); + appStorageItem = { id: 'hello-world-test', status: AppStatus.MANUALLY_ENABLED, @@ -56,42 +58,13 @@ describe('DenoRuntimeSubprocessController', () => { after( async () => { await controller?.stopApp(); + await fs.unlink(path.join(manager.getTempFilePath(), 'deno-runtime')).catch((reason) => { + console.warn('Failed to delete temporary Deno runtime symlink', reason); + }); }, { timeout: 30_000 }, ); - it('generates the Deno config in the writable host temp directory', async () => { - // eslint-disable-next-line prefer-destructuring -- accessing a private field for testing - const runtimeTempPath = controller['runtimeTempPath']; - const configPath = path.join(runtimeTempPath, 'deno.runtime.jsonc'); - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); - const { args } = controller['buildProcessConfiguration'](); - const packagePath = controller['packagePath'].split(path.sep).join(path.posix.sep); - - assert.strictEqual(controller['denoEphemeralConfigPath'], configPath); - assert.strictEqual(path.dirname(runtimeTempPath), manager.getTempFilePath()); - assert.strictEqual(config.imports['@rocket.chat/apps-engine/'], `${controller['appsEnginePath']}/`); - assert.strictEqual(config.imports['@rocket.chat/apps/base-runtime/'], `${packagePath}/base-runtime/src/`); - assert.strictEqual(config.imports['@rocket.chat/apps/'], `${packagePath}/`); - assert.strictEqual(config.imports['@std/io'], 'jsr:@std/io@^0.225.3'); - assert.ok(args.includes(`--config=${configPath}`)); - }); - - it('uses an isolated runtime directory for each controller and removes it on stop', async () => { - const secondController = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); - // eslint-disable-next-line prefer-destructuring -- accessing a private field for testing - const runtimeTempPath = secondController['runtimeTempPath']; - - try { - assert.notStrictEqual(runtimeTempPath, controller['runtimeTempPath']); - assert.strictEqual((await fs.stat(runtimeTempPath)).isDirectory(), true); - } finally { - await secondController.stopApp(); - } - - await assert.rejects(fs.stat(runtimeTempPath), { code: 'ENOENT' }); - }); - it('correctly identifies a call to the HTTP accessor', { timeout: 15_000 }, async () => { const httpBridge = manager.getBridges().getHttpBridge(); const doCallSpy = mock.method(httpBridge, 'doCall'); From 88dd944097dfc0228105c93b1052ee9d2870d395 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 15 Jul 2026 23:44:52 -0300 Subject: [PATCH 5/5] Update .changeset/tidy-snails-smile.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .changeset/tidy-snails-smile.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-snails-smile.md b/.changeset/tidy-snails-smile.md index 78f38452a04cd..546ec86bd8e63 100644 --- a/.changeset/tidy-snails-smile.md +++ b/.changeset/tidy-snails-smile.md @@ -2,4 +2,4 @@ "@rocket.chat/apps": patch --- -Fixes app startup failing with EACCESS errors in docker deployments running with specified UIDs +Fixes app startup failing with EACCES errors in docker deployments running with specified UIDs