Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Unreleased

## Breaking changes
- Config: the guest user is now configured as `guestUser: { uid, gid }` (numeric)
instead of the `user: "<name>"` string. It must currently be
`{ uid: 0, gid: 0 }` (root). Update configs that set `user`.
- The guest shell is now `/bin/bash`, previously was root's default shell (= ash
in Gondolin's Alpine base image). Using bash matches the default command
in Gondolin's `VM.shell()`.
- Config: the top-level `guestHomeDir` moved into `guestUser` as
`guestUser.homedir`. Update configs that set `guestHomeDir`.
- Mounts and volumes are now presented to the guest as owned by the guest user
(root, `0:0`) by default rather than showing the raw host uid/gid. This avoids
ownership-sensitive tooling tripping up (e.g. git's "detected dubious
ownership") when host files are owned by a non-root user. Ownership can be
adjusted through a new mount/volume-level `owner` option, see below.

## Features
- Config: Mounts and volumes now accept an `owner: { uid?, gid? }` option to
control the uid/gid presented to the guest for that mount/volume's entries
(defaults to `guestUser`). Note that this does not change on-host ownership,
and files the guest creates still land on the host owned by the Tuor process
user. As before, invoking `chown` on mounted files & directories remains a
no-op.


# 0.4.0 (2026-07-19)

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ Tuor exposes a wide number of Gondolin's features:
the guest (read-only or read/write).
- **Hide host files**: Within a mounted directory, hide select files (e.g.
`.envrc` files with credentials) from the VM guest.
- **Custom file ownership**: Control the uid/gid a mount or volume is presented
as owned by in the guest (defaults to the guest user), independent of the
files' real on-host ownership.
- **Network control**: Restrict network egress to HTTP and specific hosts. DNS
is provided by the sandbox, so as to prevent data exfiltration through UDP 53.
- **Secret injection**: Prevent the guest from seeing your auth tokens &
Expand Down
16 changes: 12 additions & 4 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ that is not set on the host is an error.
"ignore": [".env", "secret.key", ".tuor"],
// Files to read list of ignored files from (think .gitignore). Paths are
// either host paths or mount-relative paths.
"ignoreFileRefs": ["host:./tuorignore", "mount:.tuorignore"]
"ignoreFileRefs": ["host:./tuorignore", "mount:.tuorignore"],
// Optional: uid/gid presented to the guest for this mount's entries
// (defaults to guestUser). Display-only: does not change host-side
// ownership. Either field may be omitted to inherit from guestUser.
"owner": { "uid": 0, "gid": 0 }
}
],
// VM resource sizing. Any field left unset falls back to Gondolin's default
Expand All @@ -106,10 +110,14 @@ that is not set on the host is an error.
// volumes, instead!)
"rootfsSize": "2G"
},
// Constraint: Guest user must currently be root
"user": "root",
// Guest user (numeric uid/gid) the shell runs under and that mounted
// directories are presented as owned by. `homedir` (optional, default /root)
// is the guest home directory used for `~` expansion in guest paths.
// Constraint: uid/gid must currently be root ({ uid: 0, gid: 0 }).
"guestUser": { "uid": 0, "gid": 0, "homedir": "/root" },
// Persistent guest directories without a host backing directory (
// similar to Docker volumes)
// similar to Docker volumes). Like mounts, they accept an optional `owner`,
// defaults to guestUser).
"volumes": [
{ "guestPath": "~/.claude" } // Persist Claude Code state
],
Expand Down
2 changes: 1 addition & 1 deletion src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const command = buildCommand({
allowedHosts: [],
allowedInternalHosts: [],
},
user: "root",
guestUser: { uid: 0, gid: 0 },
workdir: {
hostPath: "..",
mode: workspaceMode,
Expand Down
3 changes: 1 addition & 2 deletions src/cli/show-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import type { DefaultedConfig } from "../config/defaults.ts";
import { redactSecrets } from "./show-config.ts";

const baseConfig: DefaultedConfig = {
user: "root",
guestUser: { uid: 0, gid: 0, homedir: "/root" },
workdir: "/workspace",
guestHomeDir: "/root",
network: { mode: "restricted", allowedHosts: [], allowedInternalHosts: [] },
};

Expand Down
50 changes: 23 additions & 27 deletions src/config/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,35 @@ import { applyConfigDefaults } from "./defaults.ts";
import type { TuorConfig } from "./schema.ts";

/**
* A merged config as it reaches applyConfigDefaults: user/workdir are optional
* (no schema default) and only present when a layer set them explicitly.
* A merged config as it reaches applyConfigDefaults: guestUser/workdir are
* optional (no schema default) and only present when a layer set them
* explicitly.
*/
function config(overrides: Partial<TuorConfig> = {}): TuorConfig {
return { ...overrides };
}

describe("applyConfigDefaults", () => {
describe("user / workdir", () => {
test("defaults user to root when omitted", () => {
expect(applyConfigDefaults(config()).user).toBe("root");
describe("guestUser / workdir", () => {
test("defaults guestUser to root (uid/gid 0, homedir /root) when omitted", () => {
expect(applyConfigDefaults(config()).guestUser).toEqual({
uid: 0,
gid: 0,
homedir: "/root",
});
});

test("defaults workdir to / when omitted", () => {
expect(applyConfigDefaults(config()).workdir).toBe("/");
});

test("preserves an explicit user and workdir", () => {
test("preserves an explicit guestUser and workdir", () => {
const result = applyConfigDefaults(
config({ user: "dev", workdir: "/w" }),
config({ guestUser: { uid: 0, gid: 0 }, workdir: "/w" }),
);
expect(result.user).toBe("dev");
expect(result.guestUser).toEqual({ uid: 0, gid: 0, homedir: "/root" });
expect(result.workdir).toBe("/w");
});

test("infers guestHomeDir from the defaulted user when user is omitted", () => {
expect(applyConfigDefaults(config()).guestHomeDir).toBe("/root");
});
});

describe("network", () => {
Expand Down Expand Up @@ -60,33 +61,28 @@ describe("applyConfigDefaults", () => {
});
});

describe("guestHomeDir", () => {
test("infers /root for the root user when omitted", () => {
const result = applyConfigDefaults(config({ user: "root" }));
expect(result.guestHomeDir).toBe("/root");
});

test("infers /home/<user> for a non-root user when omitted", () => {
const result = applyConfigDefaults(config({ user: "dev" }));
expect(result.guestHomeDir).toBe("/home/dev");
describe("guestUser.homedir", () => {
test("defaults to /root when omitted", () => {
const result = applyConfigDefaults(config());
expect(result.guestUser.homedir).toBe("/root");
});

test("preserves an explicit guestHomeDir", () => {
test("preserves an explicit guestUser.homedir", () => {
const result = applyConfigDefaults(
config({ guestHomeDir: "/custom/home" }),
config({ guestUser: { uid: 0, gid: 0, homedir: "/custom/home" } }),
);
expect(result.guestHomeDir).toBe("/custom/home");
expect(result.guestUser.homedir).toBe("/custom/home");
});
});

test("leaves other fields untouched", () => {
const input = config({
user: "dev",
guestUser: { uid: 0, gid: 0 },
workdir: "/work",
resources: { rootfsSize: "2G" },
});
const result = applyConfigDefaults(input);
expect(result.user).toBe("dev");
expect(result.guestUser).toEqual({ uid: 0, gid: 0, homedir: "/root" });
expect(result.workdir).toBe("/work");
expect(result.resources).toEqual({ rootfsSize: "2G" });
});
Expand All @@ -95,6 +91,6 @@ describe("applyConfigDefaults", () => {
const input = config();
applyConfigDefaults(input);
expect(input.network).toBeUndefined();
expect(input.guestHomeDir).toBeUndefined();
expect(input.guestUser).toBeUndefined();
});
});
34 changes: 21 additions & 13 deletions src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import type { NetworkSpec } from "../core/session.ts";
import { inferGuestHomeDir } from "./homedir.ts";
import type { TuorConfig, WorkdirConfig } from "./schema.ts";
import type { GuestUserConfig, TuorConfig, WorkdirConfig } from "./schema.ts";

// --- Types ---

/** Guest user assumed when no config layer sets one. */
export const DEFAULT_USER = "root";
/** Guest user assumed when no config layer sets one (root). */
export const DEFAULT_GUEST_USER: GuestUserConfig = { uid: 0, gid: 0 };
/**
* Guest home directory assumed when no config layer sets `guestUser.homedir`.
* Since the guest user is enforced to be root (i.e. root is not just the
* default), we can hard-code /root here.
*/
export const DEFAULT_GUEST_HOME_DIR = "/root";
/** Guest working directory assumed when no config layer sets one. */
export const DEFAULT_WORKDIR: WorkdirConfig = "/";

/** A {@link GuestUserConfig} with its `homedir` default materialized. */
export type DefaultedGuestUser = GuestUserConfig & { homedir: string };

/**
* A {@link TuorConfig} with all *config-level* defaults materialized. This is
* the "effective config" a user reasons about: same shape as `config.json`
Expand All @@ -22,22 +30,20 @@ export const DEFAULT_WORKDIR: WorkdirConfig = "/";
*/
export type DefaultedConfig = Omit<
TuorConfig,
"network" | "guestHomeDir" | "user" | "workdir"
"network" | "guestUser" | "workdir"
> & {
network: NetworkSpec;
guestHomeDir: string;
user: string;
guestUser: DefaultedGuestUser;
workdir: WorkdirConfig;
};

// --- Public API ---

/**
* Fill the config-level defaults that don't come from the arktype schema:
* `user`, `workdir`, `network` (block-all when omitted) and `guestHomeDir`
* (inferred from `user`).
* `guestUser`, `workdir`, and `network` (block-all when omitted).
*
* `user`/`workdir` are defaulted *here* rather than in the schema so that a
* `guestUser`/`workdir` are defaulted *here* rather than in the schema so that a
* child config layer that omits them doesn't clobber a value inherited from a
* parent layer during merge (their "omitted" would otherwise be indistinguish-
* able from an explicit default). This must run after `mergeConfigs`.
Expand All @@ -48,13 +54,15 @@ export type DefaultedConfig = Omit<
* defaults and stay in {@link createSessionSpecFromConfig}.
*/
export function applyConfigDefaults(config: TuorConfig): DefaultedConfig {
const user = config.user ?? DEFAULT_USER;
const guestUser = config.guestUser ?? DEFAULT_GUEST_USER;
return {
...config,
user,
guestUser: {
...guestUser,
homedir: guestUser.homedir ?? DEFAULT_GUEST_HOME_DIR,
},
workdir: config.workdir ?? DEFAULT_WORKDIR,
network: defaultNetwork(config.network),
guestHomeDir: config.guestHomeDir ?? inferGuestHomeDir(user),
};
}

Expand Down
12 changes: 1 addition & 11 deletions src/config/homedir.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { describe, expect, test } from "vitest";
import { expandTilde, inferGuestHomeDir } from "./homedir.ts";

describe("inferGuestHomeDir", () => {
test("returns /root for root user", () => {
expect(inferGuestHomeDir("root")).toBe("/root");
});

test("returns /home/$user for non-root user", () => {
expect(inferGuestHomeDir("dev")).toBe("/home/dev");
});
});
import { expandTilde } from "./homedir.ts";

describe("expandTilde", () => {
test("expands bare ~ to homeDir", () => {
Expand Down
5 changes: 0 additions & 5 deletions src/config/homedir.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/** Derive the conventional home directory for a Linux user. */
export function inferGuestHomeDir(user: string): string {
return user === "root" ? "/root" : `/home/${user}`;
}

/**
* Expand a leading `~` (bare or followed by `/`) to `homeDir`.
* Does NOT handle `~otheruser` syntax — only the current user's `~`.
Expand Down
46 changes: 28 additions & 18 deletions src/config/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,23 @@ describe("findAllConfigDirs", () => {

describe("mergeConfigs", () => {
test("single layer returns config unchanged (except path pre-resolution)", () => {
const result = mergeConfigs([layer("/project/.tuor", { user: "dev" })]);
expect(result.user).toBe("dev");
const result = mergeConfigs([
layer("/project/.tuor", { guestUser: { uid: 0, gid: 0 } }),
]);
expect(result.guestUser).toEqual({ uid: 0, gid: 0 });
});

test("throws on empty layers", () => {
expect(() => mergeConfigs([])).toThrow("at least one");
});

describe("scalar fields: child wins", () => {
test("user", () => {
test("guestUser: carried through when set (only root is valid)", () => {
const result = mergeConfigs([
layer("/a", { user: "parent" }),
layer("/b", { user: "child" }),
layer("/a", { guestUser: { uid: 0, gid: 0 } }),
layer("/b", { guestUser: { uid: 0, gid: 0 } }),
]);
expect(result.user).toBe("child");
expect(result.guestUser).toEqual({ uid: 0, gid: 0 });
});

test("workdir string", () => {
Expand All @@ -108,17 +110,17 @@ describe("mergeConfigs", () => {
expect(result.workdir).toBe("/child-dir");
});

// Regression: a child layer that omits user/workdir must inherit the
// Regression: a child layer that omits guestUser/workdir must inherit the
// parent's value rather than clobbering it. Previously these fields carried
// a schema default ("root"/"/"), so a child that never set them still
// overrode the parent — e.g. a project .tuor/config.json silently reset a
// workdir configured in ~/.config/tuor/config.json.
test("user: parent used when child omits", () => {
test("guestUser: parent used when child omits", () => {
const result = mergeConfigs([
layer("/a", { user: "parent" }),
layer("/a", { guestUser: { uid: 0, gid: 0 } }),
layer("/b"),
]);
expect(result.user).toBe("parent");
expect(result.guestUser).toEqual({ uid: 0, gid: 0 });
});

test("workdir: parent used when child omits", () => {
Expand All @@ -129,12 +131,20 @@ describe("mergeConfigs", () => {
expect(result.workdir).toBe("/parent-dir");
});

test("guestHomeDir: child overrides parent", () => {
test("guestUser.homedir: child overrides parent", () => {
const result = mergeConfigs([
layer("/a", { guestUser: { uid: 0, gid: 0, homedir: "/old" } }),
layer("/b", { guestUser: { uid: 0, gid: 0, homedir: "/new" } }),
]);
expect(result.guestUser?.homedir).toBe("/new");
});

test("guestUser.homedir: child inherits parent's when it omits homedir", () => {
const result = mergeConfigs([
layer("/a", { guestHomeDir: "/old" }),
layer("/b", { guestHomeDir: "/new" }),
layer("/a", { guestUser: { uid: 0, gid: 0, homedir: "/parent-home" } }),
layer("/b", { guestUser: { uid: 0, gid: 0 } }),
]);
expect(result.guestHomeDir).toBe("/new");
expect(result.guestUser?.homedir).toBe("/parent-home");
});

test("nix: child overrides parent", () => {
Expand Down Expand Up @@ -450,12 +460,12 @@ describe("mergeConfigs", () => {
test("most specific wins for scalars, arrays concatenate across all layers", () => {
const result = mergeConfigs([
layer("/home/.config/tuor", {
user: "home-user",
workdir: "/home-wd",
env: { EDITOR: "vim" },
mounts: [mount({ hostPath: "/global-tools" })],
}),
layer("/projects/.tuor", {
user: "project-user",
workdir: "/project-wd",
env: { EDITOR: "nano", PROJECT: "myproj" },
mounts: [mount({ hostPath: "/project-data" })],
}),
Expand All @@ -466,8 +476,8 @@ describe("mergeConfigs", () => {
]);

// Scalars: most specific layer that *sets* the field wins. The closest
// layer omits user, so it falls through to the middle layer.
expect(result.user).toBe("project-user");
// layer omits workdir, so it falls through to the middle layer.
expect(result.workdir).toBe("/project-wd");

// Env: shallow merge
expect(result.env).toEqual({
Expand Down
Loading