Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f0ecc24
fix: run local user commands in a dedicated workspace
Calmingstorm Jul 27, 2026
9aa49e5
fix: address PR #239 review — real roots, provisioning, ownership, ex…
Calmingstorm Jul 27, 2026
efefe9d
fix: address PR #239 round 2 — memory_path root, Docker volume, metri…
Calmingstorm Jul 27, 2026
26aff01
fix: PR #239 round 3 — per-spawn revalidation, symlink-resolved roots…
Calmingstorm Jul 27, 2026
fbc3819
fix: PR #239 round 4 — self-update preflight, Incus path, visible ref…
Calmingstorm Jul 27, 2026
db78973
fix: PR #239 round 5 — startup migration solves the bootstrap, one pr…
Calmingstorm Jul 27, 2026
ecb72c6
fix: PR #239 round 6 — entrypoint ordering, and one derivation of pro…
Calmingstorm Jul 27, 2026
760a715
fix: PR #239 round 7 — a blank workspace can no longer mean two diffe…
Calmingstorm Jul 27, 2026
93c1870
fix: PR #239 round 8 — workspace opt-in per call site, roots from the…
Calmingstorm Jul 27, 2026
b0f801b
fix: PR #239 round 9 — the last raw-command route, the live config fi…
Calmingstorm Jul 27, 2026
2cdaa52
test: hold the whole local-execution surface closed
Calmingstorm Jul 27, 2026
074721b
fix: PR #239 round 10 — the caller axis closed, config launch path, a…
Calmingstorm Jul 27, 2026
25c7fb3
test: cover every usage-refresh branch deterministically
Calmingstorm Jul 27, 2026
55b5bb2
fix: close the operator-visible gaps this change left open
Calmingstorm Jul 27, 2026
09e17df
fix: PR #239 round 11 — per-check-type workspace, lexical launch pare…
Calmingstorm Jul 27, 2026
04f85a2
fix: close remaining workspace escape and root-alias gaps
Calmingstorm Jul 28, 2026
ee0d68d
fix: cross-review of 04f85a2 — restore the finally guarantee on the u…
Calmingstorm Jul 28, 2026
a0d4770
fix: close final workspace review gaps
Calmingstorm Jul 28, 2026
11c64ba
fix: cross-review of a0d4770 — the legacy fallback must announce itself
Calmingstorm Jul 28, 2026
e457f0e
test: make the fallback tests identity-independent
Calmingstorm Jul 28, 2026
1840201
fix: preserve intended workspace in fallback remediation
Calmingstorm Jul 28, 2026
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
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ RUN pip install --no-cache-dir .
# Copy application source
COPY src/ src/
COPY ui/ ui/

# Working directory for local user commands (tools.local_working_dir).
# Deliberately OUTSIDE the install root (/app here) and outside the data dir:
# local commands run here so a bare relative path — e.g. cleaning up after
# extracting an archive whose internal layout starts with data/ — cannot
# resolve against the live install. Validation fails closed if it is missing
# or not 0700, so this must exist in the image.
RUN mkdir -p /var/lib/odin-workspace && chmod 0700 /var/lib/odin-workspace
COPY config.yml .

CMD ["python", "-m", "src"]
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ playwright install chromium # optional — enables browser_* tools

cp .env.example .env # set DISCORD_TOKEN
$EDITOR config.yml # hosts, LLM, permissions

# Local commands run in a workspace OUTSIDE the install, so a bare relative
# path (e.g. cleaning up after extracting an archive whose internal layout
# starts with `data/`) can never resolve against your live data. It must
# exist, be private, and be owned by you — validation fails closed rather
# than silently falling back to the install directory.
sudo install -d -m 0700 -o "$USER" -g "$(id -gn)" /var/lib/odin-workspace
# …or point `tools.local_working_dir` in config.yml at any private directory
# outside the checkout, e.g.:
# mkdir -p -m 0700 ~/.local/share/odin-workspace

