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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,39 @@ cmake --build build --config Release -j
.\build\Release\agentvfs-ctl.exe --sock \\.\pipe\agentvfs-<hash> 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).
55 changes: 50 additions & 5 deletions src/cas/platform/linux/fuse_adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<std::mutex> 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;
Expand All @@ -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) {
Expand Down
36 changes: 33 additions & 3 deletions src/cas/platform/macos/fuse_t_adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<std::mutex> 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;
Expand All @@ -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".

Expand Down
77 changes: 62 additions & 15 deletions src/cas/workspace_cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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();
}
Expand All @@ -300,15 +319,21 @@ 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)) {
error = "workspace.json: malformed mount_override value";
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;
Expand Down Expand Up @@ -469,7 +494,7 @@ bool socket_responds(const std::string& socket_path) {

static int workspace_usage() {
std::cerr
<< "Usage: agentvfs workspace start [name] [--root <dir>] [--mount <dir>] [--telemetry auto|none|<csv>]\n"
<< "Usage: agentvfs workspace start [name] [--root <dir>] [--mount <dir>] [--telemetry auto|none|<csv>] [--allow-root]\n"
<< " agentvfs workspace init <name> --from <dir> [--root <dir>] [--mount <dir>]\n"
<< " agentvfs workspace status [name] [--root <dir>]\n"
<< " agentvfs workspace list [--root <dir>]\n"
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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<char*> cargs;
for (auto& a : args) cargs.push_back(const_cast<char*>(a.c_str()));
cargs.push_back(nullptr);
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/cas/workspace_cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct SessionState {
// Lives at <root>/<name>/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);
Expand Down
Loading