From 96f60da0c6bd8538cb641316731ca1206fbcd0f5 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:38:40 +1030 Subject: [PATCH 1/2] fix(cli): run local components under the permission model with a minimal env thatopen run and local-server forked the engine script with the developer's whole environment and no execution flags, so a component could write files, spawn processes and start workers locally while the cloud worker denies them. Fork through a shared forkEngine helper that applies --permission (reads limited to the project, the engine script and node_modules above it, using real paths), passes only NODE_PATH, and write the temp script with mode 600. --- .changeset/local-runner-permission.md | 5 +++ docs/cli/local-server.md | 2 + docs/cli/run.md | 2 + src/cli/commands/run.ts | 20 +++++----- src/cli/lib/engine-fork.test.ts | 54 +++++++++++++++++++++++++++ src/cli/lib/engine-fork.ts | 45 ++++++++++++++++++++++ src/cli/lib/execution-manager.ts | 13 ++----- 7 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 .changeset/local-runner-permission.md create mode 100644 src/cli/lib/engine-fork.test.ts create mode 100644 src/cli/lib/engine-fork.ts diff --git a/.changeset/local-runner-permission.md b/.changeset/local-runner-permission.md new file mode 100644 index 0000000..b3456d5 --- /dev/null +++ b/.changeset/local-runner-permission.md @@ -0,0 +1,5 @@ +--- +'@thatopen/services': patch +--- + +CLI: `thatopen run` and `thatopen local-server` now run the component under Node's permission model with a minimal environment, as the cloud worker does, and write the temporary engine script with mode 600. diff --git a/docs/cli/local-server.md b/docs/cli/local-server.md index 66a9413..e979789 100644 --- a/docs/cli/local-server.md +++ b/docs/cli/local-server.md @@ -6,6 +6,8 @@ description: "Start a local HTTP + Socket.IO server that replicates the platform Starts a local server that mimics the platform's cloud execution API. Runs esbuild in watch mode and rebuilds the component on every save. +Each execution runs under Node's permission model, as in the cloud: the component can read its project folder and `node_modules`, but cannot write files, start child processes or worker threads, and it gets none of your shell's environment variables. + Connect an app to it by passing `localServerUrl` to `EngineServicesClient`: ```ts diff --git a/docs/cli/run.md b/docs/cli/run.md index 4232a38..88d208b 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -8,6 +8,8 @@ Builds and executes a cloud component locally, replicating what the platform's c Execution output (`MESSAGE`, `PROGRESS`, `SUCCESS`, `FAIL`) is streamed to the terminal with elapsed time. +The component runs under Node's permission model, as in the cloud: it can read its project folder and `node_modules`, but cannot write files, start child processes or worker threads, and it gets none of your shell's environment variables. On a Node version without the permission model, a warning is printed and these restrictions do not apply. + **Usage:** ```bash thatopen run [flags] diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 9176da2..b7b80df 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -1,11 +1,12 @@ import { Command } from 'commander'; import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; -import { execSync, fork } from 'node:child_process'; +import { execSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { requireResolvedConfig, readLocalConfig } from '../lib/config'; import { buildEngineScript } from '../lib/engine-script'; +import { forkEngine, permissionModelAvailable } from '../lib/engine-fork'; import { readDeclarations, validateParams } from '../lib/declarations'; export const runCommand = new Command('run') @@ -88,19 +89,18 @@ export const runCommand = new Command('run') ); const tmpFile = join(tmpdir(), `thatopen-run-${executionId}.js`); - writeFileSync(tmpFile, engineScript); + writeFileSync(tmpFile, engineScript, { mode: 0o600 }); console.log('Running cloud component...\n'); - // Fork as child process (mirrors backend ProcessorService behavior) + // Fork as child process (mirrors backend ProcessorService behavior, including its permission model) + if (!permissionModelAvailable) { + console.warn( + 'This Node version has no permission model: the component runs without the cloud\'s file system and process restrictions.', + ); + } const startTime = Date.now(); - const child = fork(tmpFile, [], { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { - ...process.env, - NODE_PATH: join(cwd, 'node_modules'), - }, - }); + const child = forkEngine(cwd, tmpFile); const cleanup = () => { try { diff --git a/src/cli/lib/engine-fork.test.ts b/src/cli/lib/engine-fork.test.ts new file mode 100644 index 0000000..6aa6ac6 --- /dev/null +++ b/src/cli/lib/engine-fork.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { forkEngine, permissionModelAvailable } from './engine-fork'; + +/** + * A locally run component must not get more than the cloud worker gives it: + * no file writes, child processes or worker threads, and none of the + * developer's environment. Module resolution (own and hoisted node_modules) + * must keep working. + */ +describe.runIf(permissionModelAvailable)('forkEngine', () => { + it('runs the component under the permission model with a minimal env', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'thatopen-fork-'))); + const project = join(root, 'component'); + // A workspace layout: the component's own dependency requires one that + // is hoisted to the root node_modules. The engine script lives elsewhere, + // as the CLI writes it to the temp folder. + mkdirSync(join(root, 'node_modules', 'hoisted'), { recursive: true }); + mkdirSync(join(project, 'node_modules', 'own'), { recursive: true }); + writeFileSync(join(root, 'node_modules', 'hoisted', 'index.js'), 'module.exports = 42;'); + writeFileSync(join(project, 'node_modules', 'own', 'index.js'), "module.exports = require('hoisted');"); + mkdirSync(join(root, 'tmp')); + const script = join(root, 'tmp', 'engine.js'); + writeFileSync( + script, + `const r = {}; + const attempt = (name, fn) => { try { fn(); r[name] = 'allowed'; } catch (e) { r[name] = e.code || e.message; } }; + attempt('write', () => require('fs').writeFileSync(${JSON.stringify(join(project, 'out.txt'))}, '1')); + attempt('spawn', () => require('child_' + 'process').execFileSync(process.execPath, ['-e', '0'])); + attempt('worker', () => new (require('worker_' + 'threads').Worker)('0', { eval: true })); + r.hoisted = require('own'); + r.canary = process.env.THATOPEN_FORK_CANARY ?? null; + process.send(r, () => process.exit(0));`, + ); + + process.env.THATOPEN_FORK_CANARY = 'leaked'; + const result = await new Promise>((resolve, reject) => { + const child = forkEngine(project, script); + child.on('message', (m) => resolve(m as Record)); + child.on('error', reject); + child.on('exit', (code) => reject(new Error(`exited ${code} without a result`))); + }).finally(() => delete process.env.THATOPEN_FORK_CANARY); + + expect(result).toEqual({ + write: 'ERR_ACCESS_DENIED', + spawn: 'ERR_ACCESS_DENIED', + worker: 'ERR_ACCESS_DENIED', + hoisted: 42, + canary: null, + }); + }); +}); diff --git a/src/cli/lib/engine-fork.ts b/src/cli/lib/engine-fork.ts new file mode 100644 index 0000000..32b74b5 --- /dev/null +++ b/src/cli/lib/engine-fork.ts @@ -0,0 +1,45 @@ +import { existsSync, realpathSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fork, type ChildProcess } from 'node:child_process'; + +const flags = process.allowedNodeEnvironmentFlags; +const PERMISSION_FLAG = flags.has('--permission') + ? '--permission' + : flags.has('--experimental-permission') + ? '--experimental-permission' + : undefined; + +/** + * Forks the engine script for a locally run cloud component, matching the cloud + * worker: Node's permission model (no file writes, child processes or worker + * threads) and none of the developer's environment variables. The component + * may read its project folder, the engine script and any node_modules above + * the project (hoisted workspaces). + * + * Paths are resolved first: the permission model checks real paths, and the + * system temp folder is a symlink on macOS. + */ +export function forkEngine(cwd: string, scriptPath: string): ChildProcess { + const script = realpathSync(scriptPath); + const project = realpathSync(cwd); + const readable = [script, project]; + for (let dir = dirname(project); ; dir = dirname(dir)) { + const modules = join(dir, 'node_modules'); + if (existsSync(modules)) readable.push(modules); + if (dirname(dir) === dir) break; + } + + const env: NodeJS.ProcessEnv = { NODE_PATH: join(project, 'node_modules') }; + // Windows needs SystemRoot for crypto and networking in the child. + if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; + + return fork(script, [], { + execArgv: PERMISSION_FLAG + ? [PERMISSION_FLAG, ...readable.map((p) => `--allow-fs-read=${p}`)] + : [], + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env, + }); +} + +export const permissionModelAvailable = PERMISSION_FLAG !== undefined; diff --git a/src/cli/lib/execution-manager.ts b/src/cli/lib/execution-manager.ts index 85562b8..62c80e5 100644 --- a/src/cli/lib/execution-manager.ts +++ b/src/cli/lib/execution-manager.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; -import { fork, ChildProcess } from 'node:child_process'; +import { ChildProcess } from 'node:child_process'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { buildEngineScript } from './engine-script'; +import { forkEngine } from './engine-fork'; // ─── Types ──────────────────────────────────────────────────────── @@ -122,7 +123,7 @@ export class ExecutionManager { ); const tmpFile = join(tmpdir(), `thatopen-local-${executionId}.js`); - writeFileSync(tmpFile, engineScript); + writeFileSync(tmpFile, engineScript, { mode: 0o600 }); const state: ExecutionState = { _id: executionId, @@ -138,13 +139,7 @@ export class ExecutionManager { this.executions.set(executionId, state); - const child = fork(tmpFile, [], { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { - ...process.env, - NODE_PATH: join(config.cwd, 'node_modules'), - }, - }); + const child = forkEngine(config.cwd, tmpFile); state.childProcess = child; From 02bc8b6539251d6cc579588a09e75791b55586af Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:48:24 +1030 Subject: [PATCH 2/2] fix(cli): also allow node_modules above the engine script Node searches node_modules above the temp script before NODE_PATH. When one exists there (e.g. in the home folder on Windows), the permission model made the lookup throw ERR_ACCESS_DENIED instead of moving on. Allow that chain too, so resolution is unchanged. --- src/cli/lib/engine-fork.test.ts | 11 +++++++++-- src/cli/lib/engine-fork.ts | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/cli/lib/engine-fork.test.ts b/src/cli/lib/engine-fork.test.ts index 6aa6ac6..5f55436 100644 --- a/src/cli/lib/engine-fork.test.ts +++ b/src/cli/lib/engine-fork.test.ts @@ -21,8 +21,13 @@ describe.runIf(permissionModelAvailable)('forkEngine', () => { mkdirSync(join(project, 'node_modules', 'own'), { recursive: true }); writeFileSync(join(root, 'node_modules', 'hoisted', 'index.js'), 'module.exports = 42;'); writeFileSync(join(project, 'node_modules', 'own', 'index.js'), "module.exports = require('hoisted');"); - mkdirSync(join(root, 'tmp')); - const script = join(root, 'tmp', 'engine.js'); + // node_modules above the temp folder are searched before NODE_PATH, as + // happens with a node_modules in the user's home folder. + mkdirSync(join(root, 'tmp', 'node_modules', 'scriptside'), { recursive: true }); + writeFileSync(join(root, 'tmp', 'node_modules', 'scriptside', 'package.json'), '{"main":"main.js"}'); + writeFileSync(join(root, 'tmp', 'node_modules', 'scriptside', 'main.js'), "module.exports = 'found';"); + const script = join(root, 'tmp', 'run', 'engine.js'); + mkdirSync(join(root, 'tmp', 'run')); writeFileSync( script, `const r = {}; @@ -31,6 +36,7 @@ describe.runIf(permissionModelAvailable)('forkEngine', () => { attempt('spawn', () => require('child_' + 'process').execFileSync(process.execPath, ['-e', '0'])); attempt('worker', () => new (require('worker_' + 'threads').Worker)('0', { eval: true })); r.hoisted = require('own'); + r.scriptside = require('scriptside'); r.canary = process.env.THATOPEN_FORK_CANARY ?? null; process.send(r, () => process.exit(0));`, ); @@ -48,6 +54,7 @@ describe.runIf(permissionModelAvailable)('forkEngine', () => { spawn: 'ERR_ACCESS_DENIED', worker: 'ERR_ACCESS_DENIED', hoisted: 42, + scriptside: 'found', canary: null, }); }); diff --git a/src/cli/lib/engine-fork.ts b/src/cli/lib/engine-fork.ts index 32b74b5..c9ddd30 100644 --- a/src/cli/lib/engine-fork.ts +++ b/src/cli/lib/engine-fork.ts @@ -14,7 +14,7 @@ const PERMISSION_FLAG = flags.has('--permission') * worker: Node's permission model (no file writes, child processes or worker * threads) and none of the developer's environment variables. The component * may read its project folder, the engine script and any node_modules above - * the project (hoisted workspaces). + * either of them (hoisted workspaces, or a node_modules in the home folder). * * Paths are resolved first: the permission model checks real paths, and the * system temp folder is a symlink on macOS. @@ -22,11 +22,16 @@ const PERMISSION_FLAG = flags.has('--permission') export function forkEngine(cwd: string, scriptPath: string): ChildProcess { const script = realpathSync(scriptPath); const project = realpathSync(cwd); - const readable = [script, project]; - for (let dir = dirname(project); ; dir = dirname(dir)) { - const modules = join(dir, 'node_modules'); - if (existsSync(modules)) readable.push(modules); - if (dirname(dir) === dir) break; + const readable = new Set([script, project]); + // Node searches node_modules above the script before NODE_PATH, and the + // dependencies' own lookups climb above the project: allow both chains, so + // modules resolve exactly as they did without the permission model. + for (const start of [dirname(script), dirname(project)]) { + for (let dir = start; ; dir = dirname(dir)) { + const modules = join(dir, 'node_modules'); + if (existsSync(modules)) readable.add(modules); + if (dirname(dir) === dir) break; + } } const env: NodeJS.ProcessEnv = { NODE_PATH: join(project, 'node_modules') }; @@ -35,7 +40,7 @@ export function forkEngine(cwd: string, scriptPath: string): ChildProcess { return fork(script, [], { execArgv: PERMISSION_FLAG - ? [PERMISSION_FLAG, ...readable.map((p) => `--allow-fs-read=${p}`)] + ? [PERMISSION_FLAG, ...[...readable].map((p) => `--allow-fs-read=${p}`)] : [], stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env,