python -m src
```

Expand Down
9 changes: 9 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ sessions:

tools:
enabled: true
# Working directory for local user commands (run_command, run_script,
# run_command_multi, background processes, skill run_on_host). A bare
# relative path in a command resolves HERE, not in the install directory —
# which is what stopped an extracted archive's `rm -rf data` from deleting
# Odin's own live state. Must be an absolute path, owned by the service
# account, mode 0700, and outside the install and data directories.
# Provisioned automatically on start; if it is unusable, local commands
# refuse to run rather than falling back to the install directory.
local_working_dir: /var/lib/odin-workspace
# Customize SSH key path for your deployment
ssh_key_path: /app/.ssh/id_ed25519
# Customize SSH known hosts path for your deployment
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ services:
volumes:
- ./config.yml:/app/config.yml:ro
- ./data:/app/data
# NAMED volume, not a bind mount: Docker initializes a named volume from
# the image directory, preserving the 0700 ownership the resolver
# requires. A fresh bind mount is created 755 root:root, which fails
# closed and costs local-command capability on first run (PR #239 r2).
- odin-workspace:/var/lib/odin-workspace
- ./data/context:/app/data/context:ro
- ./ssh:/app/.ssh:ro
extra_hosts:
Expand Down Expand Up @@ -57,3 +62,8 @@ services:
capabilities: [gpu]
networks:
- default

volumes:
# Local command workspace (tools.local_working_dir). Initialized from the
# image's 0700 directory so a fresh deployment satisfies the resolver.
odin-workspace:
9 changes: 9 additions & 0 deletions packaging/odin.service
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ Type=simple
User=odin
Group=odin
WorkingDirectory=/opt/odin
# Local command workspace (tools.local_working_dir). systemd creates
# /var/lib/odin-workspace on EVERY start, owned by User= with mode 0700, so
# fresh installs, package upgrades, and restarts after a self-update all get a
# valid workspace with no manual step. Local commands run there instead of the
# install directory, so a bare relative path — e.g. cleaning up after
# extracting an archive whose internal layout starts with data/ — cannot
# resolve against live data.
StateDirectory=odin-workspace
StateDirectoryMode=0700
ExecStart=/opt/odin/.venv/bin/python -m src
EnvironmentFile=/etc/odin/.env
Restart=always
Expand Down
10 changes: 10 additions & 0 deletions packaging/postinstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ SERVICE_GROUP="odin"
APP_DIR="/opt/odin"
CONFIG_DIR="/etc/odin"
DATA_DIR="/var/lib/odin"
# Working directory for local user commands (tools.local_working_dir).
# A SIBLING of DATA_DIR, never inside it: local commands run here so a bare
# relative path cannot resolve against the install or the live data.
WORKSPACE_DIR="/var/lib/odin-workspace"
LOG_DIR="/var/log/odin"
WEB_PORT="3000" # matches config.yml default web.port

Expand All @@ -34,6 +38,7 @@ fi
mkdir -p "$CONFIG_DIR"
mkdir -p "$DATA_DIR"/{sessions,context,skills,search,knowledge,trajectories}
mkdir -p "$LOG_DIR"
mkdir -p "$WORKSPACE_DIR"

# Enable passwordless sudo for the odin user (Odin runs privileged host
# operations; scope this down in /etc/sudoers.d/99-odin-passwordless if you
Expand Down Expand Up @@ -99,6 +104,11 @@ fi

# Set ownership and permissions
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$APP_DIR" "$DATA_DIR" "$LOG_DIR"
# Odin refuses to run local commands unless this is 0700 and owned by the
# service account — validation fails closed rather than falling back to the
# install directory, which is what allowed the 2026-07-27 data wipe.
chown "$SERVICE_USER:$SERVICE_GROUP" "$WORKSPACE_DIR"
chmod 0700 "$WORKSPACE_DIR"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR"
chmod 600 "$CONFIG_DIR/.env"
chown root:root /usr/lib/systemd/system/odin.service
Expand Down
8 changes: 8 additions & 0 deletions scripts/incus-deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ incus exec "$INSTANCE" -- bash -c "
id odin &>/dev/null || useradd -m -s /bin/bash odin
mkdir -p /app/src /app/data/context /app/data/sessions /app/data/logs \
/app/data/usage /app/data/skills /app/data/chromadb /app/.ssh
# Local command workspace (tools.local_working_dir). OUTSIDE /app so a bare
# relative path in a command cannot resolve against the install or its data.
# The unprivileged odin user cannot create it under /var/lib itself.
mkdir -p /var/lib/odin-workspace
chown odin:odin /var/lib/odin-workspace
chmod 0700 /var/lib/odin-workspace
chown -R odin:odin /app
chmod 700 /app/.ssh
"
Expand Down Expand Up @@ -118,6 +124,8 @@ After=network.target
Type=simple
User=odin
WorkingDirectory=/app
StateDirectory=odin-workspace
StateDirectoryMode=0700
EnvironmentFile=/app/.env
ExecStart=/usr/bin/python3 -m src
Restart=on-failure
Expand Down
82 changes: 82 additions & 0 deletions src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ def _wire_observability(health, bot, log) -> None:
tool_executor = getattr(bot, "tool_executor", None)
if tool_executor is not None and hasattr(tool_executor, "get_metrics"):
metrics.register_source("tools", tool_executor.get_metrics)
if tool_executor is not None and hasattr(tool_executor, "get_workspace_metrics"):
# Paired with the deliberate absence of auto-pruning: growth in the
# local command workspace must be alertable rather than silently
# unbounded (PR #239 round-2 review).
metrics.register_source("workspace", tool_executor.get_workspace_metrics)

cost_tracker = getattr(bot, "cost_tracker", None)
if cost_tracker is not None and hasattr(cost_tracker, "get_prometheus_metrics"):
Expand Down Expand Up @@ -108,6 +113,57 @@ def main() -> None:
log = get_logger("main")
log.info("Starting Odin")

# STARTUP MIGRATION — must run after the real configuration is loaded and
# before any command service begins.
#
# Local user commands run in a validated workspace outside the install and
# refuse to run without one. A preflight in the self-updater cannot
# bootstrap that: the update which INTRODUCES the preflight is executed by
# the previous release's handler, which has none, so the very first upgrade
# would re-exec into code whose workspace was never created (PR #239
# round-5 review, verified against a live install). Provisioning here runs
# in the NEW code on the restart that follows any update, however the
# update arrived.
#
# Failure is logged, not fatal: an unusable workspace must not prevent
# Odin from starting and answering on Discord. Local commands then fail
# closed individually, with the same actionable error.
try:
from src.tools.workspace import (
WorkspaceError,
provision_startup_workspace,
provisioning_hint,
)

def _warn_fallback(path, configured, reason) -> None:
# A fallback is not a failure, but it must never look like normal
# operation: on a packaged install it means the packaged default
# could not be provisioned, which the operator needs to know
# (cross-review of PR #239 round 13).
log.warning(
"Local command workspace fell back to %s — the configured "
"default could not be provisioned (%s). Local commands work, "
"but this indicates a packaging or permissions problem. %s",
path,
reason,
provisioning_hint(configured),
)

workspace = provision_startup_workspace(
config.tools,
protected_roots=_command_protected_roots(config),
on_fallback=_warn_fallback,
)
log.info("Local command workspace ready: %s", workspace)
except WorkspaceError as exc:
log.error(
"Local command workspace unusable — local commands will refuse to run: %s. %s",
exc,
provisioning_hint(config.tools.local_working_dir),
)
except Exception as exc: # never block startup on provisioning
log.error("Local command workspace provisioning failed unexpectedly: %s", exc)

health = HealthServer(
port=config.web.port,
webhook_config=config.webhook,
Expand Down Expand Up @@ -273,5 +329,31 @@ async def shutdown() -> None:
sys.exit(exit_code)


def _command_protected_roots(config) -> list[str]:
"""Install root plus canonical live-data roots for the startup migration.

Delegates to the ONE shared derivation so startup, the self-update
preflight, and the executor protect exactly the same directories. Deriving
them separately here silently protected nothing (``Config`` has no
``memory`` section) while the executor protected live memory.json — so a
workspace beside it was provisioned at startup and then refused on every
command (PR #239 round-6 review).

The FULL config is passed so every independently relocatable live-state
path is covered. ``memory_path`` is left at its default: production wiring
hardcodes that path, and startup runs before wiring exists.
"""
from src.tools.workspace import command_protected_roots

return command_protected_roots(Path(__file__).absolute().parents[1], config)


# The entrypoint guard MUST stay the last statement in this module. Python
# executes a module top-to-bottom, so a guard placed above a helper runs main()
# before that helper's `def` is reached: the startup migration raised NameError
# and its own nonfatal handler swallowed it, leaving the workspace uncreated and
# resurrecting the first-update bootstrap failure this migration exists to fix
# (PR #239 round-6 review). tests/test_local_workspace.py executes `python -m src`
# for real to keep this honest.
if __name__ == "__main__":
main()
60 changes: 59 additions & 1 deletion src/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ class GovernorConfig(BaseModel):
host_overrides: dict[str, str] = Field(default_factory=dict)


# The default local command workspace, spelled ONCE: the field default, the
# blank-value normalizer, the tracked config.yml template and the packaging
# scripts must never drift apart.
DEFAULT_LOCAL_WORKING_DIR = "/var/lib/odin-workspace"


class ToolsConfig(BaseModel):
enabled: bool = True
governor: GovernorConfig = GovernorConfig()
Expand Down Expand Up @@ -217,6 +223,45 @@ class ToolsConfig(BaseModel):
# Loops typically need more budget for exploration + execution + verify + commit.
max_tool_iterations_chat: int = 500
max_tool_iterations_loop: int = 500
# Working directory for USER-COMMAND local execution (run_command,
# run_script, manage_process). Before this existed, those subprocesses
# inherited systemd's WorkingDirectory=/opt/odin, so a bare relative path
# in a command resolved against the live install — on 2026-07-27 an AE2 jar
# whose internal layout is `data/` was extracted and cleaned up with
# `rm -rf data`, which deleted /opt/odin/data.
#
# Deliberately a SIBLING of /var/lib/odin, not a child: packaged installs
# use /var/lib/odin as the live data directory behind /opt/odin/data.
# Not /tmp or /var/tmp (tmpfiles policy can age those out) and not $HOME
# (packaged Odin declares /opt/odin as the service account's home).
#
# Stable and persistent BY DESIGN: a fresh directory per command would
# break two-step workflows that write a relative file in one command and
# read it in the next, which would cost capability. Restart-required, not
# hot-reloadable — swapping workspaces at runtime would break exactly the
# cross-command continuity this preserves.
local_working_dir: str = DEFAULT_LOCAL_WORKING_DIR

@field_validator("local_working_dir")
@classmethod
def _workspace_blank_means_default(cls, v):
"""Blank or whitespace-only normalizes to the default, here at the
boundary, so every consumer sees the same value.

The field accepts free strings and can be blanked through
PUT /api/config. Left un-normalized, the self-update preflight
substituted the default and approved, while the restarted process
loaded the blank value and failed closed on every local command —
preflight and runtime disagreeing about the very path being validated
(PR #239 round-7 review, reproduced).

Normalizing rather than rejecting keeps the update seamless: a blanked
value costs no capability and cannot brick startup, which a hard
validation error on a persisted config would.
"""
if not isinstance(v, str) or not v.strip():
return DEFAULT_LOCAL_WORKING_DIR
return v.strip()

@field_validator("command_timeout_seconds")
@classmethod
Expand Down Expand Up @@ -866,6 +911,18 @@ def replacer(match: re.Match) -> str:
# (a test or one-off script that never called load_config) cannot silently
# overwrite a real deployment's config.yml from the wrong working directory.
_ACTIVE_CONFIG_PATH: Path | None = None
# The path AS GIVEN (absolutized, symlinks intact). restart.reexec() replays
# sys.argv, so an alias like /etc/odin/config.yml -> /srv/real/odin.yml is what
# the restarted process opens — protecting only the canonical target would let
# a relative command delete the alias and break the next restart (PR #239
# round-10 review, reproduced).
_LAUNCH_CONFIG_PATH: Path | None = None


def active_config_launch_path() -> Path | None:
"""The config path as given on the command line, absolutized but with
symlinks intact — what ``restart.reexec()`` will hand the next process."""
return _LAUNCH_CONFIG_PATH


def active_config_path() -> Path | None:
Expand All @@ -878,8 +935,9 @@ def set_active_config_path(path: str | Path | None) -> None:
"""Record (or clear) the active config path. ``load_config`` calls this on a
successful load; tests/tools that persist a hand-built Config point it at
their own file."""
global _ACTIVE_CONFIG_PATH
global _ACTIVE_CONFIG_PATH, _LAUNCH_CONFIG_PATH
_ACTIVE_CONFIG_PATH = Path(path).resolve() if path is not None else None
_LAUNCH_CONFIG_PATH = Path(os.path.abspath(path)) if path is not None else None


def load_config(path: str | Path = "config.yml") -> Config:
Expand Down
9 changes: 8 additions & 1 deletion src/discord/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from ..sessions import SessionManager
from ..tools import SkillManager, ToolExecutor
from ..tools.autonomous_loop import LoopManager
from ..tools.workspace import DEFAULT_MEMORY_PATH
from ..trajectories.saver import TrajectorySaver
from .channel_config import ChannelConfigManager
from .channel_logger import ChannelLogger
Expand Down Expand Up @@ -206,7 +207,10 @@ def build_services(config: Config) -> BotServices: # noqa: PLR0915 — linear c
)
sessions.load()

memory_path = "./data/memory.json"
# Imported, not re-spelled: the startup migration and the self-update
# preflight protect this file before/without wiring, and three
# independent spellings is how they fell out of sync (PR #239 round 6).
memory_path = DEFAULT_MEMORY_PATH
channel_config = ChannelConfigManager("./data/channel_config.json")

# Passive channel logger — writes ALL guild messages to JSONL (zero LLM tokens)
Expand Down Expand Up @@ -257,6 +261,9 @@ def build_services(config: Config) -> BotServices: # noqa: PLR0915 — linear c
tool_executor = ToolExecutor(
config.tools,
memory_path=memory_path,
# Full config so the workspace's protected roots cover every relocatable
# live-state path, not just the ones ToolsConfig declares.
app_config=config,
browser_manager=browser_manager,
output_streamer=output_streamer,
host_access_manager=host_access_manager,
Expand Down
32 changes: 32 additions & 0 deletions src/health/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,38 @@ def render(self) -> str:
except Exception:
pass

# -- Local command workspace (no auto-prune, so growth is observable) --
ws_source = self._sources.get("workspace")
if ws_source:
try:
ws = ws_source() or {}
if ws:
gauges = {
"bytes": (
"odin_workspace_bytes",
"Bytes stored in the local command workspace",
),
"files": (
"odin_workspace_files",
"Files in the local command workspace",
),
"free_bytes": (
"odin_workspace_free_bytes",
"Free bytes on the workspace filesystem",
),
"free_inodes": (
"odin_workspace_free_inodes",
"Free inodes on the workspace filesystem",
),
}
for key, (name, helptext) in gauges.items():
if key in ws:
sections.append(f"# HELP {name} {helptext}")
sections.append(f"# TYPE {name} gauge")
sections.append(_format_metric(name, ws[key], include_header=False))
except Exception:
pass

# -- Tool execution metrics --
tool_source = self._sources.get("tools")
if tool_source:
Expand Down
Loading
Loading