WorkspaceFS Sandbox Abstraction
Package: @tagna/udiot/sandbox
Added: v0.16.0
WorkspaceFS is the common interface for code generators, local CLI workflows,
and remote sandbox workers. It separates "what the generator wants to do" from
"where the files and commands actually run."
Why It Exists
Before v0.16, generators wrote directly to the local filesystem. That works for
the CLI, but hosted products need to redirect file writes and commands into an
isolated workspace. WorkspaceFS gives both paths the same contract:
- Read and write files
- List files with globs
- Delete files safely
- Apply diffs
- Run bounded commands
- Start and stop preview servers
- Snapshot the workspace for deploy artifacts
Local Workspace
import { LocalWorkspaceFS } from '@tagna/udiot/sandbox';
const ws = new LocalWorkspaceFS({
rootDir: process.cwd(),
});
await ws.writeFile('src/generated.ts', 'export const ok = true;\n');
const result = await ws.runCommand({
command: 'npm',
args: ['test', '--', '--runInBand'],
timeoutMs: 120000,
});
console.log(result.exitCode, result.stdout);
await ws.dispose();
LocalWorkspaceFS rejects path traversal attempts, creates intermediate
directories on writes, and disables shell execution unless explicitly allowed.
Remote Workspace
Use RemoteWorkspaceFS when a sandbox worker owns the actual filesystem and
processes. The remote implementation sends JSON-over-HTTP RPC requests to your
worker.
import { RemoteWorkspaceFS } from '@tagna/udiot/sandbox';
const ws = new RemoteWorkspaceFS({
baseUrl: 'https://sandbox.example.com',
authToken: process.env.SANDBOX_TOKEN,
});
The remote worker decides how to isolate the workspace. The framework contract
only requires that each operation returns the expected shape. The client
refuses to send authToken over plain http:// to a non-local host unless you
set allowInsecureHttp: true. Calls that carry their own deadline
(runCommand's timeoutMs, startServer's readyTimeoutMs) wait that long
plus a margin instead of the default timeoutMs. See
the sandbox RPC protocol
for the wire contract.
Factory
Use createWorkspaceFS() when product code chooses local or remote execution
from configuration.
import { createWorkspaceFS } from '@tagna/udiot/sandbox';
const ws = process.env.SANDBOX_URL
? createWorkspaceFS({
kind: 'remote',
baseUrl: process.env.SANDBOX_URL,
authToken: process.env.SANDBOX_TOKEN,
})
: createWorkspaceFS({
kind: 'local',
rootDir: process.cwd(),
});
Command Safety
runCommand() takes an argv-style command by default:
await ws.runCommand({
command: 'node',
args: ['scripts/check.js'],
timeoutMs: 30000,
});
Shell evaluation is opt-in:
const ws = new LocalWorkspaceFS({
rootDir: process.cwd(),
allowShell: true,
});
Keep allowShell disabled for user-supplied inputs. Prefer command plus
args so implementations can avoid shell interpolation.
Commands and preview servers do not inherit the host environment. A child sees
a small allow-list of OS variables (PATH, HOME, temp directories, locale and
their Windows equivalents) plus the env you pass, so model-driven commands
cannot read API keys or database credentials from the host process.
const ws = new LocalWorkspaceFS({
rootDir: process.cwd(),
envAllowList: ['NODE_OPTIONS'],
});
await ws.runCommand({
command: 'npm',
args: ['test'],
env: { DATABASE_URL: 'postgres://localhost/test' },
});
Set inheritEnv: true only when every command is trusted. A command that cannot
start, such as a missing executable, rejects with an error whose code is the
spawn error code (for example ENOENT).
Each result keeps at most maxOutputBytes (default 1 MiB) of stdout and of
stderr and sets truncated when output was cut; onChunk still receives every
chunk. A timeout or abort sends SIGTERM to the whole process tree and SIGKILL
after killGraceMs (default 2000 ms). On Windows the tree is ended with
taskkill /T /F. server.stop() and dispose() wait for processes to exit.
applyDiff() accepts unified and git-style diffs, including created, deleted
and renamed files. Every file is checked and patched in memory before anything
is written, so a rejected hunk leaves the workspace unchanged.
Preview Servers
const server = await ws.startServer({
command: 'npm',
args: ['run', 'dev'],
port: 3000,
readyPattern: /ready/i,
});
console.log(await ws.getPreviewUrl(server));
await server.stop();
Local workspaces return localhost URLs. Remote sandboxes can return tunneled or proxy URLs from the sandbox provider.
Generator Integration
Generator code should accept a WorkspaceFS instead of importing
node:fs/promises directly:
import type { WorkspaceFS } from '@tagna/udiot/sandbox';
export async function writeFeature(ws: WorkspaceFS, name: string) {
await ws.writeFile(`src/features/${name}.ts`, 'export {}\n');
}
This keeps the same generator usable from the local CLI, CI jobs, and hosted sandbox execution.