Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2844,8 +2844,14 @@ _Avoid_: "orphaned" -- a released run is not lost, it has changed lane.
**Working directory** (`raven/agent/workdir.py`):
The directory a turn reads and writes files in — shared by the session's leader `AgentLoop`
and every Subagent it spawns, and resolved per turn by `WorkdirResolver.resolve()`.
`raven tui` and `raven agent` use the process launch directory, so the agent works in the
checkout you started it from (Workdir policy `LAUNCH_DIR`); intermediate artifacts it
Local surfaces (`raven tui`, `raven serve`, `raven agent`, and standalone `raven a2a`)
use the process launch directory, so the agent works in the checkout you started it from
(Workdir policy `LAUNCH_DIR`). If that launch directory is Agent home, one of its
memory/skills/transcript subtrees, or an ancestor narrower than the user's home,
assembly warns and selects `PER_CHANNEL` instead, with the gateway's default root.
An explicit working-directory override still takes precedence. Launching from the
user's home or above retains that directory; checkpointing already refuses those roots.
The Project slug still names the original launch directory. Intermediate artifacts it
produces there go under that directory's `.raven/` (the shadow-git repo lives at
`.raven/shadow.git`). `raven gateway` gives each channel one directory (Workdir policy
`PER_CHANNEL`), set by `channels.<name>.workspace`, defaulting to `<agent home>/../tmp/<channel>`, i.e. `~/.raven/tmp/<channel>`.
Expand All @@ -2872,7 +2878,8 @@ as they do in the reference. The project's identity is therefore carried by

**Workdir policy** (`WorkdirPolicy`, `raven/agent/workdir.py`):
Which default a `WorkdirResolver` falls back to when a session has no explicit override:
`LAUNCH_DIR` or `PER_CHANNEL`. Fixed per entrypoint (tui/agent vs. gateway), not user-facing.
`LAUNCH_DIR` or `PER_CHANNEL`. Local surfaces normally use `LAUNCH_DIR`, with a
`PER_CHANNEL` fallback for a protected launch directory; the gateway uses `PER_CHANNEL`.

**Workspace Template** (`templates/`):
The bundled markdown seed files copied into Agent home on first run by
Expand Down
13 changes: 12 additions & 1 deletion docs-site/docs/using-raven.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@ provider is configured.

The working directory holds the files the task acts on. Agent home holds
identity, memory, skills, and transcripts; it is not an interchangeable name for
the project checkout. For a one-shot task:
the project checkout.

Local commands (`raven tui`, `raven serve`, `raven agent`, and `raven a2a serve`)
normally work in the directory they were launched from. A launch inside Agent
home's memory, skills, or transcripts, at Agent home itself, or at an ancestor
other than your user home or a higher root produces a warning and uses
`<instance>/tmp/<channel>` instead. A valid `--workspace` override takes precedence; a protected explicit
override is rejected. When your user home or a higher root is a strict ancestor
of Agent home, launching there keeps that directory, with checkpointing disabled
for that scope.

For a one-shot task:

```bash
raven agent --workspace /absolute/path/to/project -m "Read the README and summarize the setup steps"
Expand Down
11 changes: 10 additions & 1 deletion docs-site/docs/using-raven.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@
## 选择正确工作目录 { #work-in-the-right-directory }

工作目录保存任务操作的文件;Agent home 保存身份、记忆、技能和 transcript,
不能把它当作项目仓库的同义词。单次任务示例:
不能把它当作项目仓库的同义词。

本地命令(`raven tui`、`raven serve`、`raven agent`、`raven a2a serve`)通常使用
启动时所在的目录。如果从 Agent home 本身、其记忆、技能或 transcript 子树,
或既包含 Agent home、又不是用户主目录及其更高层的目录启动,会输出警告并改用
`<instance>/tmp/<channel>`。合法的 `--workspace` 覆盖优先;显式指定受保护目录
则会报错。当用户主目录或更高层是 Agent home 的上级目录时,从那里启动仍使用
原目录,该范围不会启用 checkpoint。

单次任务示例:

```bash
raven agent --workspace /absolute/path/to/project -m "Read the README and summarize the setup steps"
Expand Down
38 changes: 30 additions & 8 deletions raven/core/engine_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Callable

from loguru import logger

