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
12 changes: 12 additions & 0 deletions api/agent/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,15 @@ export function checkedMessage(message) {
}
return message;
}

/** A write's last argument as call fields: a Commit message string, or
* `{ message?, mode? }` where `mode` is the permission bits (e.g. `0o755`).
* The engine checks `mode`; other option names are ignored. */
export function writeOptions(options) {
if (options === undefined || typeof options === "string") return { message: checkedMessage(options) };
if (options === null || typeof options !== "object" || Array.isArray(options)) {
throw new LayerFSError("InvalidInput", "options must be a message string or { message, mode }", null);
}
const { message, mode } = options;
return { message: checkedMessage(message), ...(mode === undefined ? {} : { mode }) };
}
9 changes: 5 additions & 4 deletions api/agent/src/fs/mkdir.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// fs.mkdir: one positional SDK operation.
import { checkedMessage } from "../client.js";
export function mkdir(client, branch, path, message) {
return client.call("fs.mkdir", { branch, path, recursive: true, message: checkedMessage(message) });
// fs.mkdir: one positional SDK operation. The last argument is a message or
// `{ message, mode }`; `mode` applies to `path` when it is created.
import { writeOptions } from "../client.js";
export function mkdir(client, branch, path, options) {
return client.call("fs.mkdir", { branch, path, recursive: true, ...writeOptions(options) });
}
9 changes: 5 additions & 4 deletions api/agent/src/fs/write.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// fs.write: one positional SDK operation. Text is `content`; anything else
// is `bytes` (see body.js).
import { checkedMessage } from "../client.js";
// is `bytes` (see body.js). The last argument is a message or
// `{ message, mode }`.
import { writeOptions } from "../client.js";
import { body } from "./body.js";
export function write(client, branch, path, content, message) {
return client.call("fs.write", { branch, path, ...(typeof content === "string" ? { content } : { bytes: body(content) }), message: checkedMessage(message) });
export function write(client, branch, path, content, options) {
return client.call("fs.write", { branch, path, ...(typeof content === "string" ? { content } : { bytes: body(content) }), ...writeOptions(options) });
}
4 changes: 4 additions & 0 deletions api/agent/test/fixture/cases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export const CASES = [
op: "fs.apply", sent: { branch: "cart", changes: [{ op: "write", path: "/a", content: "hi" }], message: "two" }, result: committed },
{ name: "fs mkdir", argv: ["fs", "mkdir", "cart", "/d"], sdk: (lf) => lf.fs.mkdir("cart", "/d"),
op: "fs.mkdir", sent: { branch: "cart", path: "/d", recursive: true }, result: committed },
{ name: "fs write with a mode", argv: ["fs", "write", "cart", "/run.sh", "--mode", "755"], input: Buffer.from("#!/bin/sh\n"), sdk: (lf) => lf.fs.write("cart", "/run.sh", Buffer.from("#!/bin/sh\n"), { mode: 0o755 }),
op: "fs.write", sent: { branch: "cart", path: "/run.sh", bytes: { base64: "IyEvYmluL3NoCg==" }, mode: 0o755 }, result: committed },
{ name: "fs mkdir with a mode and message", argv: ["fs", "mkdir", "cart", "/d", "--mode", "0o750", "-m", "private"], sdk: (lf) => lf.fs.mkdir("cart", "/d", { message: "private", mode: 0o750 }),
op: "fs.mkdir", sent: { branch: "cart", path: "/d", recursive: true, mode: 0o750, message: "private" }, result: committed },
{ name: "fs rm", argv: ["fs", "rm", "cart", "/d"], sdk: (lf) => lf.fs.rm("cart", "/d"),
op: "fs.rm", sent: { branch: "cart", path: "/d" }, result: { kind: "UpToDate", head: "h3ad" } },
{ name: "fs cp", argv: ["fs", "cp", "cart", "/a", "/b"], sdk: (lf) => lf.fs.cp("cart", "/a", "/b"),
Expand Down
99 changes: 99 additions & 0 deletions api/agent/test/verify/fs_mode_real.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// #442: permission modes through the JS SDK and the real native binding.
// fs.write and fs.mkdir take { message, mode } (a message string still
// works), fs.apply takes `mode` on write/mkdir and a chmod change, and
// fs.stat reports `mode`. A mounted Workspace of the Branch shows the same
// modes through FUSE (`stat -c %a`, and the new script runs), and a chmod
// made in the Workspace and committed reads back through fs.stat. Refusals:
// set-user-ID is InvalidInput with reason Mode, a text mode is InvalidInput,
// and options that are neither a message nor an object are refused by the
// SDK. Nothing refused is committed. One attempt per step; nothing retried.
// Linux, under the E2E launcher (its Store volume, SDK home and Sandbox image):
// python3 tools/worktree.py run --resources <resources.json> --bundle <bundle> \
// --timeout 90 -- node api/agent/test/verify/fs_mode_real.mjs
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const { LayerFS } = await import("@layerfs/agent");
const source = mkdtempSync(join(tmpdir(), "layerfs-442-"));
writeFileSync(join(source, "tool.sh"), "#!/bin/sh\necho tool\n");
chmodSync(join(source, "tool.sh"), 0o755);
mkdirSync(join(source, "private"));
writeFileSync(join(source, "private", "f"), "f\n");
chmodSync(join(source, "private"), 0o750);
writeFileSync(join(source, "a.txt"), "a\n");
chmodSync(join(source, "a.txt"), 0o644);
const layerfs = await LayerFS.init("site", source);
rmSync(source, { recursive: true, force: true });
await layerfs.branch.fork("work", "main");

const mode = async (ref, path) => (await layerfs.fs.stat(ref, path)).mode;
const commits = async () => (await layerfs.branch.list_commits("work")).items.length;

// Init keeps the source's modes; fs.stat reports them.
assert.equal(await mode("main", "/tool.sh"), 0o755);
assert.equal(await mode("main", "/private"), 0o750);
assert.equal(await mode("main", "/a.txt"), 0o644);

// fs.write: an options object, a plain message, and no options.
assert.equal((await layerfs.fs.write("work", "/new.sh", "#!/bin/sh\necho new\n", { mode: 0o755, message: "add new.sh" })).kind, "Committed");
assert.equal((await layerfs.fs.write("work", "/plain.txt", "p\n", "plain message")).kind, "Committed");
await layerfs.fs.write("work", "/tool.sh", "#!/bin/sh\necho changed\n");
const messages = (await layerfs.branch.list_commits("work")).items.map((c) => c.message);
assert.ok(messages.includes("add new.sh") && messages.includes("plain message"), JSON.stringify(messages));

// fs.mkdir with a mode; fs.apply with modes on write and mkdir, and chmod.
await layerfs.fs.mkdir("work", "/d/e", { mode: 0o750 });
const applied = await layerfs.fs.apply("work", [
{ op: "write", path: "/applied.sh", bytes: Buffer.from("#!/bin/sh\necho applied\n"), mode: 0o700 },
{ op: "mkdir", path: "/shared", mode: 0o1777 },
{ op: "chmod", path: "/a.txt", mode: 0o600 },
], "modes");
assert.equal(applied.kind, "Committed");

const want = {
"/tool.sh": 0o755, // overwritten without a mode: kept
"/new.sh": 0o755,
"/plain.txt": 0o644,
"/applied.sh": 0o700,
"/a.txt": 0o600,
"/private": 0o750,
"/d": 0o755,
"/d/e": 0o750,
"/shared": 0o1777,
};
for (const [path, m] of Object.entries(want)) assert.equal(await mode("work", path), m, path);
assert.equal(await mode("main", "/a.txt"), 0o644, "main is untouched");
console.log("STAT ok");

// Refused, and nothing committed.
const before = await commits();
await assert.rejects(layerfs.fs.write("work", "/x", "x", { mode: 0o4755 }), (e) => e.code === "InvalidInput" && e.detail?.reason === "Mode" && e.detail?.mode === "4755" && e.detail?.path === "/x");
await assert.rejects(layerfs.fs.apply("work", [{ op: "chmod", path: "/a.txt", mode: 0o1644 }]), (e) => e.code === "InvalidInput" && e.detail?.reason === "Mode" && e.detail?.index === 0);
await assert.rejects(layerfs.fs.write("work", "/x", "x", { mode: "755" }), { code: "InvalidInput" });
await assert.rejects(layerfs.fs.mkdir("work", "/x", 755), { code: "InvalidInput" });
assert.equal(await commits(), before);
await assert.rejects(layerfs.fs.stat("work", "/x"), { code: "NoSuchPath" });
console.log("REFUSED ok");

// A Workspace of the Branch shows the same modes through FUSE.
const ws = await layerfs.workspace.mount((await layerfs.sandbox.create()).sandbox, "work");
try {
const paths = Object.keys(want).map((p) => p.slice(1));
const seen = await layerfs.workspace.exec(ws, `stat -c '%a %n' ${paths.join(" ")} && ./new.sh && ./applied.sh`);
assert.equal(seen.exitCode, 0, seen.stderr);
const lines = paths.map((p) => `${want[`/${p}`].toString(8)} ${p}`);
assert.equal(seen.stdout, `${lines.join("\n")}\nnew\napplied\n`);
console.log("WORKSPACE ok");

// The other way: a chmod in the Workspace, committed, reads back.
assert.equal((await layerfs.workspace.exec(ws, "chmod 640 plain.txt")).exitCode, 0);
await layerfs.workspace.commit(ws, "chmod in Workspace");
assert.equal(await mode("work", "/plain.txt"), 0o640);
console.log("ROUNDTRIP ok");
} finally {
await layerfs.workspace.unmount(ws, true);
await layerfs.close();
}
console.log("fs_mode_real: ok");
4 changes: 4 additions & 0 deletions api/cli/src/argv.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function value(kind, name, text, entry) {
if (!/^\d+$/.test(text)) throw new UsageError(`--${name} takes a whole number, not ${JSON.stringify(text)}`, entry);
return Number(text);
}
if (kind === "octal") {
if (!/^(0o)?[0-7]+$/.test(text)) throw new UsageError(`--${name} takes octal permission bits such as 755, not ${JSON.stringify(text)}`, entry);
return parseInt(text.replace(/^0o/, ""), 8);
}
if (kind === "json") {
try {
return JSON.parse(text);
Expand Down
2 changes: 2 additions & 0 deletions api/cli/src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { UsageError } from "./argv.js";

export const PAGE = { "page-size": "int", "page-pos": "int" };
export const MESSAGE = { message: "text" };
// A write's message and permission bits (`--mode 755`).
export const WRITE = { ...MESSAGE, mode: "octal" };
// --no-hunks sends hunks: false (paths without line changes); left out, hunks is not
// sent and the SDK lists line changes, its default.
export const DIFF = { ...PAGE, cursor: "text", "no-hunks": "bool" };
Expand Down
10 changes: 5 additions & 5 deletions api/cli/src/fs/mkdir.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// layerfs fs mkdir: fs.mkdir(branch, path, message?).
import { MESSAGE } from "../common.js";
// layerfs fs mkdir: fs.mkdir(branch, path, { message?, mode? }).
import { WRITE } from "../common.js";

export default {
args: ["branch", "path"],
options: MESSAGE,
summary: "make a folder; one Commit",
run: (lf, a, o) => lf.fs.mkdir(a.branch, a.path, o.message),
options: WRITE,
summary: "make a folder (--mode for its permission bits); one Commit",
run: (lf, a, o) => lf.fs.mkdir(a.branch, a.path, { message: o.message, mode: o.mode }),
};
10 changes: 5 additions & 5 deletions api/cli/src/fs/write.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// layerfs fs write: fs.write(branch, path, content, message?), content from stdin or --from.
import { MESSAGE, body } from "../common.js";
// layerfs fs write: fs.write(branch, path, content, { message?, mode? }), content from stdin or --from.
import { WRITE, body } from "../common.js";

export default {
args: ["branch", "path"],
options: { ...MESSAGE, from: "file" },
summary: "write a file from stdin (or --from), bytes kept exactly; one Commit",
options: { ...WRITE, from: "file" },
summary: "write a file from stdin (or --from), bytes kept exactly; --mode sets its permission bits; one Commit",
prepare: async (a, o, io) => ({ content: await body(o, io) }),
run: (lf, a, o) => lf.fs.write(a.branch, a.path, a.content, o.message),
run: (lf, a, o) => lf.fs.write(a.branch, a.path, a.content, { message: o.message, mode: o.mode }),
};
2 changes: 1 addition & 1 deletion api/cli/src/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// with the fixed sections (settings, shared arguments, output) around them.
import { CALLS, FAMILIES, INIT } from "./calls.js";

const KIND = { text: "<text>", int: "<n>", json: "<json>", file: "<file>" };
const KIND = { text: "<text>", int: "<n>", json: "<json>", file: "<file>", octal: "<octal>" };

/** One command's usage line. */
export function usage(entry) {
Expand Down
2 changes: 2 additions & 0 deletions api/cli/test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ for (const [why, argv, message] of [
["both --old and --old-from", ["fs", "edit", "cart", "/a", "--old", "x", "--old-from", "/x", "--new", "y"], "give exactly one of --old and --old-from"],
["neither --new nor --new-from", ["fs", "edit", "cart", "/a", "--old", "x"], "give exactly one of --new and --new-from"],
["unreadable --from", ["fs", "write", "cart", "/a", "--from", "/no/such/file"], "cannot read /no/such/file"],
["--mode that is not octal", ["fs", "mkdir", "cart", "/d", "--mode", "rwx"], '--mode takes octal permission bits such as 755, not "rwx"'],
["--mode with a digit past 7", ["fs", "write", "cart", "/a", "--mode", "0789"], '--mode takes octal permission bits such as 755, not "0789"'],
]) {
test(`usage error, nothing called: ${why}`, () => {
const r = cli(argv);
Expand Down
26 changes: 19 additions & 7 deletions api/docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,16 +259,16 @@ trace line covers the command's run.
| `LayerFS` (2) | `LayerFS.open(name)`, `LayerFS.init(name, folder, timeout?)`: static, each returns a ready client |
| project (2) | `list_branches(pageSize?, pagePos?)`, `list_sandboxes(pageSize?, pagePos?)` |
| fs reads (7) | `read(ref, path, startLine?, endLine?)`, `list(ref, path?, pageSize?, pagePos?)`, `stat(ref, path)`, `grep(ref, pattern, path?, pageSize?, pagePos?)`, `glob(ref, pattern, path?, pageSize?, pagePos?)`, `diff_files(leftRef, leftPath, rightRef, rightPath)`, `blame(ref, path, fromLine?)` |
| fs writes (7) | `write(branch, path, content, message?)`, `edit(branch, path, oldText, newText, message?)`, `apply(branch, changes, message?)`, `mkdir(branch, path, message?)`, `rm(branch, path, message?)`, `cp(branch, from, to, message?)`, `mv(branch, from, to, message?)` |
| fs writes (7) | `write(branch, path, content, message? \| { message?, mode? })`, `edit(branch, path, oldText, newText, message?)`, `apply(branch, changes, message?)`, `mkdir(branch, path, message? \| { message?, mode? })`, `rm(branch, path, message?)`, `cp(branch, from, to, message?)`, `mv(branch, from, to, message?)` |
| branch (6) | `fork(name, fromRef?)`, `list_commits(branch, pageSize?, pagePos?)`, `diff_commits(from, to, pageSize?, pagePos?, { cursor?, hunks? }?)`, `diff_branches(from, to, pageSize?, pagePos?, { cursor?, hunks? }?)`, `merge(a, b, name, message?, units?)`, `publish(branch, commit?)` |
| sandbox (3) | `create({ image }?)` → `{ sandbox, image }`, `list_workspaces(sandbox, pageSize?, pagePos?)`, `close(sandbox)` |
| workspace (4) | `mount(sandbox, branch)` → `{ sandbox, path, image }`, `exec(ws, command, message?, { stdout?, stderr? }?)`, `commit(ws, message?, force?)`, `unmount(ws, discard?)` |

A Workspace's status is not an agent call: it is `backend.daemon.workspace.status(ws)`
in [`@layerfs/backend`](backend.md).

Every grouped method returns a Promise and takes positional arguments only; no
method takes an options object. A ref is `main`, a Branch name, a Commit
Every grouped method returns a Promise and takes positional arguments; only
the trailing `{ ... }?` arguments shown above are options objects. A ref is `main`, a Branch name, a Commit
id or a Layer id. Filesystem paths are absolute in that view. Main changes only
through publish. There is no `remove` alias, harness export, agent/call attribution,
public inspect or public process-control method.
Expand All @@ -284,8 +284,8 @@ no history. Unchanged state creates no Commit solely for a message.

### Writes

`apply` takes ordered `{op, ...}` changes (`write`, `edit`, `mkdir`, `rm`, `cp`,
`mv`) and publishes once. A refusal identifies its zero-based `detail.index`.
`apply` takes ordered `{op, ...}` changes (`write`, `edit`, `mkdir`, `chmod`,
`rm`, `cp`, `mv`) and publishes once. A refusal identifies its zero-based `detail.index`.
Each call uses a private indexed Workspace, the ordinary Workspace semantic ports
and the shared captured Content constructor. Failed validation closes only that
private overlay. Uncertain construction/publication keeps original custody.
Expand All @@ -296,6 +296,18 @@ identity/timestamps. Destinations are exact and must not exist. Same-path/subtre
moves or copies are InvalidInput. Mkdir creates parents and an existing directory
is a no-op. Edit requires exactly one match and streams large files.

A file's or folder's permission bits are an integer `mode` such as `0o755`. A
`write` (in `fs.write`'s options or an `fs.apply` change) or `mkdir` may carry
one: a new file gets `mode`, else 0o644; an existing file keeps its mode unless
`mode` is given; a new folder gets `mode`, else 0o755, while parents it creates
get 0o755 and an existing folder keeps its mode, as `mkdir -p -m` does.
`{ op: "chmod", path, mode }` sets the mode of an existing file or folder.
`fs.stat` reports `mode`. A mode that is not an integer from 0 to 0o7777 is
`InvalidInput`; one LayerFS cannot store (set-user-ID or set-group-ID on any
entry, the sticky bit on a file) is `InvalidInput` with `detail`
`{ reason: "Mode", path, mode }` (octal text), as in `project.init`. A symbolic
link has no mode of its own: `chmod` of one is `NotFile`.

Branch writes, like `workspace.commit`, read the Branch head, merge with it and
publish inside the Store's short publication lane, so concurrent writers never
meet a moved head. A call whose private Workspace was bound before another
Expand Down Expand Up @@ -482,10 +494,10 @@ table differs, and when a family's files stop matching the SDK's.
| `fs.glob` | `layerfs fs glob <ref> <pattern> [<path>] [--page-size <n>] [--page-pos <n>]` |
| `fs.diff_files` | `layerfs fs diff_files <left-ref> <left-path> <right-ref> <right-path>` |
| `fs.blame` | `layerfs fs blame <ref> <path> [--from-line <n>]` |
| `fs.write` | `layerfs fs write <branch> <path> [-m <text>] [--from <file>]` |
| `fs.write` | `layerfs fs write <branch> <path> [-m <text>] [--mode <octal>] [--from <file>]` |
| `fs.edit` | `layerfs fs edit <branch> <path> [-m <text>] [--old <text>] [--old-from <file>] [--new <text>] [--new-from <file>]` |
| `fs.apply` | `layerfs fs apply <branch> [-m <text>] [--from <file>]` |
| `fs.mkdir` | `layerfs fs mkdir <branch> <path> [-m <text>]` |
| `fs.mkdir` | `layerfs fs mkdir <branch> <path> [-m <text>] [--mode <octal>]` |
| `fs.rm` | `layerfs fs rm <branch> <path> [-m <text>]` |
| `fs.cp` | `layerfs fs cp <branch> <from-path> <to-path> [-m <text>]` |
| `fs.mv` | `layerfs fs mv <branch> <from-path> <to-path> [-m <text>]` |
Expand Down
23 changes: 22 additions & 1 deletion engine/crates/layerfs-api/src/base/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ pub fn selector<'a>(args: &'a SdkValue, name: &str, branch: &'a str) -> Arg<Sele
})
}

/// A change's optional `mode`: permission bits, an integer up to 0o7777.
/// Whether the entry can hold them is checked where the entry's kind is known.
fn mode(change: &SdkValue) -> Result<Option<u32>, SdkRefusal> {
match change.get("mode") {
None | Some(SdkValue::Null) => Ok(None),
Some(SdkValue::Int(n)) if (0..=0o7777).contains(n) => Ok(Some(*n as u32)),
Some(_) => Err(invalid(
"mode must be an integer permission mode from 0 to 0o7777",
)),
}
}

/// One change of a file call, from its `op` and that op's fields. A write
/// whose bytes were to follow the call (`streamed`) is refused: no bytes
/// follow a call on the in-process route.
Expand All @@ -97,10 +109,12 @@ pub fn change(change: &SdkValue) -> Arg<Change<'_>> {
(Some(SdkValue::Text(s)), None, None) => Change::Write {
path: p("path")?,
bytes: s.as_bytes(),
mode: mode(change)?,
},
(None, Some(SdkValue::Bytes(b)), None) => Change::Write {
path: p("path")?,
bytes: b,
mode: mode(change)?,
},
(None, None, Some(SdkValue::Int(n))) if *n > 0 => {
p("path")?;
Expand All @@ -113,7 +127,14 @@ pub fn change(change: &SdkValue) -> Arg<Change<'_>> {
old: text(change, "oldText")?,
new: text(change, "newText")?,
},
"mkdir" => Change::Mkdir { path: p("path")? },
"mkdir" => Change::Mkdir {
path: p("path")?,
mode: mode(change)?,
},
"chmod" => Change::Chmod {
path: p("path")?,
mode: mode(change)?.ok_or_else(|| invalid("chmod needs a mode"))?,
},
"rm" => Change::Rm { path: p("path")? },
"cp" => Change::Cp {
from: p("from")?,
Expand Down
Loading