From 478c0f9ab86ad8a946dbf40e16061d15aa9c8fc4 Mon Sep 17 00:00:00 2001 From: Zhipeng Xie Date: Sun, 24 May 2026 23:49:54 +0800 Subject: [PATCH] feat: implement chmod/chown/utimens, fix access perms, add --allow-root - Add utimens, chmod, chown FUSE handlers to silence ENOSYS on touch, chmod, chown, cp -p, rsync -a - Fix cas_access to check owner permission bits (0400/0200/0100) instead of other bits (0004/0002/0001), fixing chdir EACCES on owner-only directories - Add --allow-root flag to 'agentvfs workspace init/start', persisted in workspace.json, passes -o allow_root to the FUSE daemon - Document self-hosting workflow in README Signed-off-by: Zhipeng Xie --- README.md | 33 ++++++++++ src/cas/platform/linux/fuse_adapter.cpp | 55 ++++++++++++++-- src/cas/platform/macos/fuse_t_adapter.cpp | 36 ++++++++++- src/cas/workspace_cli.cpp | 77 ++++++++++++++++++----- src/cas/workspace_cli.h | 1 + 5 files changed, 179 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7460bb5..c85edde 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,39 @@ cmake --build build --config Release -j .\build\Release\agentvfs-ctl.exe --sock \\.\pipe\agentvfs- checkpoint baseline ``` +### Self-hosting: build inside a ContextFS mount + +ContextFS can build and install itself from within its own FUSE mount, +giving every checkout a self-contained build environment with checkpoint +and rollback for development experimentation. + +Pass `--allow-root` to `agentvfs workspace start` (or `agentvfs workspace init`) +when root needs to access the FUSE mount — required for +`sudo cmake --install build` to write to system paths from inside the mount +(needs `user_allow_other` in `/etc/fuse.conf`, already set by the prebuilt +installer). The setting is persisted in `workspace.json` for subsequent starts. + +```bash +# 1. Build and install agentvfs normally first +cd path/to/ContextFS +cmake -B build -DAGENTVFS_EBPF=OFF && cmake --build build -j +sudo cmake --install build + +# 2. Start a workspace with allow_root on the source tree itself +agentvfs workspace init selfhost --from $(pwd) --allow-root +agentvfs workspace start selfhost --allow-root +# or via start.sh (pass --allow-root after workspace args): +# ./start.sh path/to/ContextFS +# agentvfs workspace start selfhost --allow-root + +# 3. Rebuild and install from inside the mount +cd /run/user/$(id -u)/agentvfs/selfhost/mount +rm -rf build # fresh build dir inside the FUSE mount +cmake -B build -DAGENTVFS_EBPF=OFF +cmake --build build -j +sudo cmake --install build # allow_root enables sudo access +``` + ## License Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE). diff --git a/src/cas/platform/linux/fuse_adapter.cpp b/src/cas/platform/linux/fuse_adapter.cpp index 6c132a9..a1bc85d 100644 --- a/src/cas/platform/linux/fuse_adapter.cpp +++ b/src/cas/platform/linux/fuse_adapter.cpp @@ -294,12 +294,13 @@ static int cas_access(const char* path, int mask) { if (!entry) return -ENOENT; if (mask == F_OK) return 0; - // Spec §FUSE callbacks: MVP checks only the "other" mode bits; owner - // uid/gid tracking is deferred. Single-agent scope makes this safe. + // getattr always sets st_uid/st_gid to the caller's own, so the + // caller is always the "owner" of the file in this FS's semantics. + // Check owner permission bits (0400/0200/0100), not other bits. uint32_t m = entry->mode; - if ((mask & R_OK) && !(m & 0004)) return -EACCES; - if ((mask & W_OK) && !(m & 0002)) return -EACCES; - if ((mask & X_OK) && !(m & 0001)) return -EACCES; + if ((mask & R_OK) && !(m & 0400)) return -EACCES; + if ((mask & W_OK) && !(m & 0200)) return -EACCES; + if ((mask & X_OK) && !(m & 0100)) return -EACCES; return 0; } @@ -396,6 +397,47 @@ static int cas_readlink(const char* path, char* buf, size_t size) { return 0; } +static int cas_utimens(const char* path, const struct timespec tv[2], + struct fuse_file_info* fi) { + (void)tv; + (void)fi; + Daemon* d = get_daemon(); + if (Daemon::is_hidden(path)) return -ENOENT; + if (auto* bs = d->bootstrap()) bs->ensure_path(path); + auto br = resolve_branch(d); + auto entry = br->wt.lookup(path); + if (!entry) return -ENOENT; + return 0; +} + +static int cas_chmod(const char* path, mode_t mode, struct fuse_file_info* fi) { + (void)fi; + Daemon* d = get_daemon(); + if (Daemon::is_hidden(path)) return -ENOENT; + if (auto* bs = d->bootstrap()) bs->ensure_path(path); + auto br = resolve_branch(d); + std::lock_guard lk(br->checkpoint_mu); + auto entry = br->wt.lookup(path); + if (!entry) return -ENOENT; + entry->mode = (entry->mode & S_IFMT) | (mode & 07777); + br->wt.insert(path, *entry); + return 0; +} + +static int cas_chown(const char* path, uid_t uid, gid_t gid, + struct fuse_file_info* fi) { + (void)uid; + (void)gid; + (void)fi; + Daemon* d = get_daemon(); + if (Daemon::is_hidden(path)) return -ENOENT; + if (auto* bs = d->bootstrap()) bs->ensure_path(path); + auto br = resolve_branch(d); + auto entry = br->wt.lookup(path); + if (!entry) return -ENOENT; + return 0; +} + static void fill_fuse_ops(struct fuse_operations& ops) { ops.getattr = cas_getattr; ops.open = cas_open; @@ -415,6 +457,9 @@ static void fill_fuse_ops(struct fuse_operations& ops) { ops.rename = cas_rename; ops.symlink = cas_symlink; ops.readlink = cas_readlink; + ops.utimens = cas_utimens; + ops.chmod = cas_chmod; + ops.chown = cas_chown; } int run_filesystem(Daemon& daemon, const MountOptions& opts) { diff --git a/src/cas/platform/macos/fuse_t_adapter.cpp b/src/cas/platform/macos/fuse_t_adapter.cpp index 34f96eb..5ac040e 100644 --- a/src/cas/platform/macos/fuse_t_adapter.cpp +++ b/src/cas/platform/macos/fuse_t_adapter.cpp @@ -334,10 +334,13 @@ static int cas_access(const char* path, int mask) { if (!entry) return -ENOENT; if (mask == F_OK) return 0; + // getattr always sets st_uid/st_gid to the caller's own, so the + // caller is always the "owner" of the file in this FS's semantics. + // Check owner permission bits (0400/0200/0100), not other bits. uint32_t m = entry->mode; - if ((mask & R_OK) && !(m & 0004)) return -EACCES; - if ((mask & W_OK) && !(m & 0002)) return -EACCES; - if ((mask & X_OK) && !(m & 0001)) return -EACCES; + if ((mask & R_OK) && !(m & 0400)) return -EACCES; + if ((mask & W_OK) && !(m & 0200)) return -EACCES; + if ((mask & X_OK) && !(m & 0100)) return -EACCES; return 0; } @@ -430,6 +433,31 @@ static int cas_readlink(const char* path, char* buf, size_t size) { return 0; } +static int cas_chmod(const char* path, mode_t mode) { + Daemon* d = get_daemon(); + if (Daemon::is_hidden(path) || is_appledouble(path)) return -ENOENT; + if (auto* bs = d->bootstrap()) bs->ensure_path(path); + auto br = resolve_branch(d); + std::lock_guard lk(br->checkpoint_mu); + auto entry = br->wt.lookup(path); + if (!entry) return -ENOENT; + entry->mode = (entry->mode & S_IFMT) | (mode & 07777); + br->wt.insert(path, *entry); + return 0; +} + +static int cas_chown(const char* path, uid_t uid, gid_t gid) { + (void)uid; + (void)gid; + Daemon* d = get_daemon(); + if (Daemon::is_hidden(path) || is_appledouble(path)) return -ENOENT; + if (auto* bs = d->bootstrap()) bs->ensure_path(path); + auto br = resolve_branch(d); + auto entry = br->wt.lookup(path); + if (!entry) return -ENOENT; + return 0; +} + int run_filesystem(Daemon& daemon, const MountOptions& opts) { struct fuse_operations ops{}; ops.getattr = cas_getattr; @@ -451,6 +479,8 @@ int run_filesystem(Daemon& daemon, const MountOptions& opts) { ops.rename = cas_rename; ops.symlink = cas_symlink; ops.readlink = cas_readlink; + ops.chmod = cas_chmod; + ops.chown = cas_chown; // xattr callbacks left unset: libfuse returns -ENOSYS, which // clients treat equivalently to ENOTSUP. Spec §"Semantic gaps". diff --git a/src/cas/workspace_cli.cpp b/src/cas/workspace_cli.cpp index 96bdda6..eded225 100644 --- a/src/cas/workspace_cli.cpp +++ b/src/cas/workspace_cli.cpp @@ -192,6 +192,24 @@ static bool json_get_long(const std::string& json, return true; } +static bool json_get_bool(const std::string& json, + const std::string& key, + bool& value) { + size_t p = 0; + if (!json_find_key(json, key, p)) return false; + while (p < json.size() && (json[p] == ' ' || json[p] == '\t')) p++; + std::string rest = json.substr(p); + if (rest.substr(0, 4) == "true") { + value = true; + return true; + } + if (rest.substr(0, 5) == "false") { + value = false; + return true; + } + return false; +} + std::string session_to_json(const SessionState& state) { std::ostringstream out; out << "{\n" @@ -273,7 +291,8 @@ bool write_session_file(const std::string& path, std::string workspace_config_to_json(const WorkspaceConfig& config) { std::ostringstream out; out << "{\n" - << " \"mount_override\":\"" << json_escape(config.mount_override) << "\"\n" + << " \"mount_override\":\"" << json_escape(config.mount_override) << "\",\n" + << " \"allow_root\":" << (config.allow_root ? "true" : "false") << "\n" << "}\n"; return out.str(); } @@ -300,8 +319,8 @@ bool parse_workspace_config_json(const std::string& json, } WorkspaceConfig parsed; - // mount_override is optional. Missing key is valid (forward-compat). - // Key present but value unparseable is an error. + // mount_override and allow_root are optional. Missing keys are valid + // (forward-compat). Keys present but unparseable is an error. size_t after_colon = 0; if (json_find_key(json, "mount_override", after_colon)) { if (!json_get_string(json, "mount_override", parsed.mount_override)) { @@ -309,6 +328,12 @@ bool parse_workspace_config_json(const std::string& json, return false; } } + if (json_find_key(json, "allow_root", after_colon)) { + if (!json_get_bool(json, "allow_root", parsed.allow_root)) { + error = "workspace.json: malformed allow_root value"; + return false; + } + } config = parsed; error.clear(); return true; @@ -469,7 +494,7 @@ bool socket_responds(const std::string& socket_path) { static int workspace_usage() { std::cerr - << "Usage: agentvfs workspace start [name] [--root ] [--mount ] [--telemetry auto|none|]\n" + << "Usage: agentvfs workspace start [name] [--root ] [--mount ] [--telemetry auto|none|] [--allow-root]\n" << " agentvfs workspace init --from [--root ] [--mount ]\n" << " agentvfs workspace status [name] [--root ]\n" << " agentvfs workspace list [--root ]\n" @@ -488,6 +513,7 @@ struct ParsedCommon { std::string target; std::string from_dir; // for init std::string mount_override; // for init/start + bool allow_root = false; // for init/start }; static bool is_dir(const std::string& path) { @@ -715,6 +741,8 @@ static bool parse_common_args(int argc, return false; } out.mount_override = argv[i]; + } else if (arg == "--allow-root" && (cmd == "init" || cmd == "start")) { + out.allow_root = true; } else if (!arg.empty() && arg[0] == '-') { std::cerr << "agentvfs: unknown option for workspace " << cmd << ": " << arg << "\n"; return false; @@ -800,6 +828,7 @@ static bool acquire_start_lock(const WorkspacePaths& paths, static pid_t spawn_daemon(const std::string& self_path, const WorkspacePaths& paths, const std::string& telemetry, + bool allow_root, std::string& error) { pid_t pid = fork(); if (pid < 0) { @@ -823,6 +852,10 @@ static pid_t spawn_daemon(const std::string& self_path, "--telemetry=" + telemetry, "-f" }; + if (allow_root) { + args.push_back("-o"); + args.push_back("allow_root"); + } std::vector cargs; for (auto& a : args) cargs.push_back(const_cast(a.c_str())); cargs.push_back(nullptr); @@ -1061,17 +1094,22 @@ static int command_init(const ParsedCommon& opts) { return 1; } - if (!opts.mount_override.empty()) { + { WorkspaceConfig config; - if (!make_absolute(opts.mount_override, config.mount_override, error)) { - std::cerr << "agentvfs: cannot resolve --mount " << opts.mount_override - << ": " << error << "\n"; - return 1; + if (!opts.mount_override.empty()) { + if (!make_absolute(opts.mount_override, config.mount_override, error)) { + std::cerr << "agentvfs: cannot resolve --mount " << opts.mount_override + << ": " << error << "\n"; + return 1; + } } - std::string config_path = paths.root + "/workspace.json"; - if (!write_workspace_config_file(config_path, config, error)) { - std::cerr << "agentvfs: " << error << "\n"; - return 1; + config.allow_root = opts.allow_root; + if (!opts.mount_override.empty() || opts.allow_root) { + std::string config_path = paths.root + "/workspace.json"; + if (!write_workspace_config_file(config_path, config, error)) { + std::cerr << "agentvfs: " << error << "\n"; + return 1; + } } } @@ -1210,11 +1248,20 @@ static int command_start(const ParsedCommon& opts, const std::string& self_path) return 1; } paths.mount = resolve_mount_path(cli_override, config.mount_override, default_mount); + if (opts.allow_root) config.allow_root = true; - // Persist a CLI override so subsequent starts inherit it. + // Persist CLI overrides so subsequent starts inherit them. if (!cli_override.empty() && cli_override != config.mount_override) { WorkspaceConfig new_config = config; new_config.mount_override = cli_override; + new_config.allow_root = config.allow_root || opts.allow_root; + if (!write_workspace_config_file(config_path, new_config, error)) { + std::cerr << "agentvfs: " << error << "\n"; + return 1; + } + } else if (opts.allow_root && !config.allow_root) { + WorkspaceConfig new_config = config; + new_config.allow_root = true; if (!write_workspace_config_file(config_path, new_config, error)) { std::cerr << "agentvfs: " << error << "\n"; return 1; @@ -1246,7 +1293,7 @@ static int command_start(const ParsedCommon& opts, const std::string& self_path) telemetry = select_auto_telemetry(detect_telemetry_availability()); telemetry_warning = telemetry == "none"; } - pid_t pid = spawn_daemon(self_path, paths, telemetry, error); + pid_t pid = spawn_daemon(self_path, paths, telemetry, config.allow_root, error); if (pid <= 0) { std::cerr << "agentvfs: " << error << "\n"; return 1; diff --git a/src/cas/workspace_cli.h b/src/cas/workspace_cli.h index 1be84ee..40581b0 100644 --- a/src/cas/workspace_cli.h +++ b/src/cas/workspace_cli.h @@ -35,6 +35,7 @@ struct SessionState { // Lives at //workspace.json. Optional file: missing means defaults. struct WorkspaceConfig { std::string mount_override; + bool allow_root = false; }; bool is_valid_workspace_name(const std::string& name);