from raven.core.runtime import RavenRuntime

if TYPE_CHECKING:
Expand Down Expand Up @@ -97,21 +99,41 @@ def build_local_sessions(config, *, workspace: str | None) -> "tuple[SessionMana
The gateway passes no slug -- one daemon serves every project, so its
grouping is the channel instead. ``workspace`` is the operator's explicit
working-directory override, validated against the agent home
(``ValueError`` when it is not allowed).
(``ValueError`` when it is not allowed). A protected launch directory
falls back to the gateway's per-channel directories.
"""
from raven.agent.workdir import WorkdirPolicy, WorkdirResolver, validate_override
from raven.agent.workdir import WorkdirPolicy, WorkdirResolver, default_channel_root, validate_override
from raven.session.manager import SessionManager
from raven.utils.paths import project_slug

launch_dir = Path.cwd()
session_manager = SessionManager(
config.workspace_path, project_slug=project_slug(launch_dir), project_dir=launch_dir
)
agent_home = config.workspace_path
explicit_workdir = validate_override(workspace, agent_home) if workspace else None
policy = WorkdirPolicy.LAUNCH_DIR
if explicit_workdir is None:
try:
validate_override(launch_dir, agent_home)
except ValueError as exc:
user_home = Path.home().resolve()
resolved_launch = launch_dir.resolve()
# CheckpointService already refuses the user's home and wider roots.
wide_ancestor = resolved_launch in agent_home.resolve().parents and (
resolved_launch == user_home or resolved_launch in user_home.parents
)
if not wide_ancestor:
policy = WorkdirPolicy.PER_CHANNEL
logger.warning(
"Launch directory {} is not a usable working directory ({}); falling back to {} per channel",
launch_dir,
exc,
default_channel_root(agent_home),
)
session_manager = SessionManager(agent_home, project_slug=project_slug(launch_dir), project_dir=launch_dir)
workdir_resolver = WorkdirResolver(
WorkdirPolicy.LAUNCH_DIR,
agent_home=config.workspace_path,
policy,
agent_home=agent_home,
launch_dir=launch_dir,
explicit_workdir=validate_override(workspace, config.workspace_path) if workspace else None,
explicit_workdir=explicit_workdir,
sessions=session_manager,
)
return session_manager, workdir_resolver
Expand Down
13 changes: 5 additions & 8 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,14 +619,10 @@ def _no_real_raven_home(tmp_path, monkeypatch):
is module-global: one test calling ``set_config_path`` otherwise aims every
later test in the process at that path.

The knob is ``HOME`` because it is the only one every test that isolates the
home itself can still beat, and this fixture must lose to all of them. Tests
do it two ways -- ``monkeypatch.setattr(Path, "home", ...)`` and
``monkeypatch.setenv("HOME", ...)`` -- and the precedence runs
``RAVEN_HOME`` > ``Path.home`` > ``HOME``. Setting ``RAVEN_HOME`` here beats
both camps (measured: 17 unrelated failures), patching ``Path.home`` beats the
``setenv`` camp (measured: 3), and setting ``HOME`` beats neither: an attribute
patch shadows it, and a later ``setenv`` replaces it.
Set ``HOME`` on POSIX and ``USERPROFILE`` on Windows, where ``Path.home``
ignores ``HOME``. A test can still replace the platform's variable or patch
``Path.home`` itself. Leave ``RAVEN_HOME`` unset because it would outrank
either way a test names its own home.
"""

