Skip to content
Closed
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
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,26 @@ remains a separate MEMORY/OFF/EXCLUSIVE database file.
prefixed names, such as dsh's `DSH_*`, maps them itself. Linux still makes
Store calls in the caller's process, and the worker's rules above (loopback,
secret, one attempt, no replay) are unchanged.
- **VM placement (`@layerfs/runtime`, owner 2026-10-31, #515).** A LayerFS
home may be placed in Linux VMs on this machine instead of in the host's
Docker: `LAYERFS_PLACEMENT=vm:<provider>` (or the runtime's `placement`
setting), with `local` the default and unchanged. **One Linux VM per Store**
(V1): a project's VM holds that project's Store on its own ext4 disk, its
Sandboxes are Linux namespaces inside it
(`LAYERFS_SANDBOX_BACKEND=namespaces`, documented in
[the agent guide](api/docs/agent.md#namespace-sandboxes-linux)), and its
worker runs the real SDK as root with its own secret and its own
loopback-published port; the host reaches only each VM's worker, with one
attempt per call and no replay, the same rules as the macOS and Windows
routes. Store calls therefore leave the caller's process on Linux too, under
`vm:` placement only, and a process routes them per project
(`useWorker((project) => endpoint)`, or that project's
`LAYERFS_WORKER_ENDPOINT`). `project.init` makes the project's VM and
`project.remove` deletes it with its Store disk; a bare VM delete is refused
while the project exists, because that Store is on that disk only and no
remote Store push exists (#506). Implemented and verified providers:
`microsandbox` (`msb`) and `apple-container` (`container` 1.6.1).
`@layerfs/runtime`'s guide and `api/runtime/src/platform/vm/` hold the details.
- **One global database, one writer, short holds (owner decision 2026-10-18).**
All committed state lives in one SQLite Store file; no sharding, second
database, concurrent-writer engine or objects outside it. Writes go one at a
Expand Down
48 changes: 44 additions & 4 deletions api/agent/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ export const BACKEND = /^(backend|runtime)\./;

/** Runs one operation once and returns its value or throws its refusal;
* `options` are the client's settings, whose `trace` selects its trace file;
* `transport` is the client's binding (default: the one a new client would use). */
export async function invoke(op, args, options, transport = binding()) {
* `transport` is the client's binding (default: the one a new client of
* `args.project` would use). */
export async function invoke(op, args, options, transport = binding(args.project)) {
return outcome(await traced(options, op, args, (scope) => {
if (BACKEND.test(op)) throw new LayerFSError("UnknownMethod", "backend and runtime methods are @layerfs/backend's and @layerfs/runtime's", null);
return transport.call(op, observed(args, scope));
}));
}

/** One call on the binding's backend table (`backend.*`, `runtime.*`). */
export async function invokeBackend(op, args, options, transport = binding()) {
export async function invokeBackend(op, args, options, transport = binding(args?.project)) {
return outcome(await traced(options, op, args, (scope) => transport.backendCall(op, observed(args, scope))));
}

Expand Down Expand Up @@ -60,9 +61,19 @@ 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) `native` (Linux or macOS), a Sandbox is a host daemon:
* Linux uses FUSE with a root daemon and nonroot commands (501:20);
* macOS uses loopback NFS and this user. 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 === "native") return nativeDeployment(options, env, sandbox);
if (sandbox !== undefined && sandbox !== "docker") {
throw new LayerFSError("InvalidInput", `deploy.sandbox (LAYERFS_SANDBOX) is docker or native (Linux or macOS), not ${sandbox}`, null);
}
const deploy = {
docker: options.docker ?? env("DOCKER") ?? "/var/run/docker.sock",
image: options.image ?? env("IMAGE"),
Expand All @@ -80,6 +91,35 @@ export function deployment(options = {}) {
return deploy;
}

/** A native Sandbox's settings: the daemon, the directory its Sandboxes
* live in, the optional name prefix, and the backend inside that directory
* (`sandboxBackend`, LAYERFS_SANDBOX_BACKEND: `native` when unset, or
* `namespaces` on Linux). The namespaces backend gives each Sandbox a root
* filesystem from an image, so it also needs `image` (LAYERFS_IMAGE, the
* same digest Docker deploys) and `imageArchive` (LAYERFS_IMAGE_ARCHIVE, the
* docker-archive the bundle carries). */
function nativeDeployment(options, env, sandbox) {
const deploy = {
sandbox,
daemon: options.daemon ?? env("DAEMON"),
localDir: options.localDir ?? env("LOCAL_DIR"),
containerPrefix: options.containerPrefix ?? env("CONTAINER_PREFIX"),
};
const backend = options.sandboxBackend ?? env("SANDBOX_BACKEND");
if (backend !== undefined) deploy.sandboxBackend = backend;
const required = [["daemon", "DAEMON"], ["localDir", "LOCAL_DIR"]];
if (backend === "namespaces") {
deploy.image = options.image ?? env("IMAGE");
deploy.imageArchive = options.imageArchive ?? env("IMAGE_ARCHIVE");
required.push(["image", "IMAGE"], ["imageArchive", "IMAGE_ARCHIVE"]);
}
for (const [name, setting] of required) {
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
19 changes: 16 additions & 3 deletions api/agent/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,15 @@ 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>`. On Linux or macOS,
* LAYERFS_SANDBOX=native starts each Sandbox as a host daemon in a directory
* under LAYERFS_LOCAL_DIR. Linux requires root for FUSE and runs commands
* as 501:20; macOS uses loopback NFS and the caller's identity. No Docker
* setting is read; native provides no isolation from the host.
* LAYERFS_SANDBOX_BACKEND picks the backend inside that directory: `native`
* (unset) or, on Linux, `namespaces` (namespaces, cgroups, nonroot commands).
* It is read for every call, so a Sandbox made with one backend is never
* reopened as another.
* 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,13 +137,18 @@ export class LayerFS {
volume: env("VOLUME"),
daemon: env("DAEMON"),
...(env("CONTAINER_PREFIX") ? { containerPrefix: env("CONTAINER_PREFIX") } : {}),
...(env("SANDBOX") ? { sandbox: env("SANDBOX") } : {}),
...(env("SANDBOX_BACKEND") ? { sandboxBackend: env("SANDBOX_BACKEND") } : {}),
...(env("LOCAL_DIR") ? { localDir: env("LOCAL_DIR") } : {}),
...(env("IMAGE_ARCHIVE") ? { imageArchive: env("IMAGE_ARCHIVE") } : {}),
...(env("DAEMON_HOST") ? { daemonHost: env("DAEMON_HOST") } : {}),
},
storePolicy: env("STORE_POLICY"),
storePath: env("STORE_PATH"),
};
// not open until a factory has opened its Store; the binding is chosen now, once
this[STATE] = { closed: null, transport: binding() };
// not open until a factory has opened its Store; the binding is chosen now, once,
// for this client's project (a VM placement has one worker per project)
this[STATE] = { closed: null, transport: binding(this.projectName) };
this.fs = bind(this, fsMethods);
this.project = bind(this, { list_branches, list_sandboxes });
this.branch = bind(this, { list_commits, diff_commits, diff_branches, fork, merge, publish });
Expand Down
35 changes: 23 additions & 12 deletions api/agent/src/transport/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
//
// The worker route is open to any integration that names an endpoint
// (AGENTS.md, owner 2026-10-24): @layerfs/runtime starts the worker on macOS
// and Windows, where a process cannot open the Store itself. LayerFS names no
// integration here.
// and Windows, where a process cannot open the Store itself, and in a
// `vm:<provider>` home, where each project's Store is in that project's VM
// (one endpoint per project). LayerFS names no integration here.
import { LayerFSError } from "../errors.js";
import { inprocess } from "./inprocess.js";
import { workerClient } from "./worker_client.js";

/** The worker `useWorker` handed over: { endpoint }, else null. */
/** The worker `useWorker` handed over: { endpoint }, { resolve } or null. */
let handed = null;
let local = null;
/** Worker clients by endpoint file. */
Expand All @@ -24,18 +25,28 @@ const workers = new Map();
export const setting = (name) => process.env[`LAYERFS_${name}`] || undefined;

/** Internal, for @layerfs/runtime: clients made from now on in this process
* use the worker at `endpoint` (an endpoint file). Takes precedence over the
* environment. One worker per process: another endpoint is refused. */
* use the worker at `endpoint` (an endpoint file), or, when `endpoint` is a
* function, the worker that function names for a client's project — one VM
* per Store has one endpoint each. Takes precedence over the environment.
* One worker per process: another endpoint is refused, and so is a second
* resolver. */
export function useWorker(endpoint) {
if (typeof endpoint !== "string" || !endpoint) throw new LayerFSError("InvalidInput", "a worker endpoint file is required", null);
if (handed && handed.endpoint !== endpoint) throw new LayerFSError("InvalidInput", `this process already uses the worker at ${handed.endpoint}`, null);
handed = { endpoint };
const next = typeof endpoint === "function" ? { resolve: endpoint }
: typeof endpoint === "string" && endpoint ? { endpoint }
: null;
if (!next) throw new LayerFSError("InvalidInput", "a worker endpoint file is required", null);
if (handed && (handed.endpoint !== next.endpoint || handed.resolve !== next.resolve)) {
throw new LayerFSError("InvalidInput", `this process already uses the worker at ${handed.endpoint ?? "one endpoint per project"}`, null);
}
handed = next;
}

/** The binding a client made now uses: the handed-over worker, else
* LAYERFS_WORKER_ENDPOINT's, else the addon in this process. */
export function binding() {
const endpoint = handed?.endpoint ?? setting("WORKER_ENDPOINT");
/** The binding a client made now uses: the handed-over worker (by project, or
* the one endpoint), else LAYERFS_WORKER_ENDPOINT's, else the addon in this
* process. A call without a project (an in-process backend call) uses the
* environment's endpoint, never a project's resolver. */
export function binding(project) {
const endpoint = handed?.resolve && typeof project === "string" ? handed.resolve(project) : handed?.endpoint ?? setting("WORKER_ENDPOINT");
if (!endpoint) return (local ??= inprocess());
let client = workers.get(endpoint);
if (!client) workers.set(endpoint, (client = workerClient(endpoint)));
Expand Down
143 changes: 143 additions & 0 deletions api/agent/test/native_deployment.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Which runtime a Sandbox uses. No real addon, Store or Docker (the
// stand-in binding answers the client's calls):
// node api/agent/test/native_deployment.mjs
// LAYERFS_SANDBOX unset or `docker` gives exactly the Docker deployment it
// always gave; `native` (macOS) needs only the daemon and LAYERFS_LOCAL_DIR
// (no Docker setting); any other value, the former `local` included, 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));

// native: daemon + localDir, no Docker setting read or required
clear();
process.env.LAYERFS_SANDBOX = "native";
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
process.env.LAYERFS_LOCAL_DIR = "/Users/me/lfs";
assert.deepEqual(deployment(), { sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" });
// an image (sandbox.create's option) does not change a native deployment
assert.deepEqual(deployment({ image: docker.image }), { sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" });
// the backend inside the directory is native unless named; namespaces is
// passed through (the binding refuses it off Linux, and an unknown value)
assert.equal(deployment().sandboxBackend, undefined);
const archive = `/bundle/images/${"a".repeat(64)}.tar`;
process.env.LAYERFS_SANDBOX_BACKEND = "namespaces";
process.env.LAYERFS_IMAGE = docker.image;
process.env.LAYERFS_IMAGE_ARCHIVE = archive;
// a namespaces Sandbox's root is an image: the same digest Docker deploys
// and the docker-archive the bundle carries
assert.deepEqual(deployment(), {
sandbox: "native",
daemon: "/opt/layerfs-daemon",
localDir: "/Users/me/lfs",
sandboxBackend: "namespaces",
image: docker.image,
imageArchive: archive,
});
// each of those is named when it is missing
delete process.env.LAYERFS_IMAGE;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /\(LAYERFS_IMAGE\)/.test(e.message));
process.env.LAYERFS_IMAGE = docker.image;
delete process.env.LAYERFS_IMAGE_ARCHIVE;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /\(LAYERFS_IMAGE_ARCHIVE\)/.test(e.message));
process.env.LAYERFS_IMAGE_ARCHIVE = archive;
// the native backend inside the same directory reads neither of them
assert.deepEqual(deployment({ sandboxBackend: "native" }), {
sandbox: "native",
daemon: "/opt/layerfs-daemon",
localDir: "/Users/me/lfs",
sandboxBackend: "native",
});
delete process.env.LAYERFS_SANDBOX_BACKEND;
delete process.env.LAYERFS_IMAGE;
delete process.env.LAYERFS_IMAGE_ARCHIVE;
// the container prefix names native 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 = "/Users/me/lfs";
delete process.env.LAYERFS_DAEMON;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_DAEMON/.test(e.message));
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
// a DSH_LAYERFS_NFS_ name is not mapped (an integration maps its own names)
process.env.DSH_LAYERFS_NFS_MOUNT = "/bin/true";
deployment();
assert.equal(process.env.LAYERFS_NFS_MOUNT, undefined);
// since #428 the SDK reads LAYERFS_ names only: a DSH_LAYERFS_ twin selects nothing
clear();
setDocker();
process.env.DSH_LAYERFS_SANDBOX = "native";
process.env.DSH_LAYERFS_LOCAL_DIR = "/l";
assert.deepEqual(deployment(), docker);

// anything else, the former `local` included, is refused, never silently Docker
for (const typo of ["local", "Native", "nativ", "NATIVE", "podman"]) {
clear();
setDocker();
process.env.LAYERFS_SANDBOX = typo;
assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && e.message.includes(`not ${typo}`));
}

// a client reads LAYERFS_SANDBOX and LAYERFS_LOCAL_DIR once, and
// sandbox.create sends the native deployment (no Docker setting)
clear();
store();
process.env.LAYERFS_SANDBOX = "native";
process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon";
process.env.LAYERFS_LOCAL_DIR = "/Users/me/lfs";
const { LayerFS } = await import("../src/index.js");
const client = await LayerFS.open("website");
assert.equal(client.options.deploy.sandbox, "native");
assert.equal(client.options.deploy.localDir, "/Users/me/lfs");
const before = native.deploys.length;
await client.sandbox.create();
assert.deepEqual(native.deploys.slice(before), [{ sandbox: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" }]);
// 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: "native", daemon: "/opt/layerfs-daemon", localDir: "/Users/me/lfs" });
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("native_deployment: ok");
Loading