spacedriveapp / spacedriveapp/spacebot
Spacebot 0.5.0 (Docker, self-hosted): model allowlist, [agents.permissions] schema gap, channel-level fallbacks, dedicated voice-transcription endpoints, non-atomic config writes
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 2.4k
- Forks
- 367
- PR merge metrics
- No merged PRs in 30d
Description
We run Spacebot in production (12 agents, messaging binding via Telegram, several LLM providers — native and custom — with all API keys kept as env: references, no secrets in config). This is a multi-topic report from operating that deployment; each of the five sections below is self-contained and can be split into a separate issue. Sections 1 and 4 are feature requests, sections 2, 3 and 5 are bugs/gaps.
Environment
- Spacebot version: 0.5.0
- Deployment: self-hosted, Docker
- Config:
/data/config.tomlinside the container; mutated both by deploying the file and by the portal (Settings → Config File editor, per-agent routing forms, agent/link management), with the config watcher hot-reloading changes (~2 s in our deployment)
1. Feature request: configurable model_allowlist to restrict the model picker in the portal
Summary. The portal's model picker exposes the entire models.dev catalog, filtered only by which provider API keys are configured. There is currently no way to restrict the selectable list — this is a feature request for one.
No filtering mechanism exists today. The docs confirm this from two directions:
- docs/features/portal describes the per-agent model setting as: "Model | Any configured model (per-process overrides)" — any configured model, no restriction.
- docs/getting-started/configuring-channels documents a per-conversation model override, again with no filtering.
We also checked the config schema (src/config/toml_schema.rs): an option like enabled_models / model_allowlist does not exist.
Problem. In any real deployment only a small subset of the catalog is actually routable. Our routing currently uses five distinct models across six slots:
[defaults.routing]
channel = "openrouter/minimax/minimax-m3:free"
branch = "openrouter/minimax/minimax-m3:free"
worker = "openrouter/minimax/minimax-m3:free"
compactor = "openrouter/minimax/minimax-m2.7:free"
cortex = "openrouter/minimax/minimax-m2.7:free"
voice = "openrouter/google/gemini-2.5-flash"
Yet because an OpenRouter key (and a few other provider keys) is configured, every model picker in the portal offers the full catalog of those providers — hundreds of entries, of which we can actually use a handful. This causes:
- Misconfiguration risk: users can select models that are not usable in this deployment — no quota or subscription coverage for them, wrong capability class, or excluded on policy grounds (in our case several free-tier providers are excluded because their terms allow training on inputs, and company/customer data flows through our agents).
- Broken selections: a bad selection produces a channel that fails on the next message and surfaces raw provider errors to end users until an admin notices and reverts. During a recent free-tier scarcity window we had to switch the channel primary manually three times within ~24 hours, each time after users hit errors.
- Support overhead: with 12 agents × 6 routing slots, the intended models are buried in a giant dropdown instead of being the obvious choices.
Proposal. A model_allowlist config option that restricts the selectable model list shown in the portal UI. Models outside the allowlist would be hidden — or, preferably for admins, shown disabled with a hint that they are excluded by the allowlist. Schematic example:
# Global: restricts every model picker in the portal UI.
# Absent/empty = current behaviour (full models.dev catalog filtered by configured keys).
[defaults]
model_allowlist = [
"openrouter/minimax/minimax-m3:free",
"openrouter/z-ai/glm-5.2:free",
"openrouter/google/gemma-4-31b-it:free",
"groq/openai/gpt-oss-120b",
"openrouter/google/gemini-2.5-flash",
]
# Optional refinement: a tighter list per routing class (or per agent),
# e.g. only these may be picked as a channel model:
# [defaults.routing.allowlist]
# channel = ["openrouter/minimax/minimax-m3:free", "openrouter/z-ai/glm-5.2:free"]
Details worth deciding:
- Fallback targets should not be restricted by the picker allowlist (they are admin-configured, not user-selected), but ideally get validated on load — warn when a configured routing/fallback model does not resolve.
- Typo protection: warn at config load when an allowlist entry doesn't match a known provider/model. Note the model-ID format is
provider/modelwith the provider split at the first slash (so IDs likegroq/openai/gpt-oss-120bmust remain valid). - Default: absent/empty allowlist keeps today's behaviour, so the feature is purely opt-in.
2. Bug: documented [agents.permissions] schema is missing from the shipped build (silently dropped)
Summary. The documentation fully describes declarative per-agent permissions; the shipped build's config schema (src/config/toml_schema.rs) does not know the block. Documentation and code contradict each other, and [agents.permissions] blocks in config.toml are silently dropped — no validation error, no warning, neither on restart nor on hot-reload.
What the docs promise. docs/configuration/permissions documents a "Config Schema" section with a per-agent permissions block (shown there as [agents.main.permissions]) covering:
file_read,file_write—"deny"|"workspace"|"allow"| array of globsshellexec—"deny"|"allowlist"|"allow", plus a companionexec_allowlistbrowser, plusbrowser_js_evalandbrowser_url_allowlistnetwork_outbound, plusnetwork_allow_private
And explicitly:
Default when no
[permissions]block exists: all tools return permission denied.
Notably, the [[agents]] key table in docs/configuration/config omits these fields — so even within the docs the schema is only discoverable on the permissions page.
What the build does. The block is not part of the shipped schema (toml_schema.rs) and is silently ignored. We configured three permission profiles per the docs (developer / research / minimal) across our agents. A live bypass test on a "minimal" agent configured with browser = "deny" (plus denied shell/exec/network_outbound) showed that it could still navigate, search and download files — the documented default ("all tools return permission denied") is inverted in practice for dropped blocks. What is actually enforced in the current build is the OS sandbox (filesystem containment, key hygiene) and the real schema field [agents.browser] enabled = false, which we now use instead; shell, exec and web search cannot be disabled per agent at all in this build. In other words, a documented security feature is a no-op that looks configured — the silent drop makes it worse than a plain "not implemented", because nothing ever tells the operator.
Reproduction:
- Add a block to config.toml as described in docs/configuration/permissions, e.g.:
[[agents]]
id = "research"
display_name = "Research"
# Documented as [agents.main.permissions] in the docs; attached to the
# most recent [[agents]] entry when written as a sub-table:
[agents.permissions]
file_read = "workspace"
file_write = "workspace"
shell = "deny"
exec = "deny"
browser = "deny"
network_outbound = "deny"
- Restart the container, or just wait for the config hot-reload (~2 s in our deployment).
- Observe: no validation error and no warning is emitted for the unknown block; the block is silently ignored.
- Ask the agent to browse or run a web search — it proceeds as if unrestricted (or inspect the parsed config: the permissions are absent).
Expected behaviour (either one):
toml_schema.rsimplements the documented permissions schema, or- the documentation is corrected — and, independently, unknown config blocks/keys emit at least a warning on load so this class of misconfiguration is never silent.
3. Bug: [defaults.routing.fallbacks] not applied to channel processes (docs-vs-behaviour discrepancy)
Summary. The documentation promises that fallback chains apply to all process types, including channel (chat) processes. In the shipped build we observe the opposite for channel processes: a provider error on the channel model triggers no fallback attempt, and the raw provider error surfaces unfiltered in the chat. This is a docs-vs-behaviour discrepancy, not an undocumented limitation.
What the docs promise.
- docs/core/routing, "Level 3: Fallback Chains": "Fallback is built into
SpacebotModel::completion()" — and the page's code sample builds the channel model with.with_routing(routing.clone()), i.e. the documented mechanism explicitly covers channel processes, not just branches/workers. - docs/configuration/config: "[defaults.routing.fallbacks] — Map of model names to ordered fallback chains. Used when the primary model returns a retriable error."
- Documented retriable triggers: HTTP 429, 502/503/504, connection timeout, "overloaded" error responses. Documented non-triggers: success, HTTP 400, auth/billing errors. Documented limits: max 3 attempts, 60 s cooldown.
Our configuration (sanitized excerpt):
[defaults.routing]
channel = "openrouter/minimax/minimax-m3:free"
branch = "openrouter/minimax/minimax-m3:free"
worker = "openrouter/minimax/minimax-m2.7:free"
compactor = "openrouter/minimax/minimax-m2.7:free"
cortex = "openrouter/minimax/minimax-m2.7:free"
[defaults.routing.fallbacks]
"openrouter/minimax/minimax-m3:free" = [
"openrouter/z-ai/glm-5.2:free",
"openrouter/google/gemma-4-31b-it:free",
"groq/openai/gpt-oss-120b",
]
"openrouter/minimax/minimax-m2.7:free" = [
"openrouter/z-ai/glm-5.2:free",
"groq/openai/gpt-oss-120b",
]
Observed behaviour. The chains work as documented for non-channel processes: a failing branch call transparently moves to the next stage. For the channel reply — the direct, user-facing answer — a single-shot provider error does not trigger the chain; the raw provider error reaches the conversation. During a recent free-tier scarcity window we had to switch the channel primary manually three times within ~24 hours (each time after users reported raw 429-style errors, i.e. errors the docs classify as retriable), while branch/worker traffic degraded gracefully along the chain. We see the same asymmetry with quota exhaustion on a subscription provider: worker/background processes automatically drop to the next stage, the channel does not and needs a manual model switch.
Note on the likely cause: from reading the v0.5.0 source, the fallback-aware completion path appears to be wired into branch/worker/background process construction, while the channel reply path resolves its model without attaching the configured fallback chain — which matches the asymmetry we observe. We may be misreading the code, but the behavioural split above reproduces reliably either way.
Reproduction:
- Configure a fallback chain for the model used by
channel(see excerpt above). - In a normal chat conversation (portal or connected messaging channel), force a retriable provider error — e.g. a channel model whose quota is exhausted (HTTP 429), or a provider returning 502/503/504 or timing out. (Per the docs, HTTP 400 and auth/billing errors are explicitly non-triggers, so those are not suitable for this test.)
- Observe: the raw provider error is returned into the chat; no fallback model is attempted, well below the documented limits of 3 attempts / 60 s cooldown.
- For comparison, trigger the same error from a non-channel process (a delegated branch task or a background job): there the fallback chain engages.
Expected behaviour. Channel replies resolve through the configured fallback chain as documented — the chain is tried before an error is surfaced, and a raw provider error string never reaches the end user.
4. Feature request: voice transcription via dedicated audio-transcription endpoints
Current state. Voice-model routing and attachment transcription exist — introduced in v0.1.13 (CHANGELOG.md: "feat: add dedicated voice model routing and attachment transcription", PR #98) — but the implementation routes transcription through the chat-completions path: transcribe_audio_attachment() in src/agent/channel_attachments.rs sends exactly one POST to {base_url}/v1/chat/completions with an input_audio content block, using the model configured in routing.voice (KNOWN_VOICE_TRANSCRIPTION_MODELS in src/api/models.rs is only a UI filter, not a runtime gate). The transcript is injected into the conversation context. Notably, the voice key under [defaults.routing] is not documented in docs/configuration/config, and no transcription-endpoint configuration is documented anywhere.
Consequences.
- Transcription is tied to a chat model that supports audio-in/text-out, and to the chat provider's capabilities. Dedicated ASR services cannot be used at all: Groq serves audio (e.g.
whisper-large-v3) exclusively via its/audio/transcriptionsand/audio/translationsendpoints and offers no chat-completions model with audio input;zai/glm-asr-2512is the same kind of endpoint-type mismatch (multipart transcription route, hosted under the general Zhipu API rather than the coding-plan key). Both are unusable on the current hardcoded/v1/chat/completionscall. - Cost: we currently transcribe through a chat-capable audio model reached via a chat-provider gateway — noticeably more expensive per audio minute than dedicated ASR endpoints.
- No resilience on this path: the transcription path makes exactly one request and never consults
routing.fallbacks— a configured voice fallback chain is effectively dead code here. A transcription-provider failure fails the voice message with[Audio transcription failed for …: unknown error], while the HTTP status only appears in the server log (voice transcription provider returned error, fieldstatus). - The hardcoded endpoint suffix is also brittle against providers whose OpenAI-compatible base URL already ends in a version segment — we observed a native Gemini voice route 404 for exactly this reason (
…/v1beta/openai+/v1/chat/completions).
Proposal.
- Dedicated audio-transcription endpoints as a first-class mode for
routing.voice, decoupled from the routed chat model. This is cheaper per minute and removes the dependency of voice handling on chat-provider capabilities (a deployment without any audio-capable chat model could still transcribe). Schematic:
# New api_type for OpenAI-compatible transcription endpoints
# (POST {base_url}/v1/audio/transcriptions, multipart form):
[llm.provider.groq_asr]
api_type = "openai_transcriptions"
base_url = "https://api.groq.com/openai"
api_key = "env:GROQ_API_KEY"
[defaults.routing]
# Resolved against transcription-capable providers instead of chat models:
voice = "groq_asr/whisper-large-v3"
- Document the existing voice routing config: the
voicekey under[defaults.routing](live since v0.1.13) is missing from docs/configuration/config, and there is no documented transcription-endpoint configuration — we had to root-cause the transcription path in the source to learn how it behaves.
Related hardening of the same code path (observed while debugging the current implementation):
- Evaluate
routing.fallbacksin the transcription path so voice fallback chains actually work. - Include the HTTP status in the user-facing error message instead of falling back to
"unknown error". - Forward
provider.extra_headerswith the transcription request (currently ignored).
5. Bug: non-atomic config.toml writes + missing locking in several API handlers
Summary. config.toml is persisted in place via tokio::fs::write — no temp-file + rename — and several API handlers that mutate the config (including update_group in links.rs) do not hold the config_write_mutex. Two concurrent portal requests can therefore interleave their read-modify-write cycles and corrupt the file. The docs are silent on both points: docs/core/agents only states —
All write operations persist to
config.tomland update in-memory state immediately. The file watcher triggers hot reload for other subsystems that read from config.
— with nothing documented about write atomicity or concurrency, which is exactly the gap that bit us.
Real incident (2026-08-30). After concurrent group edits in the portal — a link-group edit racing another settings change — our config.toml became unparseable: a TOML parse error on the next read. The config had to be restored from our version-controlled copy. This is a realistic scenario, not a theoretical one: in our deployment the same file is written from many portal surfaces (the Settings → Config File editor, the per-agent routing forms at /agents/{id}/config?tab=routing — "Changes saved to config.toml" — plus agent/link/binding management), and the config watcher hot-reloads within ~2 s of every write, so a torn file immediately breaks the running configuration.
Reproduction:
- Open the portal in two sessions (two browser tabs with an authenticated session, or direct API calls).
- Fire two config-mutating requests concurrently — e.g. a link-group edit (
update_groupinlinks.rs) from one tab and an unrelated settings save from the other. - Repeat to hit the race window.
- Observe: config.toml comes out truncated/interleaved and fails to parse on the next read (config watcher / config editor reports a TOML parse error); recent edits from one or both requests are lost.
Suggested fix direction.
- Atomic writes: serialize the config to a temp file in the same directory, then rename over config.toml (rename is atomic on POSIX, so the watcher can never observe a half-written file):
let tmp = config_path.with_extension("toml.tmp");
tokio::fs::write(&tmp, &rendered).await?;
// optionally fsync the temp file first for durability
tokio::fs::rename(&tmp, &config_path).await?;
- Shared write lock: the existing
config_write_mutex(or an equivalent shared write lock) must be held by every config-mutating handler — includingupdate_groupinlinks.rs— so that load → mutate → serialize cycles cannot interleave. Longer term, centralizing "load → mutate → serialize → atomic write" behind a single helper would make it impossible for new handlers to bypass locking and atomicity.
Both measures complement each other: the lock prevents lost updates between concurrent mutations, the atomic rename prevents torn files.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by splitting the report into its five sections and read src/config/toml_schema.rs, docs/configuration/permissions, docs/configuration/config, and docs/core/routing. Trace how channel and non-channel processes receive routing and how config hot-reload handles unknown tables. Done means each separated issue has a confirmed scope, matching implementation or documentation, and regression coverage for the reported behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, rust
- Domain
- ai, backend, documentation, security, web-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100