feat(container): per-user Docker sandbox + mapped per-user filesystem
Realizes blueprint §6: each user gets a permanent Docker container
(skald-{userid}, our own skald-runtime image with python+node) as their
execution sandbox. Docker is now a hard requirement — a missing daemon fails
Skald::new and the process exits at boot.
- ContainerManager (crates/skald-core/src/container/): docker availability
check, builds skald-runtime from the embedded Dockerfile, reconciles one
running container per active user at boot, stops them at shutdown, and
ensure/remove on user create/delete. Shells the docker CLI (no client crate).
- UserFs (core-api): pure value type carried in ToolContext, mapping the agent's
single namespace — ~/ → homes/{userid}, shared/{X}/ → shared/{X} (membership),
user-memory/ + shared-memory/ → SQLite — to host and container paths.
- execute_cmd now runs inside the caller's container via `docker exec`.
- fs-tools resolve every physical path through UserFs to the per-user host
workspace, host-side, with fail-closed symlink/`..` containment
(resolve_host_path: canonicalize + prefix-check). grep_files resolves its root
the same way but stays disk-only.
- shared_folders + shared_folder_members (registry, junction table with
can_write) back the shared-folder membership that drives both the container
mounts and the shared/{X} routing.
- Threading: UserContext.fs → ChatSessionManager → handler → ToolContext.fs.
Per-user MCP servers do not yet run in the container (next round).
This commit is contained in:
@@ -6,7 +6,10 @@ use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
@@ -19,7 +22,7 @@ impl Tool for ExecuteCmd {
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute a shell command (sh -c) on the host machine. \
|
||||
"Execute a shell command (sh -c) inside your sandbox container (python + node available). \
|
||||
Reserve this for: builds, installs, git, tests, scripts, processes, network, package managers. \
|
||||
Do NOT use cat/head/tail to read files — use read_file instead. \
|
||||
Do NOT use grep/rg/find to search — use grep_files instead. \
|
||||
@@ -91,6 +94,65 @@ impl Tool for ExecuteCmd {
|
||||
fn execute_async<'a>(&'a self, args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
||||
Box::pin(async move { run_from_args(&args).await })
|
||||
}
|
||||
|
||||
/// The real entry point (blueprint §6): the command runs **inside the caller's
|
||||
/// container** via `docker exec`, never on the host. `workdir` is interpreted as
|
||||
/// a path in the agent's namespace (`~/…`, `shared/{X}/…`) and mapped to its
|
||||
/// container path; omitted → the container home. Cancellation still works —
|
||||
/// `kill_on_drop` kills the `docker exec` client when the work future is dropped
|
||||
/// on /stop (best-effort; the in-container process may outlive it — see below).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let container = ctx.fs.container_name.clone();
|
||||
let workdir = match args.get("workdir").and_then(Value::as_str) {
|
||||
Some(p) => ctx.fs.to_container(p),
|
||||
None => ctx.fs.container_home.clone(),
|
||||
};
|
||||
let command = match args.get("command").and_then(Value::as_str) {
|
||||
Some(c) => c.to_string(),
|
||||
None => return crate::tools::fs::error_exec("Missing required argument: command".to_string()),
|
||||
};
|
||||
let timeout_secs = args.get("timeout").and_then(Value::as_u64)
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.clamp(1, MAX_TIMEOUT_SECS);
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
Ok(ToolResult::Text(run_in_container(&container, &workdir, &command, timeout_secs).await?))
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a command inside a user's container: `docker exec -w <wd> <container> sh -c <cmd>`.
|
||||
/// Shares the capture/timeout machinery with the host path.
|
||||
///
|
||||
/// ⚠️ Cancellation caveat: dropping the `docker exec` client on /stop kills that
|
||||
/// client process, but Docker does not guarantee the process it started *inside*
|
||||
/// the container dies with it. For long-running in-container work a robust stop
|
||||
/// would track the PID and `docker exec … kill`; that is a follow-up.
|
||||
async fn run_in_container(
|
||||
container: &str,
|
||||
workdir: &std::path::Path,
|
||||
command: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<String> {
|
||||
tracing::info!(
|
||||
container = %container,
|
||||
workdir = %workdir.display(),
|
||||
command = %command,
|
||||
timeout_secs,
|
||||
"execute_cmd: running command in container"
|
||||
);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("docker");
|
||||
cmd.arg("exec")
|
||||
.arg("-w").arg(workdir)
|
||||
.arg(container)
|
||||
.arg("sh").arg("-c").arg(command)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
capture(cmd, timeout_secs, command).await
|
||||
}
|
||||
|
||||
/// Parse + run a shell command from tool arguments, as an awaitable future.
|
||||
@@ -157,6 +219,12 @@ async fn run(command: String, workdir: Option<PathBuf>, timeout_secs: u64) -> Re
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
|
||||
capture(cmd, timeout_secs, &command).await
|
||||
}
|
||||
|
||||
/// Spawns a prepared command, capturing stdout+stderr under a single timeout, and
|
||||
/// formats the result. Shared by the host `sh -c` path and the `docker exec` path.
|
||||
async fn capture(mut cmd: tokio::process::Command, timeout_secs: u64, command: &str) -> Result<String> {
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout is piped");
|
||||
|
||||
@@ -156,7 +156,12 @@ impl Tool for EditFile {
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
@@ -2,7 +2,10 @@ use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use crate::tools::{
|
||||
Tool, ToolContext, ToolDescriptionLength, ToolExecution, truncate_label,
|
||||
MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||
};
|
||||
use super::resolve;
|
||||
|
||||
pub struct GrepFiles;
|
||||
@@ -84,6 +87,23 @@ impl Tool for GrepFiles {
|
||||
}
|
||||
}
|
||||
|
||||
/// grep stays **disk-only** (regex over a tree ≠ FTS — memory notes are searched
|
||||
/// with `memory_search`). It only resolves its root against the caller's per-user
|
||||
/// workspace, with the same containment as the other fs-tools.
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_else(|| ".".to_string());
|
||||
if super::classify_memory(&path).is_some() {
|
||||
return super::error_exec(
|
||||
"grep_files does not search memory notes; use memory_search for \
|
||||
user-memory/ or shared-memory/".to_string(),
|
||||
);
|
||||
}
|
||||
match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: path"))?;
|
||||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: pattern"))?;
|
||||
|
||||
@@ -93,7 +93,12 @@ impl Tool for InsertAtLine {
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
@@ -71,7 +71,12 @@ impl Tool for ListFiles {
|
||||
/// under the prefix is returned, keyed relative to the requested directory.
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = args["path"].as_str().unwrap_or("").to_string();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
@@ -15,7 +15,9 @@ use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::ToolRegistry;
|
||||
use core_api::user_fs::UserFs;
|
||||
|
||||
use crate::tools::{SimpleExecution, ToolExecution, ToolRegistry, ToolResult};
|
||||
|
||||
/// Extracts the `path` argument as an owned string, if present. Single-file
|
||||
/// tools use this to advertise their target to the UI via `Tool::target_path`,
|
||||
@@ -184,6 +186,49 @@ pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))
|
||||
}
|
||||
|
||||
// ── Per-user physical routing (blueprint §6) ──────────────────────────────────
|
||||
//
|
||||
// A path that is *not* a memory path is physical: it resolves against the caller's
|
||||
// private home (`~/…`) or a shared folder they belong to (`shared/{X}/…`), both on
|
||||
// disk and bind-mounted into their container. The fs-tools run host-side, so we
|
||||
// resolve to the host path here and hand the on-disk `execute` an absolute path.
|
||||
|
||||
/// Resolves a physical (non-memory) agent path to an absolute host path inside the
|
||||
/// caller's workspace, **following symlinks and rejecting any escape** past the
|
||||
/// mount root. This is the containment choke point: since the same tree is writable
|
||||
/// from inside the container (`execute_cmd`), a symlink planted there that points
|
||||
/// outside the home is caught by canonicalizing and prefix-checking against the base.
|
||||
pub(crate) fn resolve_host_path(fs: &UserFs, agent_path: &str) -> Result<PathBuf> {
|
||||
let (base, tail) = fs.host_base_and_tail(agent_path).ok_or_else(|| {
|
||||
anyhow::anyhow!("no such shared folder, or you are not a member: {agent_path}")
|
||||
})?;
|
||||
// Canonicalize both sides so the prefix check is symlink-aware.
|
||||
let base_canon = canonicalize_for_policy(&base.to_string_lossy(), Path::new("/"));
|
||||
let joined = base.join(&tail);
|
||||
let canon = canonicalize_for_policy(&joined.to_string_lossy(), Path::new("/"));
|
||||
if !path_under(&canon, &base_canon) {
|
||||
anyhow::bail!("path escapes your workspace: {agent_path}");
|
||||
}
|
||||
Ok(canon)
|
||||
}
|
||||
|
||||
/// Rewrites the `path` argument of a physical fs-tool call to the resolved absolute
|
||||
/// host path, so the on-disk `execute` (which takes absolute paths as-is) acts on
|
||||
/// the caller's per-user workspace rather than the process working directory.
|
||||
pub(crate) fn rewrite_to_host(fs: &UserFs, agent_path: &str, mut args: Value) -> Result<Value> {
|
||||
let host = resolve_host_path(fs, agent_path)?;
|
||||
args["path"] = Value::String(host.to_string_lossy().into_owned());
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// A tool execution that fails immediately — surfaces a containment / access error
|
||||
/// from `run_with` without attempting a disk op.
|
||||
pub(crate) fn error_exec<'a>(msg: String) -> Box<dyn ToolExecution + 'a> {
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
Err::<ToolResult, _>(anyhow::anyhow!(msg))
|
||||
})))
|
||||
}
|
||||
|
||||
/// Registers the filesystem tools. `shared_pool` is the system (`shared-memory`)
|
||||
/// pool captured once here — a global singleton — and handed to the memory-aware
|
||||
/// tools; each still resolves the per-user (`user-memory`) pool per call from the
|
||||
@@ -207,8 +252,74 @@ mod tests {
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use core_api::user_fs::UserFs;
|
||||
|
||||
use crate::tools::{ExecutionOutcome, Tool, ToolContext};
|
||||
|
||||
/// A trivial workspace for the memory-routing tests, which never touch disk.
|
||||
fn test_fs() -> Arc<UserFs> {
|
||||
Arc::new(UserFs::new(
|
||||
"test",
|
||||
std::env::temp_dir().join("skald-fsmem-home"),
|
||||
"skald-test",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
/// Physical path resolution + containment (blueprint §6): home and shared map
|
||||
/// to their host bases; a non-member shared folder, a `..` escape, and a
|
||||
/// symlink planted inside the home that points outside are all rejected.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn host_path_resolves_and_contains() {
|
||||
use core_api::user_fs::SharedMount;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("skald-fsroot-{}", std::process::id()));
|
||||
let home = root.join("homes").join("u1");
|
||||
let shared = root.join("shared").join("family");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::create_dir_all(&shared).unwrap();
|
||||
|
||||
let fs = UserFs::new(
|
||||
"u1",
|
||||
home.clone(),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![SharedMount {
|
||||
name: "family".into(),
|
||||
host: shared.clone(),
|
||||
container: PathBuf::from("/root/shared/family"),
|
||||
can_write: true,
|
||||
}],
|
||||
);
|
||||
|
||||
let home_canon = canonicalize_for_policy(&home.to_string_lossy(), Path::new("/"));
|
||||
let shared_canon = canonicalize_for_policy(&shared.to_string_lossy(), Path::new("/"));
|
||||
|
||||
// ~/… → private home (containment holds for a not-yet-existing file).
|
||||
let p = resolve_host_path(&fs, "~/notes.md").unwrap();
|
||||
assert!(path_under(&p, &home_canon), "{p:?}");
|
||||
// a bare relative path is home-relative too
|
||||
assert!(path_under(&resolve_host_path(&fs, "proj/main.rs").unwrap(), &home_canon));
|
||||
// shared/{member} → the shared host dir
|
||||
let s = resolve_host_path(&fs, "shared/family/list.md").unwrap();
|
||||
assert!(path_under(&s, &shared_canon), "{s:?}");
|
||||
|
||||
// a shared folder the user is NOT a member of → error
|
||||
assert!(resolve_host_path(&fs, "shared/secret/x.md").is_err());
|
||||
// `..` cannot climb out of the home
|
||||
assert!(resolve_host_path(&fs, "~/../u2/secret.md").is_err());
|
||||
|
||||
// a symlink planted in the home that points outside is rejected: the
|
||||
// canonicalized target escapes the home base.
|
||||
std::os::unix::fs::symlink(&root, home.join("escape")).unwrap();
|
||||
assert!(resolve_host_path(&fs, "~/escape/homes/u2/secret.md").is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_memory_splits_root_from_key() {
|
||||
let u = classify_memory("user-memory/notes/x.md").unwrap();
|
||||
@@ -264,7 +375,7 @@ mod tests {
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let read = ReadFile::new(Arc::clone(&shared));
|
||||
let list = ListFiles::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
|
||||
|
||||
// Private write lands in the user pool — and never in the shared one.
|
||||
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte\npane"}))
|
||||
@@ -314,7 +425,7 @@ mod tests {
|
||||
let insert = InsertAtLine::new(Arc::clone(&shared));
|
||||
let replace = ReplaceLines::new(Arc::clone(&shared));
|
||||
let search = SearchFile::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
|
||||
|
||||
async fn note(pool: &SqlitePool, path: &str) -> String {
|
||||
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
|
||||
@@ -359,7 +470,7 @@ mod tests {
|
||||
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let search = MemorySearch::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user), fs: test_fs() };
|
||||
|
||||
// one note in each store, both mentioning "wifi"
|
||||
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
|
||||
|
||||
@@ -112,7 +112,12 @@ impl Tool for ReadFile {
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
@@ -98,7 +98,12 @@ impl Tool for ReplaceLines {
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
@@ -111,7 +111,12 @@ impl Tool for SearchFile {
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
@@ -62,7 +62,12 @@ impl Tool for WriteFile {
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
|
||||
Reference in New Issue
Block a user