Release 0.2.0 #4
@@ -145,7 +145,7 @@ The schema is split into two buckets (§5.1), and the split is the point:
|
||||
|
||||
**Memory injection into the prompt**: `AgentSystemContext::load_inject_memory` (`loop_adapters/system.rs`) routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → `UserLoopRuntime` → `AgentSystemContext`. `assistant` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
|
||||
|
||||
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Several are resolved by the system-context source itself (`loop_adapters/system.rs`) from the session owner (`user_id`) + registry (`shared_pool`) + their `UserFs`, so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SKILLS_LIST__` (the generated skills index — see Filesystem & containers), `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
|
||||
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Several are resolved by the system-context source itself (`loop_adapters/system.rs`) from the session owner (`user_id`) + registry (`shared_pool`) + their `UserFs`, so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SKILLS_LIST__` (the generated skills index — see Filesystem & containers), `__SANDBOX_COMMANDS__` (the sandbox command hint — see below), `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
|
||||
|
||||
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` (`SecretsStore` is built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). The global runtime no longer writes `mcp_events` there: notification persistence is an explicit `McpManager::new` argument (`EventLog::{Persist,Discard}`), `Discard` for the ownerless global runtime and `Persist` for each per-user one, because an event belongs to whoever it happened to and its only reader (event triage) is per-user. Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets, not on call-site migration.
|
||||
|
||||
@@ -180,6 +180,8 @@ Two views, **one storage**: for the mounted subtree the fs-tools run **host-side
|
||||
|
||||
**The skills index is generated, and the sentinel is the knob.** What reaches the model is not a file anyone maintains but a **function of the two trees** (`crates/skald-core/src/skills/`, pure functions in the shape of `LlmCommandManager`): each skill's `SKILL.md` **path** plus its frontmatter `description`, truncated to 200 chars, under an imperative header ("you MUST read its SKILL.md") — the countermeasure to the real failure mode, which is the model *under*-triggering. Printing the full path rather than an id plus a composition rule is what makes a read tool unnecessary: `read_file` on the printed path is one call, and there is no step left for the model to get wrong. Injection is the placeholder `<!-- SKILLS_LIST -->` (normally `<!-- INCLUDE: common/skills.md -->`, a fragment that holds **only** the sentinel), substituted in `AgentSystemContext::build_base` beside `__MCP_LIST__`; `resolve_includes` needs no branch, its generic `<!-- KEY -->` → `__KEY__` arm already covers it. There is **no `meta.json` flag** — the sentinel *is* the switch, so the four `type: system` agents opt out by not including the fragment (an imperative "read it with read_file" is exactly wrong in an unattended turn, and some of those run with `allow_tools: false`). All eleven `chat`/`task` agents carry the include, sub-agents included: in a delegation the one doing the work is the child. Three rendering rules are load-bearing and each closes a specific failure: a **stable order** (scope, then id) because the index sits inside the provider's cache key; a **deterministic tail cut** at an 8 KB budget, announced by a `[N more skills omitted]` line, because a silently truncated index has the model conclude in good faith that a skill does not exist; and **empty in, empty out** — every word of prose lives inside the render, so an instance with no skills spends zero tokens and leaves no orphan sentence (the MCP list is the counter-example: its prose sits *around* the placeholder, and the empty state once had the model inventing a discovery tool). A colliding id is marked `[name collision]` on **both** lines, never shadowed. A malformed skill is skipped with a `warn!`, never fatal — the index is built while assembling a prompt. Freshness has two doors, one per writer. The in-process tools invalidate directly (`Skald::invalidate_prompt_prefix`, called by `skill_register`/`skill_delete`); a hand edit on the box is caught by the **skills watcher** (`skills/watch.rs`, spawned from `spawn_background`): a recursive `notify` on `{WD}/skills` + `{WD}/skills-users`, debounced ~800 ms, that re-digests each touched tree (`skills::tree_digest` — the (id, description) pairs the index is made of) and emits `SystemEvent::SkillsChanged { scope }` only when the digest moved. The subscriber `spawn_skills_freshness` (next to `spawn_user_lifecycle`, same `Weak` shape) maps the scope and calls the same invalidate accessor. Editing a script leaves the digest byte-identical and announces nothing — which is exactly the §6 rule, so an invisible change costs nobody a cache miss. Two gotchas the code carries comments for: the watcher **canonicalizes `{WD}`** (FSEvents reports real paths, and `/var` is a symlink on macOS), and it creates the two trees if absent (a box before its first user has neither).
|
||||
|
||||
**The sandbox command list is a discovery hint, and the tool — not the sentinel — is the knob.** `container/commands.rs` probes the user's container at login (`UserContextFactory::build`, right after `ensure()`, **non-fatal**) with one `docker exec` running `command -v` over a curated ~35-entry `PROBE_ALLOWLIST`, and the result rides `LoopConfig.sandbox_commands` → `AgentSystemContext` → `__SANDBOX_COMMANDS__`. Three decisions carry it and each is the answer to an obvious-looking alternative. **The allowlist is the curation, and the probe is there so the list cannot lie** — not the other way round: a full `PATH` dump is 800 entries of coreutils noise, so what is worth tokens is decided by hand, and `command -v` exists only so we never announce something a container recreate threw away. A tool outside the list therefore never appears, which is fine because **the rendered prose says the list is partial and names `command -v`** — an inventory the model reads as exhaustive is the failure this shape avoids, the same one the skills index's `[N more skills omitted]` line closes. Order is the allowlist's own (grouped by kind of work), never sorted: the grouping *is* the curation, and the reader is a model, not a `grep`. **Staleness is cheap in both directions**, which is why there is no refresh machinery at all: a mid-session install is known to the agent that ran it, and a container recreate costs one `not found` plus the `apt-get install` the agent was already able to do. Gating is the one part that is not the skills pattern: every `AGENT.md` carries `<!-- INCLUDE: common/sandbox.md -->`, **including the four `type: system` ones**, and the section is emitted iff the turn's model is shown `execute_cmd` — computed from `allow_tools` plus the security group's visibility filter (`session/handler/config.rs`) for a root turn, and from `child_defs` for a sub-agent, i.e. always from *the same definitions the model will see*. Hence `has_execute_cmd` is in the `PrefixCache` key: the group is switchable mid-conversation from the chat's shield pill, and keying on it costs nothing because that switch already rewrites the tool payload sitting in the same provider cache. The fragment holds only the heading and one stable sentence; **every conditional claim lives in the renderer** (a departure from the `__MCP_LIST__` shape it otherwise follows), because prose promising `sudo apt-get install` is not the renderer's to retract when the tool is absent. Three rendered cases, and the middle one is why this is not a one-liner: the list, the *unreadable-probe* line (empty ≠ bare sandbox — rendering nothing under a heading that promises a list is how the MCP section once had a model invent a discovery tool), and the no-`execute_cmd` line. `execute_cmd`'s own description deliberately carries **no** capability advertisement — its `(python + node available)` was removed when this landed, since its job is steering the model *away* from the shell for work a file tool does better, and the two messages dilute each other.
|
||||
|
||||
**Containment** (`resolve_host_path`) is unchanged and still guards **the host branch**: every path that lands on a mount is canonicalized (following symlinks) and prefix-checked against its mount base, **fail-closed**. That check is what it always was — the defence against a symlink planted from inside the container pointing at the **host's** `/etc`, which the host-side tool would otherwise follow off the box. Opening the container branch does not weaken it: that branch never touches the host filesystem, so there is no host to escape from, and the check keeps applying to everything mounted. `grep_files` stays disk-only (regex ≠ FTS; memory → `memory_search`) but resolves its root the same way. `execute_cmd`'s `workdir` is an agent path mapped to its container path via `UserFs::to_container`.
|
||||
|
||||
The threading: `UserContext.fs` (built by `container::build_user_fs` at login, snapshotting shared memberships) → `ChatSessionManager` → `ChatSessionHandler.fs` → `ToolContext.fs`. **Admin CRUD is wired** (`src/frontend/api/shared_folders.rs` — `GET/POST /api/shared-folders`, `PATCH/DELETE /api/shared-folders/{id}`, `POST`/`DELETE .../members[/{user_id}]`; UI `shared-folders.js`): a create/describe/delete + per-member `can_write` surface, and each mutation emits `SystemEvent::UserMountsChanged`, on which the lifecycle reconciler runs `Skald::refresh_user_mounts` — rebuilding the affected user's fs + container mounts **in place**, so a membership change lands without a re-login (blueprint §6's "admin CRUD" + "membership refresh without re-login" TODOs, now closed; it still settles at next login/boot if the live remount fails). `execute_cmd` /stop is robust: the command runs under `setsid -w` in its own process-group (leader pid recorded in a container pidfile), and a `KillReaper` drop-guard reaps that group on /stop **or** timeout via a detached `docker exec` that walks `/proc` and kills members by **positive pid** (the container's dash mishandles `kill -<pgid>`); the pidfile is passed positionally (`$1`), and the container's `--init` (tini) reaps the killed processes so no zombies accumulate. **Per-user MCP connectors now run inside this container** (§7) — the container infra enabled it; see the MCP connectors section.
|
||||
|
||||
@@ -77,6 +77,8 @@ To change what gets notified, edit `data/notifications.md`.
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
## System configuration
|
||||
|
||||
Configuration tools are hidden by default to keep context small. Call `activate_tools(["config"])` to load them when you need to manage the instance's setup — plugins, scheduled jobs, secrets — then work normally.
|
||||
|
||||
@@ -122,3 +122,5 @@ No other output — the file is the report.
|
||||
<!-- INCLUDE: common/mcp.md -->
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
@@ -66,3 +66,5 @@ _Date: 2026-06-03_
|
||||
<!-- INCLUDE: common/mcp.md -->
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Your sandbox
|
||||
|
||||
You work inside your own private Linux container: your home, the shared folders and the projects you belong to are mounted in it, and `execute_cmd` runs there.
|
||||
|
||||
<!-- SANDBOX_COMMANDS -->
|
||||
@@ -121,3 +121,5 @@ Assume the person you are writing about could one day read this. Write something
|
||||
None. There is no filesystem, no memory, no search, no connector, no notification, nothing to call. Everything you need is in the message you were given, and the report is your answer — not something you save anywhere.
|
||||
|
||||
If you find yourself wanting to check something, you cannot, and that is the design. Say what the transcript supports, say plainly when it does not support something, and stop there.
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
@@ -142,6 +142,8 @@ You are producing **structured data, not a message to the user.** The main agent
|
||||
|
||||
<!-- INCLUDE: common/memory.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
You read memory primarily to evaluate relevance. Write to memory only when you discover something genuinely new and durable — for example, a new contact who wrote for the first time, or a project status update that changes what the user needs to monitor.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,3 +15,5 @@ You do NOT delegate to other agents. Do the work yourself.
|
||||
<!-- INCLUDE: common/mcp.md -->
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
@@ -85,6 +85,8 @@ There may be other helpers in the household's team — each good at different th
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
---
|
||||
|
||||
## Shared folders
|
||||
|
||||
@@ -6,6 +6,8 @@ You always run **for one specific user**, over `user-memory/` in their own encry
|
||||
|
||||
<!-- INCLUDE: common/memory-lint.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
---
|
||||
|
||||
## Your store
|
||||
|
||||
@@ -6,6 +6,8 @@ The shared store belongs to nobody in particular, so this pass runs as the **adm
|
||||
|
||||
<!-- INCLUDE: common/memory-lint.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
---
|
||||
|
||||
## Your store
|
||||
|
||||
@@ -14,6 +14,8 @@ The user is talking to a single assistant that already knows the project. They s
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
## System configuration
|
||||
|
||||
Configuration tools are hidden by default to keep context small. Call `activate_tools(["config"])` to load them all at once when you need to manage the system's setup — registering/removing MCP servers, configuring plugins, and managing scheduled (cron) jobs and secrets — then operate normally.
|
||||
|
||||
@@ -118,3 +118,5 @@ If the main agent calls you again on a related topic, check if a relevant scratc
|
||||
<!-- INCLUDE: common/mcp.md -->
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
@@ -10,6 +10,8 @@ You are a staff-level software architect. You receive a change request, study th
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
## Available agents
|
||||
|
||||
Delegate work to these task specialists via `execute_task` / `execute_subtask`:
|
||||
|
||||
@@ -12,6 +12,8 @@ You work on **any file type** in any project: Rust, Swift, Python, JavaScript/Ty
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
---
|
||||
|
||||
## Project context
|
||||
|
||||
@@ -126,6 +126,8 @@ Do not wait for permission to use a tool that would clearly help.
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
## Persistent memory
|
||||
|
||||
<!-- INCLUDE: common/memory.md -->
|
||||
@@ -12,6 +12,8 @@ You do **not** implement features yourself except for trivial scaffolding (creat
|
||||
|
||||
<!-- INCLUDE: common/skills.md -->
|
||||
|
||||
<!-- INCLUDE: common/sandbox.md -->
|
||||
|
||||
## Available agents
|
||||
|
||||
Delegate work to these task specialists via `execute_task` / `execute_subtask`:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! What the agent is told its sandbox can run — a **discovery aid, not an
|
||||
//! inventory**.
|
||||
//!
|
||||
//! The failure this closes is upstream of any tool call: an agent that does not
|
||||
//! know `ffmpeg` is installed either declines the job or spends a round finding
|
||||
//! out. So the point is to make the common case answerable without a round-trip,
|
||||
//! and nothing more. It follows that:
|
||||
//!
|
||||
//! - **The list is curated, not discovered.** `ls /usr/bin` is 800 entries of
|
||||
//! coreutils noise; a hint that long is not a hint. [`PROBE_ALLOWLIST`] is the
|
||||
//! curation — the image's own toolbelt plus the handful of things an agent
|
||||
//! plausibly installs — and its **order is meaningful** (grouped by the kind of
|
||||
//! work), which is why nothing here sorts.
|
||||
//! - **The probe exists so the list cannot lie**, not so it can discover. A
|
||||
//! hand-maintained list drifts from the image, and a container recreate throws
|
||||
//! away everything an agent installed with apt; `command -v` at login means we
|
||||
//! never announce something that is not there.
|
||||
//! - **Incompleteness is stated, not hidden.** The rendered section says the list
|
||||
//! is partial and that more can be installed — so a tool outside the allowlist
|
||||
//! costs the agent one `command -v`, which is what it would have paid anyway.
|
||||
//!
|
||||
//! Because it is a hint, staleness is cheap in both directions: a mid-session
|
||||
//! install is known to the agent that performed it, and a container recreate
|
||||
//! costs one `not found` plus an `apt-get install` on a path the agent was
|
||||
//! already walking. Hence a plain login-time snapshot, refreshed at the next
|
||||
//! login, and no invalidation machinery.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// How long the probe may take before login gives up on it.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The commands worth spending prompt tokens on, in the order they are rendered.
|
||||
///
|
||||
/// Grouped by the kind of work, because the reader is a model deciding whether
|
||||
/// it can do a job — related tools next to each other is the whole value of a
|
||||
/// curated list over a sorted one. Two kinds of entry live here: what
|
||||
/// `container/Dockerfile` installs, and what an agent plausibly adds with
|
||||
/// `sudo apt-get install` (`pandoc`, `cargo`, `yt-dlp`…) — the latter appear
|
||||
/// only once actually installed, at the next login.
|
||||
///
|
||||
/// Keep it short. Every addition is paid on every request of every agent that
|
||||
/// can run commands, and a list long enough to skim is a list that stopped
|
||||
/// being a hint.
|
||||
pub const PROBE_ALLOWLIST: &[&str] = &[
|
||||
// Runtimes and package managers.
|
||||
"python3", "pip3", "node", "npm", "cargo", "go", "php", "perl",
|
||||
// Media.
|
||||
"ffmpeg", "ffprobe", "convert", "yt-dlp",
|
||||
// Documents and OCR.
|
||||
"pdftotext", "pdftoppm", "tesseract", "pandoc",
|
||||
// Text, data, search.
|
||||
"jq", "rg", "sqlite3", "file",
|
||||
// Archives.
|
||||
"unzip", "zip", "tar", "xz", "gzip",
|
||||
// Network and source control.
|
||||
"curl", "wget", "git", "ssh", "rsync", "dig",
|
||||
// Build.
|
||||
"make", "gcc", "g++",
|
||||
];
|
||||
|
||||
/// The shell snippet run inside the container: one `command -v` per allowlist
|
||||
/// entry, printing the ones that resolve.
|
||||
///
|
||||
/// `exit 0` is load-bearing — without it the script's status is that of the last
|
||||
/// `command -v`, so a container missing the final entry would look like a failed
|
||||
/// probe. Entries are interpolated rather than passed positionally because they
|
||||
/// are compile-time constants restricted to `[a-z0-9+._-]` (asserted by
|
||||
/// `allowlist_is_shell_safe`), unlike the user-supplied paths in `exec_fs`.
|
||||
pub fn probe_script() -> String {
|
||||
let mut s = String::from("for c in");
|
||||
for c in PROBE_ALLOWLIST {
|
||||
s.push(' ');
|
||||
s.push_str(c);
|
||||
}
|
||||
s.push_str("; do command -v \"$c\" >/dev/null 2>&1 && echo \"$c\"; done; exit 0");
|
||||
s
|
||||
}
|
||||
|
||||
/// Parses the probe's stdout: one command per line, blanks dropped, duplicates
|
||||
/// collapsed, **order preserved** (the script walks the allowlist, so its output
|
||||
/// already carries the curation).
|
||||
pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let name = line.trim();
|
||||
if name.is_empty() || out.iter().any(|c| c == name) {
|
||||
continue;
|
||||
}
|
||||
out.push(name.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Probes `container` for the allowlisted commands it actually has.
|
||||
///
|
||||
/// One `docker exec`, bounded by [`PROBE_TIMEOUT`]. Callers treat a failure as an
|
||||
/// empty list: this is a hint, and login must never fail for it.
|
||||
pub async fn probe_container_commands(container: &str) -> Result<Vec<String>> {
|
||||
let stdout = tokio::time::timeout(
|
||||
PROBE_TIMEOUT,
|
||||
super::exec_fs::sh(container, &probe_script(), &[]),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("sandbox command probe timed out after {PROBE_TIMEOUT:?}"))?
|
||||
.context("sandbox command probe failed")?;
|
||||
|
||||
Ok(parse_probe_output(&String::from_utf8_lossy(&stdout)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The allowlist is interpolated straight into a shell script, so every entry
|
||||
/// must be inert there. This is the check that lets `probe_script` skip the
|
||||
/// positional-argument dance `exec_fs` needs for user-supplied paths.
|
||||
#[test]
|
||||
fn allowlist_is_shell_safe() {
|
||||
for c in PROBE_ALLOWLIST {
|
||||
assert!(
|
||||
!c.is_empty()
|
||||
&& c.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+._-".contains(ch)),
|
||||
"allowlist entry is not shell-safe: {c:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_has_no_duplicates() {
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for c in PROBE_ALLOWLIST {
|
||||
assert!(!seen.contains(c), "duplicate allowlist entry: {c}");
|
||||
seen.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// A container missing the *last* allowlist entry must not read as a failed
|
||||
/// probe — see the `exit 0` note on `probe_script`.
|
||||
#[test]
|
||||
fn probe_script_always_exits_zero() {
|
||||
assert!(probe_script().ends_with("exit 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_drops_blanks_and_duplicates_and_keeps_order() {
|
||||
let out = parse_probe_output("ffmpeg\n\n jq \nffmpeg\ngit\n");
|
||||
assert_eq!(out, vec!["ffmpeg", "jq", "git"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_of_nothing_is_empty() {
|
||||
assert!(parse_probe_output("").is_empty());
|
||||
assert!(parse_probe_output("\n \n").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use tokio::io::AsyncWriteExt;
|
||||
/// Runs a shell snippet inside `container` with `args` bound to `$1`, `$2`, …
|
||||
/// Returns raw stdout — callers that expect text decode it themselves, so a
|
||||
/// binary `cat` is not mangled on the way through.
|
||||
async fn sh(container: &str, script: &str, args: &[&str]) -> Result<Vec<u8>> {
|
||||
pub(super) async fn sh(container: &str, script: &str, args: &[&str]) -> Result<Vec<u8>> {
|
||||
let mut argv: Vec<&str> = vec!["exec", container, "sh", "-c", script, "_"];
|
||||
argv.extend_from_slice(args);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! a container can be recreated from the image at any time; boot reconciliation
|
||||
//! relies on that.
|
||||
|
||||
pub mod commands;
|
||||
pub mod exec_fs;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -126,30 +126,6 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
);
|
||||
let model = meta.client.as_deref().map(ModelHint::name);
|
||||
|
||||
// The child's system context: its own prompt, no per-turn extras.
|
||||
let context = Arc::new(AgentSystemContext {
|
||||
agent_id: id.to_string(),
|
||||
extra_static: None,
|
||||
extra_dynamic: None,
|
||||
tail_reminder: None,
|
||||
substitutions: Default::default(),
|
||||
pool: self.pool.clone(),
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
// A sub-agent sees the same skills its parent does: in a delegation
|
||||
// the one doing the work is the child, so an index injected only in
|
||||
// the parent would leave it knowing a procedure exists and handing
|
||||
// the job to someone who cannot read it.
|
||||
fs: self.fs.clone(),
|
||||
project_root: scope.project_root.clone(),
|
||||
// The scratchpad is the session's blackboard: a sub-agent reads and
|
||||
// writes the SAME one as its parent.
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
prefix_cache: self.prefix_cache.clone(),
|
||||
});
|
||||
|
||||
// The child's def list: parent's base minus root-only minus the
|
||||
// re-derived augmentations (added back natively below), plus
|
||||
// sub-agents-only tools, through the approval visibility filter.
|
||||
@@ -176,6 +152,42 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
});
|
||||
}
|
||||
|
||||
// The child's system context: its own prompt, no per-turn extras.
|
||||
//
|
||||
// Built here rather than before `child_defs` because the sandbox command
|
||||
// hint is gated on the child's own view of `execute_cmd` — which the
|
||||
// visibility filter above may have just removed. A child that cannot run
|
||||
// commands must not be told what it could run with them.
|
||||
let has_execute_cmd = child_defs.iter().any(|d| {
|
||||
d["function"]["name"].as_str() == Some(crate::tools::tool_names::EXECUTE_CMD)
|
||||
});
|
||||
let context = Arc::new(AgentSystemContext {
|
||||
agent_id: id.to_string(),
|
||||
extra_static: None,
|
||||
extra_dynamic: None,
|
||||
tail_reminder: None,
|
||||
substitutions: Default::default(),
|
||||
pool: self.pool.clone(),
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
// A sub-agent sees the same skills its parent does: in a delegation
|
||||
// the one doing the work is the child, so an index injected only in
|
||||
// the parent would leave it knowing a procedure exists and handing
|
||||
// the job to someone who cannot read it.
|
||||
fs: self.fs.clone(),
|
||||
project_root: scope.project_root.clone(),
|
||||
// The scratchpad is the session's blackboard: a sub-agent reads and
|
||||
// writes the SAME one as its parent.
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
// Same sandbox as the parent: one container per user, and the child
|
||||
// runs in it.
|
||||
sandbox_commands: self.config.sandbox_commands.clone(),
|
||||
has_execute_cmd,
|
||||
prefix_cache: self.prefix_cache.clone(),
|
||||
});
|
||||
|
||||
// Native child tools: clarification, sub-delegation (depth permitting),
|
||||
// and the frame-scoped activate_tools with a FRESH grant set — a child
|
||||
// never inherits the parent's activations.
|
||||
|
||||
@@ -47,10 +47,13 @@ use agent_loop::ids::ConversationId;
|
||||
/// minutes; OpenAI's automatic prefix cache is fuzzier and can last longer.
|
||||
pub const PREFIX_TTL: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
/// A conversation plus the agent running in it. Both are needed: a sub-agent
|
||||
/// shares its parent's conversation but has its own prompt, and therefore its
|
||||
/// own cache prefix.
|
||||
type Key = (ConversationId, String);
|
||||
/// A conversation, the agent running in it, and whether that agent is shown
|
||||
/// `execute_cmd`. The first two because a sub-agent shares its parent's
|
||||
/// conversation but has its own prompt; the third because the sandbox command
|
||||
/// hint appears with the tool, and the security group behind it is switchable
|
||||
/// mid-conversation — a switch that already invalidates the provider's cache by
|
||||
/// rewriting the tool payload, so keying on it here costs nothing.
|
||||
type Key = (ConversationId, String, bool);
|
||||
|
||||
struct Entry {
|
||||
base: String,
|
||||
@@ -134,7 +137,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(conv: &str, agent: &str) -> Key {
|
||||
(ConversationId::new(conv), agent.to_string())
|
||||
(ConversationId::new(conv), agent.to_string(), true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -68,6 +68,9 @@ pub struct LoopConfig {
|
||||
/// Automatic compaction bounds the context instead of a message window.
|
||||
pub auto_compaction_enabled: bool,
|
||||
pub datetime: DatetimeConfig,
|
||||
/// Allowlisted commands this user's container actually has, snapshotted at
|
||||
/// login — the prompt's discovery hint. See [`crate::container::commands`].
|
||||
pub sandbox_commands: Arc<Vec<String>>,
|
||||
pub max_agent_depth: u32,
|
||||
}
|
||||
|
||||
@@ -248,6 +251,23 @@ impl UserLoopRuntime {
|
||||
let TurnInputs { scope, config, live_input } = inputs;
|
||||
let frame_agent = config.agent_id.clone();
|
||||
|
||||
// The agent's own declarations. Loaded once here and used three times
|
||||
// below — for the sandbox hint, the tool set, and the selector's
|
||||
// strength floor.
|
||||
let meta = crate::agents::load_meta(&frame_agent).ok();
|
||||
|
||||
// Whether this turn's model is shown `execute_cmd`, which is what gates
|
||||
// the sandbox command hint. Read from the same two things that decide the
|
||||
// tool set below and in that order: an agent declaring `allow_tools:
|
||||
// false` is shown nothing at all, and otherwise `base_tool_defs` has
|
||||
// already been through the security group's visibility filter
|
||||
// (`session/handler/config.rs`). Deriving it from the registry instead
|
||||
// would advertise a sandbox to exactly the agents that cannot reach it.
|
||||
let has_execute_cmd = meta.as_ref().is_none_or(|m| m.allow_tools)
|
||||
&& config.base_tool_defs.iter().any(|d| {
|
||||
d["function"]["name"].as_str() == Some(crate::tools::tool_names::EXECUTE_CMD)
|
||||
});
|
||||
|
||||
// ── System context ──
|
||||
let system = Arc::new(AgentSystemContext {
|
||||
agent_id: frame_agent.clone(),
|
||||
@@ -263,13 +283,11 @@ impl UserLoopRuntime {
|
||||
project_root: scope.project_root.clone(),
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
sandbox_commands: self.config.sandbox_commands.clone(),
|
||||
has_execute_cmd,
|
||||
prefix_cache: self.prefix_cache.clone(),
|
||||
});
|
||||
|
||||
// The agent's own declarations. Loaded once here and used twice below —
|
||||
// for the tool set and for the selector's strength floor.
|
||||
let meta = crate::agents::load_meta(&frame_agent).ok();
|
||||
|
||||
// ── Tool set: the native tools, then the surface's legacy ones ──
|
||||
//
|
||||
// Unless the agent declares it gets none. An empty set is not the same as
|
||||
|
||||
@@ -48,6 +48,17 @@ pub struct AgentSystemContext {
|
||||
/// sub-task (the blackboard is shared by every agent of a session).
|
||||
pub scratchpad_sid: i64,
|
||||
pub datetime: DatetimeConfig,
|
||||
/// Allowlisted commands this user's sandbox has, snapshotted at login.
|
||||
pub sandbox_commands: Arc<Vec<String>>,
|
||||
/// Whether **this turn's model** is shown `execute_cmd`.
|
||||
///
|
||||
/// The command list is a hint about a tool, so it appears exactly when the
|
||||
/// tool does — under a restrictive security group, or for an agent declaring
|
||||
/// `allow_tools: false`, advertising a sandbox the model cannot reach is
|
||||
/// noise at best. The flag must therefore be derived from the same
|
||||
/// definitions the model will see, never from the agent's type or from an
|
||||
/// unfiltered registry.
|
||||
pub has_execute_cmd: bool,
|
||||
/// The user's frozen prefixes — `base` is assembled once per conversation
|
||||
/// and reused while its provider cache could still be warm.
|
||||
pub prefix_cache: Arc<PrefixCache>,
|
||||
@@ -60,7 +71,12 @@ impl SystemContextSource for AgentSystemContext {
|
||||
// between rounds — which is what an agent editing an injected memory
|
||||
// file used to cause — invalidates the entire request. It is therefore
|
||||
// built once per conversation and held; see [`super::prefix_cache`].
|
||||
let key = (turn.conversation.clone(), self.agent_id.clone());
|
||||
// `has_execute_cmd` is in the key because the security group can change
|
||||
// mid-conversation (the chat's shield pill), which adds or removes the
|
||||
// sandbox section. Keying on it costs nothing: the same switch rewrites
|
||||
// the tool payload, which sits in the provider's cached prefix too, so
|
||||
// the miss is already paid.
|
||||
let key = (turn.conversation.clone(), self.agent_id.clone(), self.has_execute_cmd);
|
||||
let static_content = match self.prefix_cache.get(&key) {
|
||||
Some(base) => base,
|
||||
None => {
|
||||
@@ -167,6 +183,12 @@ impl AgentSystemContext {
|
||||
&crate::skills::render_index(&self.fs.load()),
|
||||
);
|
||||
}
|
||||
if static_content.contains("__SANDBOX_COMMANDS__") {
|
||||
static_content = static_content.replace(
|
||||
"__SANDBOX_COMMANDS__",
|
||||
&render_sandbox_commands(&self.sandbox_commands, self.has_execute_cmd),
|
||||
);
|
||||
}
|
||||
if static_content.contains("__SHARED_FOLDERS__") {
|
||||
static_content = static_content.replace(
|
||||
"__SHARED_FOLDERS__",
|
||||
@@ -348,6 +370,47 @@ impl AgentSystemContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// `__SANDBOX_COMMANDS__` — the sandbox discovery hint.
|
||||
///
|
||||
/// The prose that varies lives here rather than in `agents/common/sandbox.md`,
|
||||
/// which is the one departure from the `__MCP_LIST__` shape it otherwise
|
||||
/// follows. It has to: when the model is not shown `execute_cmd`, a fragment
|
||||
/// promising `sudo apt-get install` is a lie the renderer could not retract,
|
||||
/// because it would not be the renderer's to retract. So the fragment keeps only
|
||||
/// the heading and its one stable sentence, and every conditional claim is made
|
||||
/// here.
|
||||
///
|
||||
/// Three cases, and the middle one is the reason this is not a one-liner: an
|
||||
/// empty list means the probe could not run, **not** that the sandbox is bare —
|
||||
/// rendering nothing under a heading that promises a list is how the MCP section
|
||||
/// once had a model invent a tool to go find one.
|
||||
fn render_sandbox_commands(commands: &[String], has_execute_cmd: bool) -> String {
|
||||
if !has_execute_cmd {
|
||||
return String::from(
|
||||
// No second sentence pointing at the file tools: an agent declaring
|
||||
// `allow_tools: false` has none of those either, and this line must
|
||||
// be true for every way the flag can come out false.
|
||||
"You cannot run shell commands in this session: `execute_cmd` is not available to you.",
|
||||
);
|
||||
}
|
||||
if commands.is_empty() {
|
||||
return String::from(
|
||||
"The list of installed commands could not be read for this session. Assume the \
|
||||
usual Linux toolbelt is present and check a specific one with \
|
||||
`command -v <name>` before relying on it.",
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"Some of the commands it provides: {}.\n\n\
|
||||
**This list is partial**, not an inventory — the sandbox almost certainly has more, \
|
||||
and its absence from the list is not evidence that a command is missing. Check any \
|
||||
other one with `command -v <name>`. You are free to work in there as you see fit, \
|
||||
including installing what you need with `sudo apt-get install …` (which lasts until \
|
||||
the sandbox is recreated).",
|
||||
commands.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
// ── Prompt sections resolved from the registry ───────────────────────────────
|
||||
|
||||
|
||||
@@ -535,6 +598,64 @@ fn resolve_harness_tag(content: String) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── The sandbox command hint ─────────────────────────────────────────────
|
||||
|
||||
fn cmds(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The hint is about a tool. Without the tool it is noise at best, and at
|
||||
/// worst it has a restricted agent plan around a shell it cannot open.
|
||||
#[test]
|
||||
fn without_execute_cmd_no_command_is_named() {
|
||||
let out = render_sandbox_commands(&cmds(&["ffmpeg", "jq"]), false);
|
||||
assert!(!out.contains("ffmpeg"), "{out}");
|
||||
assert!(out.contains("execute_cmd"), "{out}");
|
||||
}
|
||||
|
||||
/// An empty list means the probe could not run — never that the sandbox is
|
||||
/// bare. Rendering nothing under a heading that promises a list is how the
|
||||
/// MCP section once had a model invent a tool to go find one.
|
||||
#[test]
|
||||
fn an_unreadable_probe_says_so_and_names_the_way_out() {
|
||||
let out = render_sandbox_commands(&[], true);
|
||||
assert!(!out.trim().is_empty());
|
||||
assert!(out.contains("command -v"), "{out}");
|
||||
}
|
||||
|
||||
/// The list is a hint, so it has to say it is one: a model that reads it as
|
||||
/// an inventory concludes that an unlisted command does not exist.
|
||||
#[test]
|
||||
fn the_list_is_rendered_and_announced_as_partial() {
|
||||
let out = render_sandbox_commands(&cmds(&["ffmpeg", "jq", "pandoc"]), true);
|
||||
assert!(out.contains("ffmpeg, jq, pandoc"), "{out}");
|
||||
assert!(out.contains("partial"), "{out}");
|
||||
assert!(out.contains("command -v"), "{out}");
|
||||
assert!(out.contains("apt-get install"), "{out}");
|
||||
}
|
||||
|
||||
/// Unlike the skills index, the sentinel here is **not** the knob — every
|
||||
/// `AGENT.md` carries the fragment and the runtime decides. So the wiring has
|
||||
/// to hold in both directions of the gate: the sentinel must never survive
|
||||
/// into the prompt, and the commands must appear only with the tool.
|
||||
#[tokio::test]
|
||||
async fn the_sentinel_is_substituted_whichever_way_the_gate_falls() {
|
||||
for has_exec in [true, false] {
|
||||
let agent = PromptFixture::new("You are a fixture.\n\n<!-- SANDBOX_COMMANDS -->\n");
|
||||
let tree = SkillsTree::new(&[]);
|
||||
let base = base_of_sandbox(
|
||||
&agent.id,
|
||||
tree.fs.clone(),
|
||||
Arc::new(cmds(&["ffmpeg"])),
|
||||
has_exec,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!base.contains("__SANDBOX_COMMANDS__"), "sentinel survived: {base}");
|
||||
assert_eq!(base.contains("ffmpeg"), has_exec, "{base}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── The skills index, as it reaches (or does not reach) the prompt ───────
|
||||
//
|
||||
// These exercise `build_base` rather than the renderer, because the failure
|
||||
@@ -628,6 +749,15 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn base_of(agent_id: &str, fs: core_api::user_fs::UserFs) -> String {
|
||||
base_of_sandbox(agent_id, fs, Arc::new(Vec::new()), false).await
|
||||
}
|
||||
|
||||
async fn base_of_sandbox(
|
||||
agent_id: &str,
|
||||
fs: core_api::user_fs::UserFs,
|
||||
sandbox_commands: Arc<Vec<String>>,
|
||||
has_execute_cmd: bool,
|
||||
) -> String {
|
||||
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
|
||||
AgentSystemContext {
|
||||
agent_id: agent_id.to_string(),
|
||||
@@ -643,6 +773,8 @@ mod tests {
|
||||
project_root: None,
|
||||
scratchpad_sid: 1,
|
||||
datetime: DatetimeConfig { enabled: false, timezone: None },
|
||||
sandbox_commands,
|
||||
has_execute_cmd,
|
||||
prefix_cache: Arc::new(crate::loop_adapters::prefix_cache::PrefixCache::new()),
|
||||
}
|
||||
.build_base()
|
||||
|
||||
@@ -237,6 +237,8 @@ pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
|
||||
datetime: datetime(),
|
||||
// A cache of its own per projection: each case must see a freshly
|
||||
// assembled prefix, never one another case left behind.
|
||||
sandbox_commands: Arc::new(Vec::new()),
|
||||
has_execute_cmd: false,
|
||||
prefix_cache: Arc::new(crate::loop_adapters::prefix_cache::PrefixCache::new()),
|
||||
};
|
||||
let system = system_source
|
||||
|
||||
@@ -68,6 +68,9 @@ impl ChatSessionManager {
|
||||
max_parallel_subagents: usize,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
datetime_config: DatetimeConfig,
|
||||
// The sandbox commands snapshotted at this user's login — see
|
||||
// `crate::container::commands`.
|
||||
sandbox_commands: Arc<Vec<String>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
@@ -100,6 +103,7 @@ impl ChatSessionManager {
|
||||
// configured message cap.
|
||||
auto_compaction_enabled: compactor.auto_enabled(),
|
||||
datetime: datetime_config.clone(),
|
||||
sandbox_commands,
|
||||
max_agent_depth: crate::session::handler::MAX_AGENT_DEPTH as u32,
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -398,6 +398,8 @@ impl Conversation {
|
||||
config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS),
|
||||
config.llm.max_tool_result_chars,
|
||||
DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
|
||||
// No container, no probe: this bundle is inert (§19).
|
||||
Arc::new(Vec::new()),
|
||||
Arc::clone(&tools.tools),
|
||||
// Inert ownerless bundle (§19): the global runtime as a provider,
|
||||
// unfiltered — never actually exercised (no loops, no consumers).
|
||||
|
||||
@@ -250,6 +250,23 @@ impl UserContextFactory {
|
||||
if let Err(e) = self.container.ensure(user_id).await {
|
||||
tracing::warn!(user = %user_id, error = %e, "failed to ensure container before per-user MCP start");
|
||||
}
|
||||
|
||||
// Which of the allowlisted commands this user's sandbox actually has, for
|
||||
// the prompt's discovery hint (`__SANDBOX_COMMANDS__`). Snapshotted here
|
||||
// like fs membership and MCP access, and for the same reason: it changes
|
||||
// at login cadence, not turn cadence. **Non-fatal** — a hint must never
|
||||
// cost a login, and an empty list renders as an honest absence rather
|
||||
// than as a claim that the sandbox is bare.
|
||||
let sandbox_commands = {
|
||||
let name = crate::container::container_name(user_id);
|
||||
match crate::container::commands::probe_container_commands(&name).await {
|
||||
Ok(cmds) => Arc::new(cmds),
|
||||
Err(e) => {
|
||||
tracing::warn!(user = %user_id, error = %e, "sandbox command probe failed; the prompt will omit the command list");
|
||||
Arc::new(Vec::new())
|
||||
}
|
||||
}
|
||||
};
|
||||
let user_mcp = Arc::new(McpManager::new(
|
||||
Arc::clone(&pool),
|
||||
user_shutdown.clone(),
|
||||
@@ -345,6 +362,7 @@ impl UserContextFactory {
|
||||
self.max_parallel_subagents,
|
||||
self.max_tool_result_chars,
|
||||
self.datetime_config.clone(),
|
||||
sandbox_commands,
|
||||
Arc::clone(&self.tools),
|
||||
mcp_view,
|
||||
Arc::clone(&approval),
|
||||
|
||||
@@ -31,7 +31,12 @@ impl Tool for ExecuteCmd {
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute a shell command (sh -c) inside your sandbox container (python + node available). \
|
||||
// No capability advertisement here: which commands the sandbox has is the
|
||||
// system prompt's `<!-- SANDBOX_COMMANDS -->` section, which appears
|
||||
// exactly when this tool does. This description's job is the opposite one
|
||||
// — steering the model *away* from the shell for work a file tool does
|
||||
// better — and the two messages dilute each other.
|
||||
"Execute a shell command (sh -c) inside your sandbox container. \
|
||||
Reserve this for: builds, installs, git, tests, scripts, processes, network, package managers. \
|
||||
Runs as a non-root user; prefix system-package or global installs with `sudo` (e.g. `sudo apt-get install …`). \
|
||||
Do NOT use cat/head/tail to read files — use read_file instead. \
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I
|
||||
|
||||
Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance.
|
||||
|
||||
This index will grow over time. Right now it covers the interface, agents, memory, projects, shared folders, background tasks, system agents, access grants, connectors, skills, voice input and plugins; more sections (security groups…) will be added later.
|
||||
This index will grow over time. Right now it covers the interface, agents, memory, projects, shared folders, background tasks, system agents, access grants, connectors, skills, the sandbox, voice input and plugins; more sections (security groups…) will be added later.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -19,6 +19,7 @@ This index will grow over time. Right now it covers the interface, agents, memor
|
||||
| [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
|
||||
| [access.md](access.md) | Who can use which plugin or connector: the open default, removing access per person, and the role switch that keeps children out of it |
|
||||
| [connectors.md](connectors.md) | Connectors (MCP servers): shared vs per-user, setting one up in the UI, the sign-in and QR-pairing flows, and what to do when one is not working |
|
||||
| [sandbox.md](sandbox.md) | Your sandbox: the private Linux container commands run in, which files survive a rebuild, why the command list in your prompt is partial, and installing what is missing |
|
||||
| [skills.md](skills.md) | Skills: instruction folders the assistant loads on demand — where they live, how to read and run one, and the contract for writing, installing and downloading one |
|
||||
| [voice.md](voice.md) | Voice input: configuring a transcription model, and why the microphone button does nothing unless the page is served over HTTPS or localhost |
|
||||
| [interface.md](interface.md) | The desktop interface: collapsing the sidebar to an icon-only strip to make room for documents |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Your sandbox
|
||||
|
||||
Every member of this instance has their **own private Linux container**, and you work inside theirs. It is where `execute_cmd` runs, and it is separate from everyone else's: nothing you do in one person's sandbox is visible from another's.
|
||||
|
||||
## What is in it
|
||||
|
||||
Mounted into it are the places you already know by name: the home directory (`~`), the shared folders that person belongs to, their projects, and the read-only `skills/` and `docs/` trees. Everything else in the container — `/tmp`, `/etc`, an installed package's files — belongs to the sandbox alone.
|
||||
|
||||
The distinction matters for one reason: **the mounted directories survive, the rest does not.** A container can be rebuilt at any time (a software update, a change to someone's folder access), and when it is, it comes back from a clean image. Files under `~`, the shared folders and the projects are untouched. Anything installed into the container is gone.
|
||||
|
||||
## What you can run
|
||||
|
||||
Your prompt lists **some** of the commands the sandbox provides — the common ones, checked at the start of the session so the list never claims something that is not there. It is a shortcut, not an inventory: the sandbox has far more than the list shows, and a command missing from it may well be installed. Check any specific one with `command -v <name>`.
|
||||
|
||||
You are free to work in there as you see fit, including installing what you need:
|
||||
|
||||
```
|
||||
sudo apt-get install -y <package>
|
||||
```
|
||||
|
||||
No password is needed. Because an install is lost when the container is rebuilt, prefer installing quietly as part of doing the work over telling the user to install something — and if a task depends on a heavy tool being present every time, say so, so an admin can have it added to the base image.
|
||||
|
||||
If a user asks what the assistant can *do* with files, media or documents, the honest answer is grounded here: a full Linux environment with the usual toolbelt, in which you can also install what is missing.
|
||||
|
||||
## When you cannot run commands
|
||||
|
||||
`execute_cmd` is not always available. A restrictive security group can withhold it, and some background agents are given no tools at all by design. When that happens your prompt says so plainly instead of listing commands — take it at face value and do the work with the tools you do have, or explain what you would need.
|
||||
Reference in New Issue
Block a user