# Outside ``tmp_path`` rather than under it, and fresh per test. Tests use
Expand All @@ -639,6 +635,7 @@ def _no_real_raven_home(tmp_path, monkeypatch):
home = tmp_path.with_name(f"{tmp_path.name}-home")
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
# The suite's baseline permission mode is full access -- the behaviour the
# whole suite was written against before the gate existed, and what a test
# about streaming or diffs should keep seeing. The product default is ask;
Expand Down
137 changes: 137 additions & 0 deletions tests/test_core_engine_stack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for working-directory selection by local surface assembly."""

from pathlib import Path
from unittest.mock import Mock

import pytest
from loguru import logger

from raven.agent.workdir import default_channel_root
from raven.config.schema import Config
from raven.core.engine_stack import build_local_sessions
from raven.utils.paths import project_slug


@pytest.fixture
def local_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
user_home = tmp_path / "user"
agent_home = user_home / ".raven" / "workspace"
agent_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: user_home)
config = Config()
config.agents.defaults.workspace = str(agent_home)
return config


@pytest.mark.parametrize(
"relative",
["..", ".", "user_memory", "user_memory/nested", "skills", "skills/nested", "sessions", "sessions/nested"],
)
def test_protected_launch_directory_uses_per_channel_fallback(
local_config: Config, monkeypatch: pytest.MonkeyPatch, relative: str
) -> None:
agent_home = local_config.workspace_path
launch = (agent_home / relative).resolve()
launch.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(launch)
launch = Path.cwd()
warning = Mock()
monkeypatch.setattr(logger, "warning", warning)

sessions, resolver = build_local_sessions(local_config, workspace=None)
root = default_channel_root(agent_home)

for channel in ("tui", "web", "cli", "a2a"):
assert resolver.resolve(f"{channel}:one", create=False) == root / channel
assert resolver.resolve(f"{channel}:two", create=False) == root / channel
assert resolver.mount_root() == root
assert not root.exists()
assert sessions.project_dir == launch
assert sessions.project_slug == project_slug(launch)
warning.assert_called_once()
message, *args = warning.call_args.args
assert str(launch) in message.format(*args)
assert str(root) in message.format(*args)
assert resolver.resolve("tui:one").is_dir()


@pytest.mark.parametrize("relative", ["project", ".", ".."])
def test_project_and_user_home_launch_directories_are_preserved(
local_config: Config, monkeypatch: pytest.MonkeyPatch, relative: str
) -> None:
launch = (Path.home() / relative).resolve()
launch.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(launch)
launch = Path.cwd()
warning = Mock()
monkeypatch.setattr(logger, "warning", warning)

_, resolver = build_local_sessions(local_config, workspace=None)

assert resolver.resolve("tui:one", create=False) == launch
assert resolver.resolve("web:two", create=False) == launch
assert resolver.mount_root() == launch
warning.assert_not_called()


def test_explicit_workdir_wins_when_the_launch_directory_is_protected(
local_config: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(local_config.workspace_path)
project = Path.home() / "project"
project.mkdir()
warning = Mock()
monkeypatch.setattr(logger, "warning", warning)

_, resolver = build_local_sessions(local_config, workspace=str(project))

assert resolver.resolve("tui:one", create=False) == project.resolve()
assert resolver.mount_root() == project.resolve()
warning.assert_not_called()


def test_invalid_explicit_workdir_is_rejected_instead_of_falling_back(
local_config: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(local_config.workspace_path)

with pytest.raises(ValueError, match="agent home"):
build_local_sessions(local_config, workspace=str(local_config.workspace_path))


def test_session_override_wins_over_the_protected_launch_fallback(
local_config: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(local_config.workspace_path)
project = Path.home() / "project"
project.mkdir()
sessions, resolver = build_local_sessions(local_config, workspace=None)
sessions.get_or_create("tui:pinned").metadata["workdir"] = str(project)

assert resolver.resolve("tui:pinned", create=False) == project.resolve()
assert resolver.resolve("tui:other", create=False) == default_channel_root(local_config.workspace_path) / "tui"


def test_agent_home_equal_to_user_home_is_still_protected(
local_config: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
local_config.agents.defaults.workspace = str(Path.home())
monkeypatch.chdir(Path.home())

_, resolver = build_local_sessions(local_config, workspace=None)

assert resolver.resolve("tui:one", create=False) == default_channel_root(local_config.workspace_path) / "tui"


def test_instance_outside_user_home_also_uses_the_fallback(
local_config: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
instance = Path.home().parent / "another-instance"
agent_home = instance / "workspace"
agent_home.mkdir(parents=True)
local_config.agents.defaults.workspace = str(agent_home)
monkeypatch.chdir(instance)

_, resolver = build_local_sessions(local_config, workspace=None)

assert resolver.resolve("tui:one", create=False) == instance / "tmp" / "tui"
61 changes: 58 additions & 3 deletions tests/test_runtime_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ async def test_trackable_follows_the_repos_own_exclusion_rules(workspace, tmp_pa
assert kept == {str(names["plain"])}


@pytest.mark.skipif(os.name == "nt", reason="POSIX byte filenames require surrogate-escape decoding")
async def test_a_name_that_is_not_utf8_is_judged_and_looked_up_by_its_bytes(workspace):
"""A POSIX name holding byte 0xff reaches Python surrogate-escaped. The
query git reads must carry that byte, not fail to encode it after the
Expand Down Expand Up @@ -1000,7 +1001,7 @@ def test_checkpoint_refuses_a_home_or_wider_root(tmp_path, monkeypatch):
to recover in exchange."""
home = tmp_path / "home"
(home / "proj").mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: home)

for refused in (home, tmp_path, Path(tmp_path.anchor)):
with pytest.raises(ValueError, match="home directory"):
Expand All @@ -1015,7 +1016,7 @@ async def test_checkpoint_excludes_private_keys(tmp_path, monkeypatch):
exhaustive miss the most common secret on the machine."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: home)
proj = home / "proj"
(proj / ".ssh").mkdir(parents=True)
(proj / ".ssh" / "id_ed25519").write_text("PRIVATE\n", encoding="utf-8")
Expand All @@ -1030,6 +1031,59 @@ async def test_checkpoint_excludes_private_keys(tmp_path, monkeypatch):
assert sorted(out.split()) == ["main.py"]


@pytest.mark.parametrize("launch_scope", ["instance", "agent_home"])
async def test_protected_local_launch_excludes_runtime_data_from_checkpoint_and_exec_changes(
workspace, monkeypatch, launch_scope
):
"""The local fallback must protect both the shadow repo and command diffs."""
from raven.agent.tools import command_writes
from raven.config.schema import Config
from raven.core.engine_stack import build_local_sessions

home = workspace / "home"
instance = home / ".raven"
agent_home = instance / "workspace"
agent_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: home)
for relative in (
"config.json",
"oauth/codex.json",
"workspace/user_memory/MEMORY.md",
"workspace/sessions/sub/.config.rendered.123.json",
):
path = instance / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('{"apiKey": "fake-runtime-secret"}', encoding="utf-8")
config = Config()
config.agents.defaults.workspace = str(agent_home)
with monkeypatch.context() as launch:
launch.chdir(instance if launch_scope == "instance" else agent_home)
sessions, resolver = build_local_sessions(config, workspace=None)
session = sessions.get_or_create("tui:repro")
session.add_message("user", "Before the command.")
sessions.save(session)
working_dir = resolver.resolve(session.key)
svc = CheckpointService(working_dir)

before = await command_writes.before(working_dir, lambda _: svc)
notes = working_dir / "notes.md"
notes.write_text("The command's own output.\n", encoding="utf-8")
session.add_message("assistant", "Transcript written while the command runs.")
sessions.save(session)
writes, removals = await command_writes.after(before)
cid, changed = await svc.commit_turn("local turn")
rc, tracked, _ = await svc._git("ls-tree", "-r", "--name-only", "HEAD")

assert {Path(row.path).resolve() for row in writes} == {notes.resolve()}
assert removals == ()
assert cid is not None
assert changed == ["notes.md"]
assert rc == 0
assert tracked.splitlines() == ["notes.md"]
assert not (instance / ".raven" / "shadow.git").exists()
assert not (agent_home / ".raven" / "shadow.git").exists()


async def test_loop_runs_the_turn_when_the_root_is_refused(tmp_path, monkeypatch):
"""A refused root disables the safety net, it does not break the turn.

Expand All @@ -1039,7 +1093,7 @@ async def test_loop_runs_the_turn_when_the_root_is_refused(tmp_path, monkeypatch
"""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: home)
(home / ".raven").mkdir()
(home / ".raven" / "config.json").write_text('{"permissions": {"mode": "full"}}')

Expand Down Expand Up @@ -1119,6 +1173,7 @@ def _raise(*_args: object, **_kwargs: object) -> None:
assert await asyncio.wait_for(svc.stage_tree(), 30) is None


@pytest.mark.skipif(os.name == "nt", reason="Requires a POSIX shell and non-destructive process liveness probes")
async def test_a_git_call_whose_caller_stops_waiting_is_killed(workspace, monkeypatch):
"""A bounded measurement or a cancelled turn stops waiting on a git read;
the git itself must not be left running behind it."""
Expand Down
Loading