Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
125723a
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
e4cbcad
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
c43060f
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
0604b72
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
5099871
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
12a734a
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
e20bb93
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
c5e1502
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
f832490
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
726869a
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
13d8631
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
eda1537
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
4f0ce6d
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
8d2473c
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
055655d
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
4906f63
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
de16c45
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
3466de3
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
41a6c20
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
6d9dd41
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
4a5caa7
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
819bd2c
feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX…
jiangkoumo Oct 22, 2026
13a791a
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
13361ba
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
fed0249
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
603b561
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
1f2307e
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
4a66f35
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
cba6af0
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
f71df4a
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
0466af2
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
9e0b6a0
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
fd35180
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
0cf539a
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
e88bd55
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
0f156e3
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
3fa366c
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
d53d4fb
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
d463067
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
8c22d17
fix(sandbox): address the first review of the local runtime (#402)
jiangkoumo Oct 22, 2026
ada323c
Merge upstream main 4497b0cbf (v1.1.0, #428) into PR #402 (local port)
jiangkoumo Oct 24, 2026
0cc863a
Merge upstream main ad78914e3 (#449, per-Store overlay name) into PR …
jiangkoumo Oct 25, 2026
766e67e
fix(sandbox): detach local mounts before stopping the daemon
jiangkoumo Oct 26, 2026
3e427a3
Merge upstream main caebd268d (D8 three-layer split, #465) into PR #4…
jiangkoumo Oct 27, 2026
8b11778
style: rustfmt
jiangkoumo Oct 27, 2026
eb21fbb
test(node): cover local Sandbox records through the built addon (#402)
jiangkoumo Oct 27, 2026
8b17038
style: rustfmt
jiangkoumo Oct 27, 2026
bf50a5f
docs(node): list native_local in the binding test guide (#402)
jiangkoumo Oct 27, 2026
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
27 changes: 26 additions & 1 deletion api/agent/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,18 @@ export function sandboxArg(sandbox) {

/** Deployment settings from the client options, else the environment
* (LAYERFS_<NAME>). A Workspace's Sandbox needs
* every one but the container prefix and daemon host. */
* every one but the container prefix and daemon host. With `sandbox`
* (LAYERFS_SANDBOX) `local`, a Sandbox is a daemon process on this machine
* instead of a container: it needs the daemon and `localDir`
* (LAYERFS_LOCAL_DIR) and no Docker setting. Unset or `docker` is Docker;
* any other value is refused. */
export function deployment(options = {}) {
const env = (name) => process.env[`LAYERFS_${name}`] || undefined;
const sandbox = options.sandbox ?? env("SANDBOX");
if (sandbox === "local") return localDeployment(options, env);
if (sandbox !== undefined && sandbox !== "docker") {
throw new LayerFSError("InvalidInput", `deploy.sandbox (LAYERFS_SANDBOX) is docker or local, not ${sandbox}`, null);
}
const deploy = {
docker: options.docker ?? env("DOCKER") ?? "/var/run/docker.sock",
image: options.image ?? env("IMAGE"),
Expand All @@ -80,6 +89,22 @@ export function deployment(options = {}) {
return deploy;
}

/** A local Sandbox's settings: the daemon, the directory its Sandboxes
* live in, and the optional name prefix. */
function localDeployment(options, env) {
const deploy = {
sandbox: "local",
daemon: options.daemon ?? env("DAEMON"),
localDir: options.localDir ?? env("LOCAL_DIR"),
containerPrefix: options.containerPrefix ?? env("CONTAINER_PREFIX"),
};
for (const [name, setting] of [["daemon", "DAEMON"], ["localDir", "LOCAL_DIR"]]) {
if (!deploy[name]) throw new LayerFSError("InvalidInput", `deploy.${name} is not configured (LAYERFS_${setting})`, null);
}
if (!deploy.containerPrefix) delete deploy.containerPrefix;
return deploy;
}

/** Refuses every call on a closed client (or one no factory opened). */
export function live(client) {
if (client?.[STATE]?.closed !== false) {
Expand Down
7 changes: 6 additions & 1 deletion api/agent/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export class LayerFS {
* settings read LAYERFS_HOME, _DOCKER, _IMAGE, _VOLUME and _DAEMON; a
* Sandbox this client starts runs _IMAGE
* on _VOLUME. LAYERFS_CONTAINER_PREFIX (default `layerfs-`) names every
* container it starts `<prefix><project>-<n>`.
* container it starts `<prefix><project>-<n>`. LAYERFS_SANDBOX=local
* starts each Sandbox as a daemon process on this machine instead of a
* container, in a directory under LAYERFS_LOCAL_DIR (no Docker setting is
* read; it isolates nothing, so use it only inside a VM or CI box).
* LAYERFS_DAEMON_HOST is the host this process
* dials for a daemon's port, published on the Docker host's 127.0.0.1 only;
* set it when the client runs in a container (`host.docker.internal` on
Expand Down Expand Up @@ -129,6 +132,8 @@ export class LayerFS {
volume: env("VOLUME"),
daemon: env("DAEMON"),
...(env("CONTAINER_PREFIX") ? { containerPrefix: env("CONTAINER_PREFIX") } : {}),
...(env("SANDBOX") ? { sandbox: env("SANDBOX") } : {}),
...(env("LOCAL_DIR") ? { localDir: env("LOCAL_DIR") } : {}),
...(env("DAEMON_HOST") ? { daemonHost: env("DAEMON_HOST") } : {}),
},
storePolicy: env("STORE_POLICY"),
Expand Down
103 changes: 103 additions & 0 deletions api/agent/test/local_deployment.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Which runtime a Sandbox uses. No real addon, Store or Docker (the
// stand-in binding answers the client's calls):
// node api/agent/sdk/test/local_deployment.mjs
// LAYERFS_SANDBOX unset or `docker` gives exactly the Docker deployment it
// always gave; `local` needs only the daemon and LAYERFS_LOCAL_DIR (no Docker
// setting); any other value is refused rather than taken as either.
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { deployment } from "../src/client.js";

const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url));
const native = createRequire(import.meta.url)(FAKE);

const clear = () => {
for (const name of Object.keys(process.env)) {
if (name.startsWith("LAYERFS_") || name.startsWith("DSH_LAYERFS_")) delete process.env[name];
}
process.env.LAYERFS_NATIVE = FAKE;
};
const docker = { docker: "/run/docker.sock", image: `sha256:${"a".repeat(64)}`, volume: "lfs-v", daemon: "/opt/layerfs-daemon" };
const setDocker = () => {
process.env.LAYERFS_DOCKER = docker.docker;
process.env.LAYERFS_IMAGE = docker.image;
process.env.LAYERFS_VOLUME = docker.volume;
process.env.LAYERFS_DAEMON = docker.daemon;
};
const store = () => {
process.env.LAYERFS_HOME = "/private/local-home";
process.env.LAYERFS_STORE_PATH = "/fake/store.sqlite";
};

// unset: the Docker deployment, field for field
clear();
setDocker();
assert.deepEqual(deployment(), docker);
// `docker` is the same deployment
process.env.LAYERFS_SANDBOX = "docker";
assert.deepEqual(deployment(), docker);
// the Docker settings are still required there
delete process.env.LAYERFS_IMAGE;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_IMAGE/.test(e.message));

// local: daemon + localDir, no Docker setting read or required
clear();
process.env.LAYERFS_SANDBOX = "local";
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
process.env.LAYERFS_LOCAL_DIR = "/store/local";
assert.deepEqual(deployment(), { sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" });
// an image (sandbox.create's option) does not change a local deployment
assert.deepEqual(deployment({ image: docker.image }), { sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" });
// the container prefix names local Sandbox directories too
process.env.LAYERFS_CONTAINER_PREFIX = "ci-";
assert.equal(deployment().containerPrefix, "ci-");
delete process.env.LAYERFS_CONTAINER_PREFIX;
// each required setting is named when missing
delete process.env.LAYERFS_LOCAL_DIR;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_LOCAL_DIR/.test(e.message));
process.env.LAYERFS_LOCAL_DIR = "/store/local";
delete process.env.LAYERFS_DAEMON;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_DAEMON/.test(e.message));
// since #428 the SDK reads LAYERFS_ names only: a DSH_LAYERFS_ twin selects nothing
clear();
setDocker();
process.env.DSH_LAYERFS_SANDBOX = "local";
process.env.DSH_LAYERFS_LOCAL_DIR = "/l";
assert.deepEqual(deployment(), docker);

// anything else is refused, never silently Docker
for (const typo of ["Local", "lokal", "LOCAL", "podman"]) {
clear();
setDocker();
process.env.LAYERFS_SANDBOX = typo;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && e.message.includes(`docker or local, not ${typo}`));
}

// a client reads LAYERFS_SANDBOX and LAYERFS_LOCAL_DIR once, and
// sandbox.create sends the local deployment (no Docker setting)
clear();
store();
process.env.LAYERFS_SANDBOX = "local";
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
process.env.LAYERFS_LOCAL_DIR = "/store/local";
const { LayerFS } = await import("../src/index.js");
const client = await LayerFS.open("website");
assert.equal(client.options.deploy.sandbox, "local");
assert.equal(client.options.deploy.localDir, "/store/local");
const before = native.deploys.length;
await client.sandbox.create();
assert.deepEqual(native.deploys.slice(before), [{ sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" }]);
// an image given to sandbox.create does not turn it into a Docker deployment
await client.sandbox.create({ image: docker.image });
assert.deepEqual(native.deploys.at(-1), { sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" });
await client.close();
// unset: the client carries no new key
clear();
store();
setDocker();
const plain = await LayerFS.open("website");
assert.equal("sandbox" in plain.options.deploy, false);
assert.equal("localDir" in plain.options.deploy, false);
await plain.close();
console.log("local_deployment: ok");
47 changes: 46 additions & 1 deletion api/docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async and return a ready client, and `new LayerFS()` is refused
`InvalidInput`. **There is no options argument:** every setting comes from the
environment, read once when the client is made, by its `LAYERFS_` name only:
`LAYERFS_HOME`, `_STORE_PATH`, `_DOCKER`, `_IMAGE`,
`_VOLUME`, `_DAEMON`, `_CONTAINER_PREFIX`, `_DAEMON_HOST`, `_TRACE`,
`_VOLUME`, `_DAEMON`, `_CONTAINER_PREFIX`, `_DAEMON_HOST`, `_SANDBOX`, `_LOCAL_DIR`, `_TRACE`,
`_STORE_POLICY` (and `_TRACE_STEP`, read at each call). An object passed where
the options went (`LayerFS.open(name, {…})`, or an object as `LayerFS.init`'s
third argument) is refused `InvalidInput`, never ignored. `open` opens the project's Store and makes one project lookup
Expand Down Expand Up @@ -145,6 +145,8 @@ Keep each agent's operations on a Workspace sequential.
| `LAYERFS_STORE_PATH` | The installed Store file this client's Store calls open: `global/store.sqlite` in the project's volume. On Linux this process opens it, so it must be on a local filesystem the daemons share, such as `/layerfs-store/global/store.sqlite` where the volume is mounted at `/layerfs-store`; otherwise Store calls fail `Unsupported`. On macOS and Windows the worker opens it, so it is the path inside the worker's Linux, `/var/lib/docker/volumes/<volume>/_data/global/store.sqlite`. On any other platform Store calls fail `Unsupported`. Unset, they fail `InvalidInput` everywhere. |
| `LAYERFS_CONTAINER_PREFIX` | Optional. Names every container this client creates `<prefix><project>-<Sandbox>` (default prefix `layerfs-`); another LayerFS home on the same Docker engine sets its own, so names never collide. Docker's name rule applies (an ASCII letter or digit, then letters, digits, `_`, `.`, `-`, at most 128). A name another container holds refuses the create before any effect. |
| `LAYERFS_DAEMON_HOST` | Optional. The host this process dials for a daemon's port. Each daemon publishes its port on the Docker host's `127.0.0.1` only, and that stays so. A client inside a container does not share that loopback, so it names the host that reaches it, `host.docker.internal` on Docker Desktop. One resolution, its first address, one connect; nothing is tried again. Unset, the client dials `127.0.0.1`. |
| `LAYERFS_SANDBOX` | Optional. Unset or `docker`: a Sandbox is a Docker container, exactly as described here. `local`: a Sandbox is a daemon process on this machine, with no container and no Docker setting read (see [Local Sandboxes](#local-sandboxes-no-docker)). Any other value is refused as `InvalidInput` before anything is made. |
| `LAYERFS_LOCAL_DIR` | With `LAYERFS_SANDBOX=local`, required: an existing absolute directory, owned by the running user, not group/other writable and traversable by others (0755). Each local Sandbox gets its own directory under it. |
| `LAYERFS_TRACE` | Optional per-call trace file (see [the call trace](backend.md#the-call-trace)); unset means no trace. |
| `LAYERFS_TRACE_STEP` | Optional step label put on each trace line as `step`; read at every call. |
| `LAYERFS_TRACE_START` | `1` adds a start line before each traced call; read at every call. |
Expand Down Expand Up @@ -238,6 +240,49 @@ Daemon/container restart recovery and replay of uncertain operations are exclude
Background process ownership is in the native executor, including persisted
process identity; the filesystem daemon launches and supervises no commands.

### Local Sandboxes (no Docker)

`LAYERFS_SANDBOX=local` runs each Sandbox's daemon as a process of this
machine instead of in a container, for a Linux VM or CI job that has no Docker
and is itself the isolation boundary (a microVM, for instance). **It isolates
nothing**: the daemon runs as root in this machine's namespaces, and commands
share its processes, network and filesystem; they only run as their own uid
501 and gid 20, with no supplementary groups. Use it only where everything on
the machine belongs to the same trust domain. Docker stays the default.

It needs root (the daemon mounts FUSE and commands run as another identity;
otherwise `workspace.mount` is refused `Unsupported`), `/dev/fuse` (the
daemon narrows it to 0600, as it does in a container), the daemon executable
(`LAYERFS_DAEMON`), `LAYERFS_LOCAL_DIR` and the client's Store path
(`LAYERFS_STORE_PATH`), which the daemon opens directly: the Store calls and
the daemons use the same file, on ext4, xfs or btrfs. `LAYERFS_DOCKER`,
`_IMAGE`, `_VOLUME` and `_DAEMON_HOST` are not read, and an `image` given to
`sandbox.create` is ignored. Everything else above holds: numbered Sandboxes
of up to 16 Workspaces, `sandbox.create` as the only start, `sandbox.close`,
idle close after 2 minutes, reclaim, records under
`LAYERFS_HOME/<project>/sandboxes/<n>/`, background processes, start intents
and their refusals.

Sandbox `n` lives in `LAYERFS_LOCAL_DIR/<prefix><project>-<n>/`:
`config/daemon.setup` (its daemon setup, 0600 in a 0700 directory),
`overlay/` (0700), `workspaces/<k>` (the Workspace mounts, so a Workspace's
`path` is under this directory), `tmp/` (1777, the commands' `HOME` and the
background processes' logs) and `daemon.log` (the daemon's output, what
`docker logs` shows for a container and what
`backend.daemon.sandbox.get_log` reads; a failed start's report carries its
last 20 lines). The daemon listens on 127.0.0.1 only. Its records name the
daemon by process id and start time, so a reused id is never taken for it.
`sandbox.create`, the mount result and `project.list_sandboxes()` give a local
Sandbox's `image` as `local`. A local record is used only by a client whose
`LAYERFS_LOCAL_DIR` and prefix put that Sandbox exactly where the record
says; any other is refused `InvalidInput` and nothing is deleted. A daemon
that ends (idle close, crash, kill) is the stopped observation: Workspace
calls to it are refused, and the next `sandbox.create` or
`runtime.sandbox.reclaim` reclaims it, detaching any mount the dead daemon
left before deleting its directory. Nothing outside that directory is
removed; the Store is never touched. `@layerfs/runtime`'s Docker-side
`runtime.sandbox.list` and `sweep` do not see local Sandboxes.

### Exec output

`exec` returns `{ kind, exitCode, stdout, stderr, message, truncated }` when the
Expand Down
Loading