Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-runner-permission.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/cli/local-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/cli/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 10 additions & 10 deletions src/cli/commands/run.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions src/cli/lib/engine-fork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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');");
// 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 = {};
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.scriptside = require('scriptside');
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<Record<string, unknown>>((resolve, reject) => {
const child = forkEngine(project, script);
child.on('message', (m) => resolve(m as Record<string, unknown>));
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,
scriptside: 'found',
canary: null,
});
});
});
50 changes: 50 additions & 0 deletions src/cli/lib/engine-fork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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
* 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.
*/
export function forkEngine(cwd: string, scriptPath: string): ChildProcess {
const script = realpathSync(scriptPath);
const project = realpathSync(cwd);
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') };
// 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;
13 changes: 4 additions & 9 deletions src/cli/lib/execution-manager.ts
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand Down