diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f83d72c6..b56afdc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,8 +70,11 @@ jobs: if: runner.os == 'Linux' - run: cargo check --locked -p devo-kernel --all-targets --target ${{ matrix.target }} if: runner.os == 'macOS' - - run: cargo test --locked -p devo-kernel credential_unix::tests + - name: Test kernel credential environment and host protocol if: runner.os == 'macOS' + run: | + cargo test --locked -p devo-kernel session::credential_env_tests + cargo test --locked -p devo-kernel protocol::tests windows-kernel: needs: changes diff --git a/.gitignore b/.gitignore index 918474c2..ddf29c76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store .claude __pycache__/ +.venv/ node_modules /apps/desktop/out/ /apps/desktop/resources/runtime/ diff --git a/Cargo.lock b/Cargo.lock index 2d0b429a..aa2e6332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1557,10 +1557,7 @@ name = "devo-kernel" version = "0.2.16" dependencies = [ "anyhow", - "devo-sandbox", "devo-util-paths", - "devo-util-process", - "devo-windows-sandbox", "dunce", "futures", "libc", @@ -1570,6 +1567,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "uuid", ] diff --git a/README.ja.md b/README.ja.md index 8fdd0caf..dbbce0d1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -66,11 +66,11 @@ DeepSeek、Qwen、Kimi、GLM、MiniMax が含まれます。 `/model` でモデルを切り替え、`devo onboard` でプロバイダーを再設定できます。 手動設定、認証情報、MCP、オフラインインストール、リリースミラーは -[ユーザーガイド(英語)](./docs/usage.md)を参照してください。 +[ユーザーガイド(英語)](./docs/user-guide.md)を参照してください。 ## ドキュメントと貢献 -- [ユーザーガイド(英語)](./docs/usage.md) — インストール、プロバイダー、設定、オフライン利用。 +- [ユーザーガイド(英語)](./docs/user-guide.md) — インストール、プロバイダー、設定、オフライン利用。 - [TUI ガイド(英語)](./apps/tui/README.md) — ターミナル操作とコマンド。 - [貢献ガイド(英語)](./CONTRIBUTING.md) — 問題報告、ドキュメント改善、小規模な修正を歓迎します。 diff --git a/README.md b/README.md index 9cd0a600..eca60f25 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,12 @@ Terminal bundles include Node.js and Python. On Windows, install [Git for Windows](https://gitforwindows.org/) for shell commands and approve the one-time sandbox setup prompt. -> **Temporarily disabled.** OS sandboxing and the permission picker are -> disabled repo-wide: every process runs with full user permissions and new -> sessions default to full-access. Revert by flipping -> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), -> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), -> and the default-preset literals back to their asking defaults. +> Shell and terminal execution follow the configured sandbox profile; there is +> no global sandbox-disable switch. Python kernels are trusted user-privileged +> processes, not OS-isolated environments. See +> [the execution boundary](docs/trusted-kernel-host-shell.md). +> Permission-picker UI flags and new-session permission defaults are separate +> settings; removing the global switch does not change them. ## Configuration @@ -74,11 +74,11 @@ DeepSeek, Qwen, Kimi, GLM, and MiniMax. Use `/model` to switch models or `devo onboard` to revisit provider setup. For manual configuration, credentials, MCP, offline installation, and release -mirrors, see the [user guide](./docs/usage.md). +mirrors, see the [user guide](./docs/user-guide.md). ## Docs and contributing -- [User guide](./docs/usage.md) — installation, providers, configuration, and offline use. +- [User guide](./docs/user-guide.md) — installation, providers, configuration, and offline use. - [TUI guide](./apps/tui/README.md) — terminal controls and commands. - [Contributing](./CONTRIBUTING.md) — issues, documentation, and focused fixes welcome. diff --git a/README.ru.md b/README.ru.md index 580eeb6c..9e41e81f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -68,11 +68,11 @@ devo Переключайте модели через `/model`, повторно настраивайте провайдера через `devo onboard`. Ручная конфигурация, учётные данные, MCP, офлайн-установка и зеркала релизов -описаны в [руководстве пользователя (на английском)](./docs/usage.md). +описаны в [руководстве пользователя (на английском)](./docs/user-guide.md). ## Документация и участие -- [Руководство пользователя (на английском)](./docs/usage.md) — установка, провайдеры, конфигурация и офлайн-работа. +- [Руководство пользователя (на английском)](./docs/user-guide.md) — установка, провайдеры, конфигурация и офлайн-работа. - [Руководство TUI (на английском)](./apps/tui/README.md) — управление терминалом и команды. - [Участие в разработке (на английском)](./CONTRIBUTING.md) — приветствуются сообщения об ошибках, улучшения документации и небольшие исправления. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 60bdda60..e7b640ca 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -61,11 +61,11 @@ devo **Anthropic Messages** API。内置目录包含 DeepSeek、Qwen、Kimi、GLM 和 MiniMax。 用 `/model` 切换模型,用 `devo onboard` 重新配置提供商。 -手动配置、凭据、MCP、离线安装和发布镜像见[使用指南(英文)](./docs/usage.md)。 +手动配置、凭据、MCP、离线安装和发布镜像见[使用指南(英文)](./docs/user-guide.md)。 ## 文档与贡献 -- [使用指南(英文)](./docs/usage.md) — 安装、提供商、配置与离线使用。 +- [使用指南(英文)](./docs/user-guide.md) — 安装、提供商、配置与离线使用。 - [TUI 指南(英文)](./apps/tui/README.md) — 终端操作与命令。 - [贡献指南(英文)](./CONTRIBUTING.md) — 欢迎问题反馈、文档改进和小范围修复。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 5cb1a06c..67183d27 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -61,11 +61,11 @@ devo **Anthropic Messages** API。內建目錄包含 DeepSeek、Qwen、Kimi、GLM 和 MiniMax。 用 `/model` 切換模型,用 `devo onboard` 重新設定供應商。 -手動設定、憑證、MCP、離線安裝和發行鏡像請見[使用指南(英文)](./docs/usage.md)。 +手動設定、憑證、MCP、離線安裝和發行鏡像請見[使用指南(英文)](./docs/user-guide.md)。 ## 文件與貢獻 -- [使用指南(英文)](./docs/usage.md) — 安裝、供應商、設定與離線使用。 +- [使用指南(英文)](./docs/user-guide.md) — 安裝、供應商、設定與離線使用。 - [TUI 指南(英文)](./apps/tui/README.md) — 終端操作與命令。 - [貢獻指南(英文)](./CONTRIBUTING.md) — 歡迎問題回報、文件改進和小範圍修正。 diff --git a/apps/desktop/docs/windows-desktop-qa.md b/apps/desktop/docs/windows-desktop-qa.md index 251896ce..763390fd 100644 --- a/apps/desktop/docs/windows-desktop-qa.md +++ b/apps/desktop/docs/windows-desktop-qa.md @@ -64,7 +64,7 @@ replaced by scripted browser end-to-end tests. | Provider failure | HTTP 402 was visible as “Insufficient provider balance,” with expandable details. | | Funded recovery | A new real Electron request produced assistant output, Python output, and a fixture read after balance was replenished. No new 402 occurred. | | Funded automatic queue | Enter queued a follow-up during a 60-second Python cell; it drained automatically and produced its own Python result and assistant reply. | -| Mouse submission during work | Clicked Queue message during a fenced 150-second Python cell. Edited and resubmitted the first queued entry, then queued and steered a second. The steer produced 402; the edited queue drained automatically and produced 301. Stop remains separately available. | +| Mouse submission during work | Clicked Queue message during a fenced 150-second Python cell (historical: the QA build then sandboxed the Python kernel). Edited and resubmitted the first queued entry, then queued and steered a second. The steer produced 402; the edited queue drained automatically and produced 301. Stop remains separately available. | | Completed activity label | Removed the tool suffix: completed activity displays “Worked for 1s,” with Python details inside the disclosure. | | Separate inner cells | Clicked Worked to reveal collapsed Thought and Python rows. Opened each independently and collapsed the whole group with Worked. Python source and output stay paired. | | Steering visibility after completion | A fresh steer during a 90-second Python wait remained a user bubble after completion, runtime restart, and renderer reload. The assistant applied it and produced 503. The reported disappearance was not reproduced in this checkout; live/replay regression tests preserve it. | @@ -84,6 +84,16 @@ a fresh real Electron Python turn reported the token `DevoSandboxOffline`, used the fenced kernel, and emitted no new unfenced warning. Historical warnings remain attached to their original turns. +Historical note: the Python-turn sentences above (the `DevoSandboxOffline` +token, the fenced kernel, and the no-new-unfenced-warning result) describe the +pre-cleanup build, where the Python kernel itself ran in the Windows sandbox. +Current builds run the Python kernel as a trusted, user-privileged process with +no OS sandbox (`docs/trusted-kernel-host-shell.md`); only host shell execution +follows the configured sandbox profile. The earlier write/network probe is +historical evidence, not validation of the current host-shell path; that path +requires a fresh Windows runtime check. A current Python turn should not be +expected to report that token or a fenced-kernel state. + Funded Electron requests now succeed. The earlier 402 checks remain coverage of provider failure handling, rather than a current provider-balance blocker. diff --git a/apps/desktop/src/main/provider-oauth-credential.test.ts b/apps/desktop/src/main/provider-oauth-credential.test.ts index 73d0df68..795b9a05 100644 --- a/apps/desktop/src/main/provider-oauth-credential.test.ts +++ b/apps/desktop/src/main/provider-oauth-credential.test.ts @@ -3,7 +3,6 @@ import { credentialSetParamsFromDesktopOAuth } from "./provider-oauth-credential describe("credentialSetParamsFromDesktopOAuth", () => { /** - * Trace: L2-DES-AUTH-001 * Verifies: Desktop OAuth login persists via Native credential/set oauth params. */ test("maps OAuth credential into credential/set params", () => { diff --git a/apps/desktop/src/renderer/components/settings/desktop-oauth-providers.test.ts b/apps/desktop/src/renderer/components/settings/desktop-oauth-providers.test.ts index 2994ca71..8a29620b 100644 --- a/apps/desktop/src/renderer/components/settings/desktop-oauth-providers.test.ts +++ b/apps/desktop/src/renderer/components/settings/desktop-oauth-providers.test.ts @@ -6,7 +6,6 @@ import { describe("resolveProviderAuthMethods", () => { /** - * Trace: L2-DES-AUTH-001 * Verifies: Desktop exposes the four supported provider OAuth logins. */ test("provides the native OAuth providers", () => { @@ -32,7 +31,6 @@ describe("resolveProviderAuthMethods", () => { }) /** - * Trace: L2-DES-AUTH-001 * Verifies: Settings Connect routes OAuth builtins to the OAuth dialog. */ test("settingsConnectDialogKind routes OAuth vs API-key templates", () => { diff --git a/apps/tui/lib/coding-agent/src/core/kernel/shared.ts b/apps/tui/lib/coding-agent/src/core/kernel/shared.ts index 7f705965..bf625119 100644 --- a/apps/tui/lib/coding-agent/src/core/kernel/shared.ts +++ b/apps/tui/lib/coding-agent/src/core/kernel/shared.ts @@ -18,7 +18,7 @@ export type HostRequestHandlers = Record; /** Extra import labels advertised to the model alongside the Python skills (inert on the Native path). */ export const DEFAULT_RLM_EXTRA_IMPORT_LABELS: string[] = []; -/** One file edit, captured from a `application/vnd.devo.diff+json` display payload. */ +/** One file edit, captured from a `application/vnd.diff+json` display payload. */ export interface KernelDiffDisplay { path: string; oldStr: string; diff --git a/apps/tui/src/generated/protocol/native.ts b/apps/tui/src/generated/protocol/native.ts index 61a7bfb6..48dbee28 100644 --- a/apps/tui/src/generated/protocol/native.ts +++ b/apps/tui/src/generated/protocol/native.ts @@ -490,9 +490,11 @@ model: ModelBinding, settings: SessionSettings, */ gitInfo?: GitInfo | null, preview: string, lastActivityAt: string, /** - * Current on-disk size of the durable JSONL transcript. This is a - * list-view cache: servers may omit it from non-list responses or when - * the session has no readable rollout file. + * Current on-disk size of the owning physical journal, when known. + * Root sessions report the shared journal size; a child sharing that + * journal reports `None` so the tree size is never presented as the + * child's transcript size. This is a list-view cache: servers may omit + * it from non-list responses or when no readable rollout exists. */ transcriptSizeBytes?: bigint | null, /** diff --git a/apps/tui/src/generated/protocol/schema.json b/apps/tui/src/generated/protocol/schema.json index 5cf70e65..3d313660 100644 --- a/apps/tui/src/generated/protocol/schema.json +++ b/apps/tui/src/generated/protocol/schema.json @@ -7297,7 +7297,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -12038,7 +12038,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -16542,7 +16542,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -20211,7 +20211,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -23312,7 +23312,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -26676,7 +26676,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -29763,7 +29763,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -32854,7 +32854,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -36039,7 +36039,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -39130,7 +39130,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -42424,7 +42424,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -46803,7 +46803,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -49878,7 +49878,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -52961,7 +52961,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -56036,7 +56036,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -59111,7 +59111,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -62194,7 +62194,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -65269,7 +65269,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -68359,7 +68359,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -71442,7 +71442,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -74903,7 +74903,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -78859,7 +78859,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -81991,7 +81991,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -84678,7 +84678,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -87628,7 +87628,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -90717,7 +90717,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -96608,7 +96608,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -99748,7 +99748,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -102839,7 +102839,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -105933,7 +105933,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -109008,7 +109008,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -112571,7 +112571,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -115658,7 +115658,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -118755,7 +118755,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -120053,7 +120053,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -122617,7 +122617,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -125700,7 +125700,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -128801,7 +128801,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -130100,7 +130100,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -132664,7 +132664,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -135776,7 +135776,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -137183,7 +137183,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -139752,7 +139752,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -142835,7 +142835,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -146183,7 +146183,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -149577,7 +149577,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -153201,7 +153201,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -155761,7 +155761,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -157055,7 +157055,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -159272,7 +159272,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -160207,7 +160207,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -162901,7 +162901,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -164823,7 +164823,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -167413,7 +167413,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -172119,7 +172119,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -178242,7 +178242,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -181542,7 +181542,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -184635,7 +184635,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -189290,7 +189290,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -194111,7 +194111,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -197245,7 +197245,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -200403,7 +200403,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -203997,7 +203997,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -207500,7 +207500,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -211147,7 +211147,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -214234,7 +214234,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -217775,7 +217775,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -220890,7 +220890,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -224657,7 +224657,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -227758,7 +227758,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" @@ -230845,7 +230845,7 @@ ] }, "transcriptSizeBytes": { - "description": "Current on-disk size of the durable JSONL transcript. This is a list-view cache: servers may omit it from non-list responses or when the session has no readable rollout file.", + "description": "Current on-disk size of the owning physical journal, when known. Root sessions report the shared journal size; a child sharing that journal reports `None` so the tree size is never presented as the child's transcript size. This is a list-view cache: servers may omit it from non-list responses or when no readable rollout exists.", "type": [ "integer", "null" diff --git a/apps/tui/src/markdown-math.test.ts b/apps/tui/src/markdown-math.test.ts index d5322b1d..701c9c54 100644 --- a/apps/tui/src/markdown-math.test.ts +++ b/apps/tui/src/markdown-math.test.ts @@ -1,6 +1,4 @@ /** - * Trace: shell "$(...)" / "$var" pairs must not lex as inline math. - * * Live symptom (round 56): a user message pasting * for i in $(seq 1 200); do echo line-$i; done * rendered as "for i in (seq 1 200); do echo line-i; done" — the two dollars diff --git a/apps/tui/src/native-agent-connection.test.ts b/apps/tui/src/native-agent-connection.test.ts index aa6fc22c..f48f2eb6 100644 --- a/apps/tui/src/native-agent-connection.test.ts +++ b/apps/tui/src/native-agent-connection.test.ts @@ -1,5 +1,4 @@ /** - * Trace: L2-DES-APP-010 * Verifies: NativeAgentConnection prompt/abort/snapshot main path over fake stdio. */ diff --git a/apps/tui/src/native-agents-view-bridge.test.ts b/apps/tui/src/native-agents-view-bridge.test.ts index 537ca77a..d71b02e8 100644 --- a/apps/tui/src/native-agents-view-bridge.test.ts +++ b/apps/tui/src/native-agents-view-bridge.test.ts @@ -1,5 +1,4 @@ /** - * Trace: L2-DES-RLM-001 * Verifies: Native session/list Page.data + agent/list fan-out maps to nested * Agents View SessionSummary rows (parent pointers, activity, defaults). */ diff --git a/apps/tui/src/native-to-pi/events.test.ts b/apps/tui/src/native-to-pi/events.test.ts index 5b777832..3684cb5e 100644 --- a/apps/tui/src/native-to-pi/events.test.ts +++ b/apps/tui/src/native-to-pi/events.test.ts @@ -1,5 +1,4 @@ /** - * Trace: L2-DES-APP-010 * Verifies: Native stream notifications project to InteractiveMode session_event shapes. */ diff --git a/apps/tui/src/native-to-pi/tools.test.ts b/apps/tui/src/native-to-pi/tools.test.ts index 4f1e9676..af54af45 100644 --- a/apps/tui/src/native-to-pi/tools.test.ts +++ b/apps/tui/src/native-to-pi/tools.test.ts @@ -1,7 +1,3 @@ -/** - * Trace: tool lifecycle projection (pending → args → start → end). - */ - import { test } from "node:test"; import assert from "node:assert/strict"; import { diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 90dd7ac7..85444786 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -48,7 +48,6 @@ use upgrade_command::run_upgrade; /// Top-level `devo` command that dispatches to interactive agent mode or one /// of the supporting runtime subcommands. -/// #[derive(Debug, Parser)] #[command(name = "devo", version, about = "Devo CLI")] struct Cli { @@ -88,6 +87,7 @@ fn main() -> Result<()> { ) } +/// TODO: Move exit related logic into a single file. fn format_with_separators(value: usize) -> String { let digits = value.to_string(); let separator_count = digits.len().saturating_sub(1) / 3; @@ -102,6 +102,7 @@ fn format_with_separators(value: usize) -> String { out } +/// TODO: Move exit related logic into a single file. fn format_token_usage_line(exit: &AppExit, color_enabled: bool) -> Option { let total = exit.total_tokens; let non_cached_input = exit @@ -144,6 +145,7 @@ fn format_token_usage_line(exit: &AppExit, color_enabled: bool) -> Option Vec { let mut lines = Vec::new(); if let Some(line) = format_token_usage_line(exit, color_enabled) { @@ -245,8 +247,8 @@ async fn run_cli() -> Result<()> { fn direct_server_early_dispatch() -> devo_arg0::EarlyDispatch { let args: Vec = std::env::args().collect(); // One-shot Windows sandbox provisioning: `devo sandbox-setup` pops the - // UAC consent and provisions the sandbox accounts/firewall/ACLs so the - // RLM kernel fence can be raised (design doc §5.3 setup entry). + // UAC consent and provisions the sandbox accounts/firewall/ACLs so + // host-side sandboxed commands can use the provisioned account. if args.get(1).is_some_and(|arg| arg == "sandbox-setup") { #[cfg(windows)] { @@ -371,6 +373,7 @@ async fn maybe_print_startup_update(cli: &Cli) { } } +/// TODO: Rename this function to instll_client_logging fn install_logging(cli: &Cli) -> Result { let home_dir = find_devo_home()?; let app_config = devo_core::FileSystemAppConfigLoader::new(home_dir.clone()) @@ -420,6 +423,7 @@ fn cli_logging_overrides(cli: &Cli) -> toml::Value { )])) } +/// TODO: Why there is a simple wrapper function, maybe should be removed. /// Launch helper re-exported for tests / clarity — implementation lives in /// [`agent_command::launch_interactive_mode_client`]. #[allow(dead_code)] @@ -430,6 +434,7 @@ fn launch_interactive_mode_client_for_tests() -> Result<()> { ) } +/// TODO: Move test to another single file. #[cfg(test)] mod tests { use clap::Parser; diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 8f3c5782..304f2862 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -156,6 +156,11 @@ pub(crate) async fn run_prompt( &runtime, )))) .await; + kernel + .set_idle_bash_completion_handler(Some(crate::prompt_host::idle_host_request_handler( + session_state.id.to_string(), + ))) + .await; } let provider = Arc::new(RoutedPromptProvider::new( Arc::clone(&resolved_provider.provider_router), @@ -176,6 +181,7 @@ pub(crate) async fn run_prompt( ) .await; + devo_core::tools::host_bash::cancel_session(session_state.id.as_str()); if let Some(kernel) = &kernel && let Err(err) = kernel.shutdown().await { diff --git a/crates/cli/src/prompt_host.rs b/crates/cli/src/prompt_host.rs index b24f8880..6ce1d053 100644 --- a/crates/cli/src/prompt_host.rs +++ b/crates/cli/src/prompt_host.rs @@ -24,6 +24,26 @@ pub(crate) fn host_request_handler(runtime: Arc) -> HostRequestHand }) } +/// Keep detached handle polling available between Python cells, without allowing new work. +pub(crate) fn idle_host_request_handler(session_id: String) -> HostRequestHandler { + Arc::new(move |_request_id, data| { + let session_id = session_id.clone(); + Box::pin(async move { + let (action, params) = devo_kernel::parse_host_request_data(&data); + match action.as_str() { + "bash.poll" | "bash.kill" | "bash.forget" => { + devo_core::tools::host_bash::control(&session_id, &action, ¶ms) + } + // One-shot prompts have no persisted completion-notice inbox. + "bash.completed" | "bash.consumed" => { + json!({"status":"ok", "result":{"accepted":true}}) + } + _ => error_reply(format!("idle host request denied: {action}")), + } + }) + }) +} + async fn dispatch_host_request(runtime: &ToolRuntime, request_id: String, data: Value) -> Value { let Some(action) = data .get("type") @@ -33,8 +53,25 @@ async fn dispatch_host_request(runtime: &ToolRuntime, request_id: String, data: return error_reply("host request is missing a type"); }; + if matches!(action, "bash.poll" | "bash.kill" | "bash.forget") { + return runtime.control_host_bash(action, &data); + } + if action == "bash.start" { + let mut input = data.as_object().cloned().unwrap_or_default(); + input.remove("type"); + input.remove("action"); + let result = runtime + .start_host_bash(request_id, Value::Object(input)) + .await; + return if result.is_error { + error_reply(result.content.into_string()) + } else { + json!({ "status": "ok", "result": content_as_json(result.content) }) + }; + } + let (tool_name, input) = match action { - "bash" | "bash.start" => { + "bash" => { let mut input = data.as_object().cloned().unwrap_or_default(); input.remove("type"); input.remove("action"); @@ -205,7 +242,7 @@ mod tests { builder.register_handler("bash", Arc::new(ShellInputProbe { spec })); builder.restrict_model_exposure_to(&["ipython"]); let runtime = ToolRuntime::new_without_permissions(Arc::new(builder.build())); - for action in ["bash", "bash.start"] { + for action in ["bash"] { assert_eq!( dispatch_host_request( &runtime, @@ -234,6 +271,112 @@ mod tests { } } + #[tokio::test] + async fn prompt_host_start_preserves_runtime_permission_denial() { + let mut builder = ToolRegistryBuilder::new(); + let handler = devo_core::tools::handlers::ShellCommandHandler::new(); + let mut spec = handler.spec().clone(); + spec.name = "bash".to_string(); + builder.push_spec(spec); + builder.register_handler("bash", Arc::new(handler)); + let runtime = ToolRuntime::new( + Arc::new(builder.build()), + devo_core::tools::PermissionChecker::new(|request| { + Box::pin(async move { + assert_eq!(request.input["command"], json!("echo denied")); + Err("declined".to_string()) + }) + }), + ); + assert_eq!( + dispatch_host_request( + &runtime, + "denied".to_string(), + json!({"type":"bash.start", "command":"echo denied"}) + ) + .await, + json!({"status":"error", "error":"permission denied: declined"}), + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn prompt_host_shell_handles_complete_fail_and_cancel() { + let mut builder = ToolRegistryBuilder::new(); + let handler = devo_core::tools::handlers::ShellCommandHandler::new(); + let mut spec = handler.spec().clone(); + spec.name = "bash".to_string(); + builder.push_spec(spec); + builder.register_handler("bash", Arc::new(handler)); + let runtime = ToolRuntime::new_with_context( + Arc::new(builder.build()), + permission_checker(), + devo_core::tools::ToolRuntimeContext { + session_id: "ses_cli_lifecycle".into(), + cwd: std::env::current_dir().expect("test cwd"), + ..Default::default() + }, + ); + for (command, expected_exit) in [ + ("printf cli-ok", Some(0)), + ("exit 7", Some(7)), + ("sleep 30", None), + ] { + let start = dispatch_host_request( + &runtime, + "start".to_string(), + json!({"type":"bash.start", "command":command}), + ) + .await; + assert_eq!(start["status"], json!("ok"), "{start}"); + let handle_id = start["result"]["handle_id"].clone(); + if expected_exit.is_none() { + let killed = dispatch_host_request( + &runtime, + "kill".to_string(), + json!({"type":"bash.kill", "handle_id":handle_id}), + ) + .await; + assert_eq!(killed["status"], json!("ok")); + } + let final_reply = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let reply = dispatch_host_request( + &runtime, + "poll".to_string(), + json!({"type":"bash.poll", "handle_id":handle_id}), + ) + .await; + if reply["result"]["running"] == false { + break reply; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("shell completion"); + if let Some(exit) = expected_exit { + assert_eq!( + final_reply["result"]["exit_code"], + json!(exit), + "{final_reply}" + ); + } else { + assert!(final_reply["result"]["error"].is_string(), "{final_reply}"); + } + assert_eq!( + dispatch_host_request( + &runtime, + "forget".to_string(), + json!({"type":"bash.forget", "handle_id":handle_id}) + ) + .await, + json!({"status":"ok", "result":{"forgotten":true}}) + ); + } + devo_core::tools::host_bash::cancel_session("ses_cli_lifecycle"); + } + #[test] fn mixed_tool_content_preserves_text_and_metadata_for_python() { assert_eq!( @@ -249,6 +392,93 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn prompt_kernel_detached_bash_completes_while_idle() { + let cwd = std::env::current_dir().expect("cwd"); + let kernel = devo_core::tools::handlers::ensure_kernel( + &None, + &cwd, + None, + devo_protocol::CollaborationMode::Build, + ) + .await + .expect("kernel"); + let registry = devo_core::tools::handlers::build_registry_from_plan(&Default::default()); + let runtime = Arc::new(ToolRuntime::new_with_context( + Arc::new(registry), + permission_checker(), + devo_core::tools::ToolRuntimeContext { + session_id: "ses_cli_kernel_idle".into(), + cwd, + kernel: Some(Arc::clone(&kernel)), + ..Default::default() + }, + )); + kernel + .set_host_handler(Some(super::host_request_handler(runtime))) + .await; + let idle = super::idle_host_request_handler("ses_cli_kernel_idle".to_string()); + let completed = Arc::new(tokio::sync::Notify::new()); + let observed = Arc::clone(&completed); + kernel + .set_idle_bash_completion_handler(Some(Arc::new(move |id, data| { + let idle = Arc::clone(&idle); + let observed = Arc::clone(&observed); + Box::pin(async move { + let (action, _) = devo_kernel::parse_host_request_data(&data); + let result = idle(id, data).await; + if action == "bash.completed" { + observed.notify_one(); + } + result + }) + }))) + .await; + let start = kernel + .execute("h = bash('sleep 0.2; printf detached-ok')") + .await + .expect("start cell"); + assert_eq!(start.status, "ok", "{start:?}"); + tokio::time::timeout(std::time::Duration::from_secs(10), completed.notified()) + .await + .expect("idle completion"); + let cached = kernel + .execute("import json; print(json.dumps([h.running, h.poll().exit_code, h.output()]))") + .await + .expect("cached cell"); + assert_eq!(cached.status, "ok", "{cached:?}"); + let value: serde_json::Value = + serde_json::from_str(cached.stdout.trim()).expect("cached result"); + assert_eq!(value, json!([false, 0, "detached-ok"])); + kernel.shutdown().await.expect("shutdown"); + } + + #[tokio::test] + async fn idle_prompt_host_rejects_starts_but_routes_scoped_controls() { + let handler = super::idle_host_request_handler("ses_cli_idle".to_string()); + assert_eq!( + handler( + "start".to_string(), + json!({"type":"bash.start", "command":"echo forbidden"}) + ) + .await, + json!({"status":"error", "error":"idle host request denied: bash.start"}) + ); + assert_eq!( + handler( + "poll".to_string(), + json!({"type":"bash.poll", "params":{"handle_id":"missing"}}) + ) + .await, + json!({"status":"error", "error":"unknown bash handle"}) + ); + assert_eq!( + handler("notice".to_string(), json!({"type":"bash.completed"})).await, + json!({"status":"ok", "result":{"accepted":true}}) + ); + } + #[tokio::test] async fn prompt_host_rejects_interactive_questions() { let runtime = ToolRuntime::new_without_permissions(Arc::new(ToolRegistry::new())); diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 5d5a1cb3..8d859a7e 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -1365,7 +1365,6 @@ mod tests { use super::SERVER_RESPONSE_TIMEOUT; use super::ServerResponseWait; - /// Trace: L2-DES-TUI-001, L1-REQ-TUI-010 /// Verifies: stdio initialize waits for a cold-started server instead of /// the default 10s RPC budget used by later requests. #[test] diff --git a/crates/config/src/provider/auth.rs b/crates/config/src/provider/auth.rs index 3cf1c47a..88f3762b 100644 --- a/crates/config/src/provider/auth.rs +++ b/crates/config/src/provider/auth.rs @@ -426,7 +426,6 @@ mod tests { use super::*; use crate::AuthCredentialKind; - /// Trace: L2-DES-AUTH-001 /// Verifies: API key upsert/read roundtrip stores under the provider id and /// writes the pi AuthStorage wire shape. #[test] @@ -456,7 +455,6 @@ mod tests { assert!(raw.contains("\"key\": \"sk-test\"")); } - /// Trace: L2-DES-AUTH-001 /// Verifies: legacy `*_api_key` ids migrate to provider keys on upsert. #[test] fn upsert_legacy_api_key_id_normalizes_to_provider() { @@ -467,7 +465,6 @@ mod tests { assert!(!auth.credentials.contains_key("deepseek_api_key")); } - /// Trace: L2-DES-AUTH-001 /// Verifies: OAuth upsert/read roundtrip persists access and optional fields. #[test] fn upsert_oauth_roundtrips() { @@ -507,7 +504,6 @@ mod tests { assert!(raw.contains("\"accountId\"")); } - /// Trace: L2-DES-AUTH-001 /// Verifies: empty OAuth access is rejected on upsert. #[test] fn upsert_oauth_rejects_empty_access() { @@ -527,7 +523,6 @@ mod tests { assert!(error.to_string().contains("empty OAuth access")); } - /// Trace: L2-DES-AUTH-001 /// Verifies: empty API key value is rejected on upsert. #[test] fn upsert_api_key_rejects_empty_value() { @@ -536,7 +531,6 @@ mod tests { assert!(error.to_string().contains("empty API key value")); } - /// Trace: L2-DES-AUTH-001 /// Verifies: API key and OAuth credentials can coexist in one auth.json. #[test] fn api_key_and_oauth_coexist() { @@ -561,7 +555,6 @@ mod tests { assert_eq!(auth.credentials["xai"].kind, AuthCredentialKind::Oauth); } - /// Trace: L2-DES-AUTH-001 /// Verifies: legacy envelope auth.json migrates to provider-keyed pi shape. #[test] fn migrates_legacy_envelope_on_read() { @@ -588,7 +581,6 @@ mod tests { assert!(raw.contains("\"key\": \"sk-legacy\"")); } - /// Trace: L2-DES-AUTH-001 /// Verifies: auth.json is written with mode 0600 on Unix. #[cfg(unix)] #[test] diff --git a/crates/config/src/provider/catalog.rs b/crates/config/src/provider/catalog.rs index 5376fff0..330554d3 100644 --- a/crates/config/src/provider/catalog.rs +++ b/crates/config/src/provider/catalog.rs @@ -1119,7 +1119,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-002, L2-DES-MODEL-003 /// Verifies: provider model_overrides merge before custom models and preserve rich fields. #[test] fn provider_scoped_model_overrides_merge_before_custom_models() { diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index f2b987ba..236d4634 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -531,7 +531,6 @@ statusMessage = "Checking tool use" ); } -/// Trace: L2-DES-APP-005 /// Verifies: provider HTTP proxy settings and provider header fields follow user/workspace merge precedence. #[test] fn loader_merges_provider_sections_with_provider_overlay_rules() { @@ -609,7 +608,6 @@ invocation_method = "openai_responses" ); } -/// Trace: L2-DES-APP-005 /// Verifies: omitted defaulted provider fields in a higher-priority partial overlay do not overwrite lower-priority values. #[test] fn loader_provider_overlay_preserves_absent_defaulted_provider_fields() { @@ -798,7 +796,6 @@ max_tokens = 4096 ); } -/// Trace: L2-DES-APP-005 /// Verifies: CLI provider overrides participate in the same provider merge precedence as other CLI config. #[test] fn loader_applies_cli_provider_overrides_to_provider_section() { @@ -858,7 +855,6 @@ enabled = false ); } -/// Trace: L2-DES-APP-005 /// Verifies: provider upsert persists custom provider header JSON in user config and projections. #[test] fn provider_upsert_writes_user_config_when_workspace_is_active() { diff --git a/crates/config/src/tools.rs b/crates/config/src/tools.rs index f90731f9..7512bbcf 100644 --- a/crates/config/src/tools.rs +++ b/crates/config/src/tools.rs @@ -404,7 +404,6 @@ mod tests { #[test] fn default_web_fetch_mode_resolves_local() { - // Trace: L2-DES-APP-005 // Verifies: missing web_fetch config preserves the local webfetch default. assert_eq!(WebFetchConfig::default().mode, WebFetchMode::Local); assert_eq!( diff --git a/crates/core/prompts/rlm/repl_control.md b/crates/core/prompts/rlm/repl_control.md index d3dea994..b568997c 100644 --- a/crates/core/prompts/rlm/repl_control.md +++ b/crates/core/prompts/rlm/repl_control.md @@ -4,13 +4,13 @@ Python is the orchestration language: use Python for loops, conditionals, parsin Do not assume the REPL is the native runtime of the external thing being investigated. A repository, package, service, dataset, paper, website, benchmark, or API may have its own environment and normal interface. Evaluate external systems through their own interface, then use the REPL to coordinate the process and analyze what comes back. -`bash(command)` starts a shell command in the background and returns a handle immediately: `h = bash('npm test')`. Use `h.pid` / `h.running` for liveness, `h.tail(n)` / `h.output()` for combined stdout+stderr so far, `h.poll()` for a non-blocking result, `h.kill()` to terminate (SIGTERM, escalating to SIGKILL; on Windows kill() uses taskkill /T and detached or reparented descendants may survive), and `await h` (or `await bash('cmd')`) for the completed result with exit_code, output, and duration. Prefer bash() for long-running commands so the turn keeps working. Run shell commands with `bash()`, not `subprocess`/`os.system`: subprocess calls block the kernel, show the user nothing while they run, and spawn processes the harness cannot see or stop. +`bash(command)` requests a host-owned shell command and returns a pending handle immediately: `h = bash('npm test')`. Authorization and launch happen asynchronously. Use `h.handle_id` for its opaque host identity (`h.pid` is unavailable), `h.running` for cached liveness, `h.tail(n)` / `h.output()` for cached output, `h.poll()` for a non-blocking cached result, `h.kill()` to request host cancellation, and `await h` (or `await bash('cmd')`) for the completed result with exit_code, output, and duration. Prefer bash() for long-running commands. Run shell commands with `bash()`, not `subprocess`/`os.system`, so the host owns approval, sandbox policy, and process cleanup. Important: do not install dependencies into the kernel just to make an external project import or run there. If a project import, test, script, CLI, or dependency check is needed, run it through that project's own environment and normal command interface. For example, in a Python repo use its documented commands, `uv run ...`, `.venv/bin/python ...`, or the active project interpreter from the repo root. Treat failures from that native environment as the relevant result. Use Python for reading, searching, and editing files — it gives you reusable variables you can slice, filter, and act on without re-reading. Always assign read/search results to named variables so you can revisit them later. -Each `bash()` call is its own process, so shell state does not persist between calls; use `os.chdir(...)` for the working directory and `os.environ[...]` for environment variables — both persist in the REPL and apply to later `bash()` calls. +Each `bash()` call is a separate host-owned process. Shell state does not persist between calls. `os.chdir(...)` sets the working directory captured by later `bash()` calls. Python `os.environ` changes do not propagate to host commands; use the target program's explicit configuration interface. Python state in the kernel persists across cells: named variables, helper functions, classes, imports, notes, parsed outputs, and helper data structures all remain available in every later turn. Tool calls are themselves Python `await` expressions, so their return values can be bound to variables and composed into program logic just like any other call. diff --git a/crates/core/src/collaboration_mode_prompts.rs b/crates/core/src/collaboration_mode_prompts.rs index 0ff4ba60..b155a937 100644 --- a/crates/core/src/collaboration_mode_prompts.rs +++ b/crates/core/src/collaboration_mode_prompts.rs @@ -46,7 +46,6 @@ mod tests { } } - /// Trace: prompt-payload audit (R27) /// Verifies: no sentence is stated twice verbatim — the "Assumptions-first /// execution" section used to repeat the grouping rule and the /// no-reaction-means-accepted rule that the "Use reasonable assumptions" diff --git a/crates/core/src/context/execution_context.rs b/crates/core/src/context/execution_context.rs index 6d2fbcc9..6341ac46 100644 --- a/crates/core/src/context/execution_context.rs +++ b/crates/core/src/context/execution_context.rs @@ -658,7 +658,6 @@ mod tests { assert_eq!(message.content.len(), 1); } - /// Trace: L2-DES-CONTEXT-001 /// Verifies: Turn context diffs include collaboration-mode changes without full mode prompts. #[test] fn turn_context_diff_reports_collaboration_mode_changes_without_prompt() { diff --git a/crates/core/src/conversation/history.rs b/crates/core/src/conversation/history.rs index 7e5183de..5d6e7d9a 100644 --- a/crates/core/src/conversation/history.rs +++ b/crates/core/src/conversation/history.rs @@ -13,7 +13,8 @@ use std::collections::{HashMap, HashSet}; use std::io::{BufRead, Read}; use std::path::Path; -use devo_protocol::native::ids::ItemId; +use chrono::{DateTime, Utc}; +use devo_protocol::native::ids::{ItemId, SessionId}; use devo_protocol::native::item::ItemEnvelope; use devo_protocol::native::session::Session; use devo_protocol::native::turn::Turn; @@ -60,6 +61,15 @@ pub enum HistoryReadError { line_index: usize, error: RolloutLineReadError, }, + /// The requested logical session has a durable hard-delete marker in the + /// journal. Shared journals retain the file for the root and siblings, so + /// absence of data is not enough to distinguish "deleted" from "not yet + /// written"; callers map this to a not-found/resume error. + #[error("session {session_id} was deleted at {deleted_at}")] + SessionDeleted { + session_id: SessionId, + deleted_at: DateTime, + }, } fn visit_rollout_file_lines( @@ -97,6 +107,66 @@ fn visit_rollout_lines( Ok(()) } +/// Parses one physical JSONL row into a rollout line. Blank rows and a +/// truncated final row are crash tails: they were never acknowledged, so they +/// are skipped. Any other parse failure is fail-closed. +fn parse_history_rollout_line( + index: usize, + raw: &str, + is_last: bool, +) -> Result, HistoryReadError> { + if raw.trim().is_empty() { + return Ok(None); + } + match parse_rollout_line(raw) { + Ok(line) => Ok(Some(line)), + Err(RolloutLineReadError::TruncatedTail) if is_last => Ok(None), + Err(error) => Err(HistoryReadError::DamagedLine { + line_index: index, + error, + }), + } +} + +/// Visits only the lines owned by `session_id`. Tombstones are checked before +/// the caller's prefilter so a deletion marker is never skipped just because +/// the caller only wants headers or leaf pointers. +fn visit_rollout_file_lines_for_session( + path: &Path, + session_id: &SessionId, + prefilter: impl Fn(&str) -> bool, + mut visit: impl FnMut(usize, RolloutLine) -> Result<(), HistoryReadError>, +) -> Result<(), HistoryReadError> { + visit_rollout_file_lines(path, |index, raw, is_last| { + let tombstone_candidate = raw.contains("\"sessionDeleted\""); + if !tombstone_candidate && !prefilter(raw) { + return Ok(()); + } + let Some(line) = parse_history_rollout_line(index, raw, is_last)? else { + return Ok(()); + }; + if let RolloutLine::Internal { + session_id: owner, + entry: InternalRecord::SessionDeleted { deleted_at, .. }, + .. + } = &line + && owner == session_id + { + return Err(HistoryReadError::SessionDeleted { + session_id: *session_id, + deleted_at: deleted_at.to_owned(), + }); + } + if !prefilter(raw) { + return Ok(()); + } + if line.session_id().as_ref() != Some(session_id) { + return Ok(()); + } + visit(index, line) + }) +} + /// Reads one versioned rollout file into canonical history form. Every line /// must use the current format version; no older-file projection is attempted. /// A truncated final line is tolerated as a crash tail, matching resume. @@ -108,26 +178,38 @@ fn visit_rollout_lines( pub fn read_canonical_history(path: &Path) -> Result { let mut history = CanonicalHistory::default(); visit_rollout_file_lines(path, |index, raw, is_last| { - if raw.trim().is_empty() { + let Some(line) = parse_history_rollout_line(index, raw, is_last)? else { return Ok(()); - } - let parsed = match parse_rollout_line(raw) { - Ok(parsed) => parsed, - // A truncated final line is a crash tail: the write never - // completed, nothing was acknowledged. - Err(RolloutLineReadError::TruncatedTail) if is_last => return Ok(()), - Err(error) => { - return Err(HistoryReadError::DamagedLine { - line_index: index, - error, - }); - } }; - apply_rollout_line(&mut history, parsed); + apply_rollout_line(&mut history, line); Ok(()) })?; Ok(history) } + +/// Reads canonical history for one logical session inside a physical journal. +/// +/// A shared root journal contains many sessions; this is the production read +/// for both shared and legacy single-session files. Lines owned by other +/// sessions are skipped, and the target session's hard-delete tombstone is +/// reported as [`HistoryReadError::SessionDeleted`]. +pub fn read_canonical_history_for_session( + path: &Path, + session_id: &SessionId, +) -> Result { + let mut history = CanonicalHistory::default(); + visit_rollout_file_lines_for_session( + path, + session_id, + |_| true, + |_, line| { + apply_rollout_line(&mut history, line); + Ok(()) + }, + )?; + Ok(history) +} + /// Reads only the latest folded session header, skipping the typed parse /// of every line that cannot affect it. Callers that need items, turns, /// checkpoints, or the leaf pointer must use [`read_canonical_history`]; @@ -147,23 +229,39 @@ pub fn read_rollout_session_meta(path: &Path) -> Result>, Hi { return Ok(()); } - let parsed = match parse_rollout_line(raw) { - Ok(parsed) => parsed, - // A truncated final line is a crash tail: the write never - // completed, nothing was acknowledged. - Err(RolloutLineReadError::TruncatedTail) if is_last => return Ok(()), - Err(error) => { - return Err(HistoryReadError::DamagedLine { - line_index: index, - error, - }); - } + let Some(line) = parse_history_rollout_line(index, raw, is_last)? else { + return Ok(()); }; - apply_rollout_line(&mut history, parsed); + apply_rollout_line(&mut history, line); Ok(()) })?; Ok(history.session) } + +/// Session-scoped variant of [`read_rollout_session_meta`] for shared +/// journals. A later child `SessionMeta` can never replace the requested +/// root's header. +pub fn read_rollout_session_meta_for_session( + path: &Path, + session_id: &SessionId, +) -> Result>, HistoryReadError> { + let mut history = CanonicalHistory::default(); + visit_rollout_file_lines_for_session( + path, + session_id, + |raw| { + raw.contains("\"sessionMeta\"") + || raw.contains("\"sessionSettings\"") + || raw.contains("\"sessionTitleUpdated\"") + }, + |_, line| { + apply_rollout_line(&mut history, line); + Ok(()) + }, + )?; + Ok(history.session) +} + /// Reads only the effective transcript-tree tip, avoiding the full typed /// history parse on actor construction. Callers that need items, turns, or /// tree edges must use [`read_canonical_history`]. The `sessionLeaf` tag is @@ -176,23 +274,34 @@ pub fn read_rollout_session_leaf(path: &Path) -> Result<(Option, u64), H if !raw.contains("\"sessionLeaf\"") { return Ok(()); } - let parsed = match parse_rollout_line(raw) { - Ok(parsed) => parsed, - // A truncated final line is a crash tail: the write never - // completed, nothing was acknowledged. - Err(RolloutLineReadError::TruncatedTail) if is_last => return Ok(()), - Err(error) => { - return Err(HistoryReadError::DamagedLine { - line_index: index, - error, - }); - } + let Some(line) = parse_history_rollout_line(index, raw, is_last)? else { + return Ok(()); }; - apply_rollout_line(&mut history, parsed); + apply_rollout_line(&mut history, line); Ok(()) })?; Ok((history.leaf_id, history.leaf_epoch)) } + +/// Session-scoped variant of [`read_rollout_session_leaf`] for shared +/// journals. A child's later leaf line must not replace the root tip. +pub fn read_rollout_session_leaf_for_session( + path: &Path, + session_id: &SessionId, +) -> Result<(Option, u64), HistoryReadError> { + let mut history = CanonicalHistory::default(); + visit_rollout_file_lines_for_session( + path, + session_id, + |raw| raw.contains("\"sessionLeaf\""), + |_, line| { + apply_rollout_line(&mut history, line); + Ok(()) + }, + )?; + Ok((history.leaf_id, history.leaf_epoch)) +} + fn apply_rollout_line(history: &mut CanonicalHistory, line: RolloutLine) { match line { RolloutLine::SessionMeta { session, .. } => history.session = Some(session), @@ -739,7 +848,6 @@ mod tests { assert!(matches!(error, HistoryReadError::DamagedLine { .. })); } - /// Trace: L2-DES-CONV-002 /// Verifies: field-level settings lines fold into the canonical session /// snapshot (last line per field wins) and bump its version (DD-4). #[test] @@ -1080,4 +1188,281 @@ mod tests { expected ); } + fn session_record_for( + id: SessionId, + parent: Option, + dir: &Path, + now: chrono::DateTime, + ) -> crate::conversation::SessionRecord { + crate::conversation::SessionRecord { + id, + rollout_path: dir.join("rollout.jsonl"), + created_at: now, + updated_at: now, + last_activity_at: Some(now), + source: "cli".into(), + agent_nickname: parent.map(|_| "child".to_string()), + agent_role: parent.map(|_| "default".to_string()), + agent_path: parent.map(|_| "root/child".to_string()), + model_provider: "test".into(), + model: Some("test-model".into()), + model_binding_id: None, + reasoning_effort_selection: None, + cwd: dir.to_path_buf(), + additional_directories: Vec::new(), + cli_version: "test".into(), + title: None, + title_state: crate::conversation::SessionTitleState::Unset, + sandbox_policy: "workspace-write".into(), + approval_mode: "on-request".into(), + effective_context_window: None, + tokens_used: 0, + first_user_message: None, + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + parent_session_id: parent, + fork_from_id: None, + fork_at_turn_id: None, + session_context: None, + latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, + schema_version: 2, + } + } + + fn session_meta_line( + record: &crate::conversation::SessionRecord, + now: chrono::DateTime, + ) -> RolloutLine { + RolloutLine::SessionMeta { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + session: Box::new(native_session_from_record(record).expect("convert session record")), + extras: None, + } + } + + fn native_turn_for( + session_id: SessionId, + status: devo_protocol::native::turn::TurnStatus, + id: TurnId, + sequence: u32, + ) -> devo_protocol::native::turn::Turn { + let mut turn = native_turn(status, id, sequence); + turn.session_id = session_id; + turn + } + + #[test] + fn shared_journal_scoped_readers_isolate_sessions() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let path = dir.path().join("rollout.jsonl"); + let now = fixture_now(); + let root = SessionId::new(); + let child = SessionId::new(); + let root_turn = TurnId::new(); + let child_turn = TurnId::new(); + let root_item = assistant_item_envelope(root, root_turn, 1, "root text"); + let child_item = assistant_item_envelope(child, child_turn, 1, "child text"); + let root_leaf = ItemId::new(); + let child_leaf = ItemId::new(); + let root_edge = ItemId::new(); + let child_edge = ItemId::new(); + let root_record = session_record_for(root, None, dir.path(), now); + let child_record = session_record_for(child, Some(root), dir.path(), now); + let leaf_line = |session_id, epoch, leaf_id| RolloutLine::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + session_id, + turn_id: None, + seq: 0, + entry: InternalRecord::SessionLeaf { epoch, leaf_id }, + }; + let edge_line = |session_id, child_id| RolloutLine::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + session_id, + turn_id: None, + seq: 0, + entry: InternalRecord::TreeEdge { + child_id, + parent_id: None, + }, + }; + write_lines( + &path, + &[ + session_meta_line(&root_record, now), + session_meta_line(&child_record, now), + RolloutLine::Turn { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + turn: native_turn_for( + root, + devo_protocol::native::turn::TurnStatus::Completed, + root_turn, + 1, + ), + extras: None, + }, + RolloutLine::Turn { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + turn: native_turn_for( + child, + devo_protocol::native::turn::TurnStatus::Completed, + child_turn, + 1, + ), + extras: None, + }, + RolloutLine::Item { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + item: root_item.clone(), + }, + RolloutLine::Item { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + item: child_item.clone(), + }, + RolloutLine::SessionTitleUpdated { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + session_id: root, + title: "root title".into(), + previous_title: None, + }, + RolloutLine::SessionTitleUpdated { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + session_id: child, + title: "child title".into(), + previous_title: None, + }, + settings_line( + now, + root, + SessionSettingsField::Model, + serde_json::json!("root-model"), + 1, + ), + settings_line( + now, + child, + SessionSettingsField::Model, + serde_json::json!("child-model"), + 1, + ), + leaf_line(root, 1, Some(root_leaf)), + leaf_line(child, 9, Some(child_leaf)), + edge_line(root, root_edge), + edge_line(child, child_edge), + ], + ); + + let root_history = read_canonical_history_for_session(&path, &root).expect("root history"); + assert_eq!(root_history.session.as_ref().map(|s| s.id), Some(root)); + assert_eq!( + root_history.session.as_ref().and_then(|s| s.title.clone()), + Some("root title".into()) + ); + assert_eq!(root_history.turns.len(), 1); + assert_eq!(root_history.turns[0].id, root_turn); + assert_eq!(root_history.items.len(), 1); + assert_eq!(root_history.items[0].session_id, root); + assert_eq!( + (root_history.leaf_id, root_history.leaf_epoch), + (Some(root_leaf), 1) + ); + assert_eq!(root_history.tree_edges.get(&root_edge), Some(&None)); + assert!(!root_history.tree_edges.contains_key(&child_edge)); + + let child_history = + read_canonical_history_for_session(&path, &child).expect("child history"); + assert_eq!(child_history.session.as_ref().map(|s| s.id), Some(child)); + assert_eq!(child_history.turns.len(), 1); + assert_eq!(child_history.turns[0].id, child_turn); + assert_eq!(child_history.items.len(), 1); + assert_eq!(child_history.items[0].session_id, child); + assert_eq!( + (child_history.leaf_id, child_history.leaf_epoch), + (Some(child_leaf), 9) + ); + assert_eq!(child_history.tree_edges.get(&child_edge), Some(&None)); + assert!(!child_history.tree_edges.contains_key(&root_edge)); + + assert_eq!( + read_rollout_session_meta_for_session(&path, &root) + .expect("root meta") + .map(|s| s.id), + Some(root) + ); + assert_eq!( + read_rollout_session_meta_for_session(&path, &child) + .expect("child meta") + .map(|s| s.id), + Some(child) + ); + assert_eq!( + read_rollout_session_leaf_for_session(&path, &root).expect("root leaf"), + (Some(root_leaf), 1) + ); + assert_eq!( + read_rollout_session_leaf_for_session(&path, &child).expect("child leaf"), + (Some(child_leaf), 9) + ); + } + + #[test] + fn scoped_reader_reports_session_deleted_tombstone() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let path = dir.path().join("rollout.jsonl"); + let now = fixture_now(); + let root = SessionId::new(); + let child = SessionId::new(); + let root_record = session_record_for(root, None, dir.path(), now); + let child_record = session_record_for(child, Some(root), dir.path(), now); + let mut lines = vec![ + session_meta_line(&root_record, now), + session_meta_line(&child_record, now), + ]; + lines.push(RolloutLine::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: now, + session_id: child, + turn_id: None, + seq: 0, + entry: InternalRecord::SessionDeleted { + schema_version: 1, + deleted_at: now, + }, + }); + write_lines(&path, &lines); + + let error = read_canonical_history_for_session(&path, &child) + .expect_err("deleted child must not read"); + assert!(matches!( + error, + HistoryReadError::SessionDeleted { session_id, deleted_at } + if session_id == child && deleted_at == now + )); + assert!( + read_canonical_history_for_session(&path, &root).is_ok(), + "sibling tombstone must not affect the root" + ); + assert!(matches!( + read_rollout_session_meta_for_session(&path, &child), + Err(HistoryReadError::SessionDeleted { .. }) + )); + assert!(matches!( + read_rollout_session_leaf_for_session(&path, &child), + Err(HistoryReadError::SessionDeleted { .. }) + )); + // Unscoped whole-file scans skip the tombstone rather than failing. + assert!(read_canonical_history(&path).is_ok()); + } } diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index 05637d79..429dff55 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -15,8 +15,9 @@ pub use event_projection::{ sessions_stream_id, source_fact_id, }; pub use history::{ - CanonicalHistory, HistoryReadError, read_canonical_history, read_rollout_session_leaf, - read_rollout_session_meta, + CanonicalHistory, HistoryReadError, read_canonical_history, read_canonical_history_for_session, + read_rollout_session_leaf, read_rollout_session_leaf_for_session, read_rollout_session_meta, + read_rollout_session_meta_for_session, }; pub use native_from_turn_item::native_item_from_turn_item; pub use native_replay::{ diff --git a/crates/core/src/conversation/native_from_turn_item.rs b/crates/core/src/conversation/native_from_turn_item.rs index 4e9199d0..506a0bf3 100644 --- a/crates/core/src/conversation/native_from_turn_item.rs +++ b/crates/core/src/conversation/native_from_turn_item.rs @@ -139,7 +139,6 @@ mod tests { use super::*; use crate::conversation::TextItem; - /// Trace: L2-DES-APP-008 /// Verifies: empty assistant TurnItems are omitted from Native history. #[test] fn omits_empty_agent_message() { @@ -153,7 +152,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-008 /// Verifies: user TurnItems become Native UserMessage items. #[test] fn user_message_projects_to_native() { diff --git a/crates/core/src/conversation/native_replay.rs b/crates/core/src/conversation/native_replay.rs index a89ecc89..8ef18482 100644 --- a/crates/core/src/conversation/native_replay.rs +++ b/crates/core/src/conversation/native_replay.rs @@ -626,7 +626,8 @@ pub fn legacy_lines_from_internal( | InternalRecord::UsageRecord { .. } | InternalRecord::TurnApprovalCheckpoint(_) | InternalRecord::SessionLeaf { .. } - | InternalRecord::TreeEdge { .. } => Ok(Vec::new()), + | InternalRecord::TreeEdge { .. } + | InternalRecord::SessionDeleted { .. } => Ok(Vec::new()), } } diff --git a/crates/core/src/conversation/rollout.rs b/crates/core/src/conversation/rollout.rs index 03625e2f..48aed33d 100644 --- a/crates/core/src/conversation/rollout.rs +++ b/crates/core/src/conversation/rollout.rs @@ -264,6 +264,46 @@ pub enum InternalRecord { child_id: devo_protocol::native::ids::ItemId, parent_id: Option, }, + /// Hard-delete marker for one logical session inside a shared journal. + /// + /// Shared root journals contain many sessions, so a child delete cannot + /// unlink the file without destroying the root and siblings. This marker + /// is append-only and session-scoped: the enclosing [`RolloutLine::Internal`] + /// carries the owning `session_id`. Scoped readers fail with + /// `SessionDeleted`; index rebuilds omit tombstoned sessions; event + /// projection and legacy projection emit nothing for it. + SessionDeleted { + /// Schema version for persisted tombstone lines. + schema_version: u32, + /// Wall-clock time the delete became durable. + deleted_at: DateTime, + }, +} + +impl RolloutLine { + /// The logical session that owns this line, when the line is + /// session-attributed. + /// + /// Every v2 variant currently carries an owner, either on the canonical + /// payload (`SessionMeta`, `Turn`, `Item`), on the envelope (`Internal`, + /// title, compaction, rollback), or on the workspace record. Returning + /// `Option` keeps the API explicit and lets future line kinds opt out + /// without silently mis-attributing records. + pub fn session_id(&self) -> Option { + match self { + Self::SessionMeta { session, .. } => Some(session.id), + Self::Turn { turn, .. } => Some(turn.session_id), + Self::Item { item, .. } => Some(item.session_id), + Self::Internal { session_id, .. } => Some(*session_id), + Self::SessionTitleUpdated { session_id, .. } => Some(*session_id), + Self::CompactionSnapshot { session_id, .. } => Some(*session_id), + Self::SessionRollback { session_id, .. } => Some(*session_id), + Self::WorkspaceCheckpoint { record, .. } => Some(record.session_id), + Self::WorkspaceChange { record, .. } => Some(record.session_id), + Self::WorkspaceRestoreStarted { record, .. } => Some(record.session_id), + Self::WorkspaceRestoreCompleted { record, .. } => Some(record.session_id), + } + } } /// Errors from reading a single rollout line. Unknown format versions must @@ -448,4 +488,34 @@ mod tests { let error = parse_rollout_line(r#"{"v":2,"kind":"nonsense"}"#).expect_err("must fail"); assert!(matches!(error, RolloutLineReadError::Damaged { .. })); } + #[test] + fn session_deleted_internal_line_shape_is_stable() { + let line = RolloutLine::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: fixed_ts(), + session_id: SessionId::from_string("ses_1".into()), + turn_id: None, + seq: 0, + entry: InternalRecord::SessionDeleted { + schema_version: 1, + deleted_at: fixed_ts(), + }, + }; + assert_eq!( + serde_json::to_value(&line).expect("serialize"), + serde_json::json!({ + "v": 2, + "kind": "internal", + "timestamp": "2026-08-01T00:00:00Z", + "sessionId": "ses_1", + "turnId": null, + "seq": 0, + "entry": { + "type": "sessionDeleted", + "schemaVersion": 1, + "deletedAt": "2026-08-01T00:00:00Z" + } + }) + ); + } } diff --git a/crates/core/src/conversation/rollout_write_state/packed_expand.rs b/crates/core/src/conversation/rollout_write_state/packed_expand.rs index bee8b5d5..c9f0935b 100644 --- a/crates/core/src/conversation/rollout_write_state/packed_expand.rs +++ b/crates/core/src/conversation/rollout_write_state/packed_expand.rs @@ -401,7 +401,6 @@ mod tests { use super::*; - /// Trace: multimodal LocalImage persistence /// Verifies: UserMessage/SteerInput projection includes LocalImage parts /// from TextItem.local_image_paths. #[test] diff --git a/crates/core/src/durable_execution.rs b/crates/core/src/durable_execution.rs index 5fb6efb2..1d6a95b9 100644 --- a/crates/core/src/durable_execution.rs +++ b/crates/core/src/durable_execution.rs @@ -348,6 +348,27 @@ fn last_prompt_checkpoint_line( pub fn read_execution_replay( path: &std::path::Path, turn_id: &devo_protocol::native::ids::TurnId, +) -> anyhow::Result { + read_execution_replay_scoped(path, turn_id, /*session_id*/ None) +} + +/// Loads acknowledged facts for one turn owned by one logical session. +/// +/// Shared journals contain several sessions; a sibling's `CompactionSnapshot` +/// must not invalidate this session's checkpoint, and only this session's +/// execution lines may be applied. +pub fn read_execution_replay_for_session( + path: &std::path::Path, + turn_id: &devo_protocol::native::ids::TurnId, + session_id: &devo_protocol::native::ids::SessionId, +) -> anyhow::Result { + read_execution_replay_scoped(path, turn_id, Some(*session_id)) +} + +fn read_execution_replay_scoped( + path: &std::path::Path, + turn_id: &devo_protocol::native::ids::TurnId, + session_id: Option, ) -> anyhow::Result { use crate::{InternalRecord, RolloutLine, RolloutLineReadError, parse_rollout_line}; use std::io::BufRead; @@ -379,11 +400,21 @@ pub fn read_execution_replay( match parse_rollout_line(&line) { Ok(line) => match line { RolloutLine::Internal { + session_id: owner_session, turn_id: Some(owner), entry: InternalRecord::Execution { record }, .. - } if owner.as_str() == turn_id.as_str() => replay.apply(&record)?, - RolloutLine::CompactionSnapshot { .. } => replay.has_checkpoint = false, + } if owner.as_str() == turn_id.as_str() + && session_id.is_none_or(|expected| owner_session == expected) => + { + replay.apply(&record)? + } + RolloutLine::CompactionSnapshot { + session_id: owner_session, + .. + } if session_id.is_none_or(|expected| owner_session == expected) => { + replay.has_checkpoint = false; + } _ => {} }, Err(RolloutLineReadError::TruncatedTail) => { @@ -607,7 +638,6 @@ mod tests { assert_eq!(replay, replay_of(&records)); } - /// Trace: L2-DES-CONTEXT-004 #[test] fn replay_is_idempotent_and_rejects_conflicting_arguments() { let call = ResponseItem::ToolCall { @@ -855,4 +885,55 @@ mod tests { assert_eq!(replay.counters, Some(counters(10))); assert!(replay.has_checkpoint); } + #[test] + fn sibling_compaction_does_not_invalidate_parent_execution_checkpoint() { + use crate::conversation::rollout::{InternalRecord, RolloutLine}; + use devo_protocol::native::ids::{ItemId, SessionId, TurnId}; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("journal.jsonl"); + let parent = SessionId::new(); + let child = SessionId::new(); + let turn = TurnId::new(); + let records = [ + RolloutLine::Internal { + v: 2, + timestamp: chrono::Utc::now(), + session_id: parent, + turn_id: Some(turn), + seq: 0, + entry: InternalRecord::Execution { + record: ExecutionRecord::PromptCheckpoint { + items: vec![call_item("a")], + counters: None, + }, + }, + }, + RolloutLine::CompactionSnapshot { + v: 2, + timestamp: chrono::Utc::now(), + session_id: child, + turn_id: TurnId::new(), + summary_item_id: ItemId::new(), + preserved_item_ids: Vec::new(), + context_occupancy: None, + }, + ]; + let mut text = String::new(); + for record in &records { + text.push_str(&serde_json::to_string(record).expect("serialize")); + text.push('\n'); + } + std::fs::write(&path, text).expect("write journal"); + + let replay = read_execution_replay_for_session(&path, &turn, &parent) + .expect("parent execution replay"); + assert!(replay.has_checkpoint); + assert_eq!(replay.items, vec![call_item("a")]); + + // The unscoped reader keeps the old whole-file behavior: the child's + // compaction invalidates the checkpoint when no owner is supplied. + let unscoped = read_execution_replay(&path, &turn).expect("unscoped replay"); + assert!(!unscoped.has_checkpoint); + } } diff --git a/crates/core/src/goal_prompts.rs b/crates/core/src/goal_prompts.rs index 4cdd47c1..3ef3ed32 100644 --- a/crates/core/src/goal_prompts.rs +++ b/crates/core/src/goal_prompts.rs @@ -99,7 +99,6 @@ mod tests { #[test] fn continuation_prompt_escapes_untrusted_objective_xml() { - // Trace: L2-DES-GOAL-001 let goal = active_goal("finish & report \"done\"", Some(100)); let prompt = render_goal_continuation_prompt(&goal).expect("active goal prompt"); @@ -112,7 +111,6 @@ mod tests { #[test] fn continuation_prompt_does_not_fabricate_default_budget() { - // Trace: L2-DES-GOAL-001 let goal = active_goal("finish goal", None); let prompt = render_goal_continuation_prompt(&goal).expect("active goal prompt"); @@ -123,7 +121,6 @@ mod tests { #[test] fn exhausted_goal_budget_renders_budget_limit_prompt() { - // Trace: L2-DES-GOAL-001 let mut goal = active_goal("finish goal", Some(17)); goal.tokens_used = 17; diff --git a/crates/core/src/history/compaction.rs b/crates/core/src/history/compaction.rs index ebdeadbe..1a4f9a2c 100644 --- a/crates/core/src/history/compaction.rs +++ b/crates/core/src/history/compaction.rs @@ -692,7 +692,6 @@ mod tests { } } - /// Trace: L2-DES-AGENT-002 /// Verifies: cancel token aborts an in-flight history summarizer. #[tokio::test] async fn compact_history_returns_canceled_when_token_fires() { diff --git a/crates/core/src/history/context_insertion.rs b/crates/core/src/history/context_insertion.rs index 34bc1d39..fa1e2086 100644 --- a/crates/core/src/history/context_insertion.rs +++ b/crates/core/src/history/context_insertion.rs @@ -35,7 +35,6 @@ mod tests { #[test] fn places_diff_before_current_user_request() { - // Trace: L2-DES-GOAL-001 let mut messages = vec![ Message::user("first"), Message::assistant_text("reply"), @@ -54,7 +53,6 @@ mod tests { #[test] fn appends_after_completed_turn_instead_of_rewriting_old_request() { - // Trace: L2-DES-GOAL-001 let mut messages = vec![Message::user("first"), Message::assistant_text("reply")]; insert_context_diff_message(&mut messages, context_diff()); @@ -68,7 +66,6 @@ mod tests { #[test] fn does_not_split_assistant_tool_call_from_tool_result() { - // Trace: L2-DES-GOAL-001 let mut messages = vec![ Message { role: Role::Assistant, diff --git a/crates/core/src/history/harness_digest_strip.rs b/crates/core/src/history/harness_digest_strip.rs index 6269f8d2..3313d725 100644 --- a/crates/core/src/history/harness_digest_strip.rs +++ b/crates/core/src/history/harness_digest_strip.rs @@ -97,7 +97,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-CONTEXT-002, L2-DES-HARNESS-001 /// Verifies: summarizer input drops harness digest messages. #[test] fn excludes_digest_only_messages() { diff --git a/crates/core/src/history/transactions.rs b/crates/core/src/history/transactions.rs index cf88a715..ac7ef136 100644 --- a/crates/core/src/history/transactions.rs +++ b/crates/core/src/history/transactions.rs @@ -121,7 +121,6 @@ mod tests { ] } - /// Trace: L2-DES-CONTEXT-004 #[test] fn parallel_batch_is_indivisible() { let items = batch(); @@ -134,7 +133,6 @@ mod tests { assert_eq!(preserve_boundary(&items[..4], 4), 1); } - /// Trace: L2-DES-CONTEXT-004 #[test] fn messages_round_trip_all_tool_items() { let items = batch(); diff --git a/crates/core/src/message_edit.rs b/crates/core/src/message_edit.rs index f3459717..2cee1f2a 100644 --- a/crates/core/src/message_edit.rs +++ b/crates/core/src/message_edit.rs @@ -304,7 +304,6 @@ mod tests { assert_eq!(record.record_kind(), "turn_workspace_restore_completed"); } - /// Trace: L2-DES-APP-003 /// Verifies: safe workspace restore accepts unchanged post-turn file content. #[test] fn safe_restore_accepts_matching_post_turn_hash() { @@ -313,7 +312,6 @@ mod tests { assert!(is_safe_to_restore("post-turn content", &expected_hash)); } - /// Trace: L2-DES-APP-003 /// Verifies: safe workspace restore rejects files that diverged after the superseded turn. #[test] fn safe_restore_rejects_diverged_content() { @@ -322,7 +320,6 @@ mod tests { assert!(!is_safe_to_restore("user changed content", &expected_hash)); } - /// Trace: L2-DES-APP-003 /// Verifies: safe workspace restore accepts explicit sha256-prefixed hashes. #[test] fn safe_restore_accepts_sha256_prefixed_hash() { @@ -331,7 +328,6 @@ mod tests { assert!(is_safe_to_restore("post-turn content", &expected_hash)); } - /// Trace: L2-DES-APP-003 /// Verifies: safe workspace restore fails closed when no usable post-turn hash exists. #[test] fn safe_restore_rejects_empty_or_malformed_hash() { diff --git a/crates/core/src/model_catalog.rs b/crates/core/src/model_catalog.rs index c3aeeaae..a760c875 100644 --- a/crates/core/src/model_catalog.rs +++ b/crates/core/src/model_catalog.rs @@ -591,7 +591,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-002, L2-DES-MODEL-003 /// Verifies: authored thinking fields survive catalog load and model/list metadata projection. #[test] fn custom_catalog_preserves_authored_thinking_fields() { @@ -631,7 +630,6 @@ mod tests { assert_eq!(info.thinking_level_map, Some(thinking_level_map)); } - /// Trace: L2-DES-MODEL-002 /// Verifies: sparse user overlays still resolve pure builtin model defaults. #[test] fn effective_provider_catalog_resolves_builtin_model_under_sparse_overlay() { @@ -673,7 +671,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: DeepSeek builtin `reasoning_capability.levels` drive chips and /// the authored map stays verbatim upstream (pi-ai import): the API's real /// `max` level is aliased at the UI's `xhigh` slot (`xhigh` → wire `max`), @@ -715,7 +712,6 @@ mod tests { ); } - /// Trace: L2-DES-AUTH-001, L2-DES-MODEL-002 /// Verifies: builtin catalog mirrors pi-ai static providers plus Devo-local templates. #[test] fn builtin_provider_catalog_contains_current_cloud_and_local_models() { diff --git a/crates/core/src/output_replay.rs b/crates/core/src/output_replay.rs index 50cac77e..d98596a4 100644 --- a/crates/core/src/output_replay.rs +++ b/crates/core/src/output_replay.rs @@ -11,6 +11,25 @@ use crate::{InternalRecord, RolloutLine, RolloutLineReadError, parse_rollout_lin /// Manifests alone are not capabilities. Only committed session references, /// including explicitly inherited references, authorize restored artifact reads. pub fn read_output_references(path: &Path) -> anyhow::Result> { + read_output_references_scoped(path, /*session_id*/ None) +} + +/// Restores output references owned by one logical session inside a journal. +/// +/// A child sharing the root journal must not inherit the root's or a sibling's +/// output capabilities; use this form for turn restore, fork, and child delete. +/// The unscoped form remains for whole-tree GC. +pub fn read_output_references_for_session( + path: &Path, + session_id: &devo_protocol::native::ids::SessionId, +) -> anyhow::Result> { + read_output_references_scoped(path, Some(*session_id)) +} + +fn read_output_references_scoped( + path: &Path, + session_id: Option, +) -> anyhow::Result> { let mut replay = ExecutionReplay::default(); let mut lines = std::io::BufReader::new(std::fs::File::open(path)?) .lines() @@ -23,12 +42,14 @@ pub fn read_output_references(path: &Path) -> anyhow::Result match parse_rollout_line(&line) { Ok(line) => { if let RolloutLine::Internal { + session_id: owner, entry: InternalRecord::Execution { record: record @ ExecutionRecord::OutputArtifacts { .. }, }, .. } = line + && session_id.is_none_or(|expected| owner == expected) { replay.apply(&record)?; } @@ -63,3 +84,70 @@ pub fn read_output_references(path: &Path) -> anyhow::Result } Ok(replay.artifacts.into_values().collect()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + use devo_protocol::native::ids::{SessionId, TurnId}; + use devo_tools::output_store::CaptureState; + + fn artifact(id: &str, owner: &SessionId) -> OutputArtifact { + OutputArtifact { + id: id.into(), + file_identity: format!("{id}-identity"), + owner: owner.to_string(), + call_id: format!("call-{id}"), + path: PathBuf::from(format!("/tmp/outputs/{id}.txt")), + bytes: 1, + digest: "digest".into(), + state: CaptureState::Complete, + } + } + + fn output_line(session_id: SessionId, artifact: OutputArtifact) -> RolloutLine { + RolloutLine::Internal { + v: 2, + timestamp: chrono::Utc::now(), + session_id, + turn_id: Some(TurnId::new()), + seq: 0, + entry: InternalRecord::Execution { + record: ExecutionRecord::OutputArtifacts { + artifacts: vec![artifact], + }, + }, + } + } + + #[test] + fn scoped_output_references_do_not_cross_sessions() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("journal.jsonl"); + let root = SessionId::new(); + let child = SessionId::new(); + let root_artifact = artifact("root", &root); + let child_artifact = artifact("child", &child); + let lines = [ + output_line(root, root_artifact.clone()), + output_line(child, child_artifact.clone()), + ]; + let mut text = String::new(); + for line in &lines { + text.push_str(&serde_json::to_string(line).expect("serialize")); + text.push('\n'); + } + std::fs::write(&path, text).expect("write journal"); + + assert_eq!( + read_output_references_for_session(&path, &root).expect("root refs"), + vec![root_artifact] + ); + assert_eq!( + read_output_references_for_session(&path, &child).expect("child refs"), + vec![child_artifact] + ); + assert_eq!(read_output_references(&path).expect("all refs").len(), 2); + } +} diff --git a/crates/core/src/query/durability_tests.rs b/crates/core/src/query/durability_tests.rs index 9f9f8c44..9057ef26 100644 --- a/crates/core/src/query/durability_tests.rs +++ b/crates/core/src/query/durability_tests.rs @@ -55,7 +55,6 @@ impl ToolHandler for CountedTool { } } -/// Trace: L2-DES-CONTEXT-004. Persistence failure never causes automatic retry. #[tokio::test] async fn journal_failures_stop_dispatch_or_model_continuation() { for (fault, expected_calls) in [(Fault::Intent, 0), (Fault::Outcome, 1)] { @@ -109,7 +108,6 @@ async fn journal_failures_stop_dispatch_or_model_continuation() { } } -/// Trace: L2-DES-CONTEXT-004. Finalization after a crash does not call a model. #[tokio::test] async fn acknowledged_final_response_only_finishes_bookkeeping() { let messages = vec![ diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs index a9e11524..52366b20 100644 --- a/crates/core/src/query/tests.rs +++ b/crates/core/src/query/tests.rs @@ -1669,7 +1669,6 @@ async fn query_exposes_only_ipython_and_appends_subagent_warning() { ); } -/// Trace: L2-DES-RESEARCH-001 /// Verifies: provider-hosted tools emit normal tool events without local execution. #[tokio::test] async fn provider_hosted_tools_emit_events_without_local_execution() { @@ -1875,7 +1874,6 @@ async fn provider_hosted_tools_emit_events_without_local_execution() { } } -/// Trace: L2-DES-RESEARCH-001 /// Verifies: DSML text that represents a provider-hosted web_search does not end the query loop. #[tokio::test] async fn provider_hosted_dsml_text_tool_call_continues_query_loop() { @@ -2373,7 +2371,6 @@ async fn query_request_model_routing() { } } -/// Trace: L2-DES-CONTEXT-001 #[tokio::test] async fn query_collaboration_mode_prompts_follow_plan_to_build_transition() { let fixtures = CapturingFixtures::new("capturing-provider"); diff --git a/crates/core/src/rlm_prompts.rs b/crates/core/src/rlm_prompts.rs index 8bed03b5..f4106f48 100644 --- a/crates/core/src/rlm_prompts.rs +++ b/crates/core/src/rlm_prompts.rs @@ -186,20 +186,17 @@ pub fn build_rlm_base_prompt(options: &RlmPromptOptions) -> String { ); if has_ipython { - // Design doc rlm-permissions.md §6.2: guidance, not a boundary — steer - // the model to the front door when raw syscalls hit the OS fence. parts.push(String::new()); parts.push( - "The Python kernel runs inside an OS sandbox fence: file access outside the working directory and granted roots is refused by the OS itself. When direct access (`open()`, `os.*`) raises `PermissionError`/`OSError` (including `ENOENT` for hidden paths), retry through the front door: `await rlm.read(path)`, `await rlm.write(path, content)` — the host routes those through the approval pipeline and executes them for you. Do not report a sandbox refusal as a hard failure without trying the front door." + "The Python kernel is a trusted orchestration process with user-level OS access, not a sandbox for untrusted code. Plan mode does not OS-confine Python; respect its no-mutation policy. Use host-mediated APIs for shell execution and approval requests. Never use direct Python process or filesystem APIs to bypass a denied host operation." .to_string(), ); } if can_run_shell_skills { - // Local bash stays fenced; rlm.shell mediates explicit grants through the host. parts.push(String::new()); parts.push( - "Sandbox and approvals: ordinary Python `bash()` runs inside the kernel fence. If a command needs additional access, use `await rlm.shell(command, sandbox_permissions=\"with_additional_permissions\", additional_permissions={\"network\": {\"enabled\": True}}, justification=\"May I ...?\")` for narrow grants, or `await rlm.shell(command, sandbox_permissions=\"require_escalated\", justification=\"May I ...?\")` for an approved unsandboxed command. This calls the host shell approval pipeline; the kernel stays fenced and denial is final. Do not retry a denied approval through another route. Never attach these fields to an `ipython` call itself. The command uses the host platform shell (PowerShell on Windows), so use native shell syntax rather than nesting another shell. The result is a dict with command output and status." + "Sandbox and approvals: Python `bash()` requests host-owned shell execution under the session's configured sandbox profile and approval policy. For additional access, use `await rlm.shell(command, sandbox_permissions=\"with_additional_permissions\", additional_permissions={\"network\": {\"enabled\": True}}, justification=\"May I ...?\")` for narrow grants, or `await rlm.shell(command, sandbox_permissions=\"require_escalated\", justification=\"May I ...?\")` for an approved unsandboxed command. Denial is final; do not retry through another route. Never attach these fields to an `ipython` call itself. Commands use the host platform shell (PowerShell on Windows). `rlm.shell` returns a dict with command output and status." .to_string(), ); } @@ -443,7 +440,6 @@ mod tests { use super::*; - /// Trace: L1-REQ-RLM-001, L2-DES-RLM-001 /// Verifies: root RLM base prompt carries Devo doctrine strings and denies create_session. #[test] fn build_rlm_base_prompt_contains_key_doctrine() { @@ -481,7 +477,6 @@ mod tests { assert!(!prompt.contains("Each skill is also available as a shell command")); } - /// Trace: L1-REQ-RLM-001, L2-DES-RLM-001 /// Verifies: child prompt differs from root (parent messaging, no auto-refine, own kernel). #[test] fn child_prompt_differs_from_root() { @@ -505,7 +500,6 @@ mod tests { assert!(child.contains("Recursive agent depth: 1")); } - /// Trace: L2-DES-SKILLS-001, L2-DES-RLM-001 /// Verifies: catalog skills render as XML progressive disclosure. #[test] fn format_catalog_skills_xml_escapes_and_lists_fields() { diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index bd401788..1e40bc92 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -546,7 +546,6 @@ mod tests { #[test] fn goal_context_prompt_escapes_untrusted_objective_xml() { - // Trace: L2-DES-GOAL-001 let state = SessionGoalState::new(active_thread_goal( "finish & report \"done\"", Some(100), @@ -561,7 +560,6 @@ mod tests { #[test] fn goal_context_prompt_does_not_fabricate_default_budget() { - // Trace: L2-DES-GOAL-001 let state = SessionGoalState::new(active_thread_goal("finish goal", None)); let prompt = state.context_prompt().expect("active goal prompt"); @@ -572,7 +570,6 @@ mod tests { #[test] fn plan_mode_session_suppresses_goal_context_prompt() { - // Trace: L2-DES-GOAL-001 let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); session.set_active_goal(active_thread_goal("plan should not pursue goal", None)); session.collaboration_mode = CollaborationMode::Plan; diff --git a/crates/core/src/tools/handlers/exec_command.rs b/crates/core/src/tools/handlers/exec_command.rs index ebf156ba..a905f1b7 100644 --- a/crates/core/src/tools/handlers/exec_command.rs +++ b/crates/core/src/tools/handlers/exec_command.rs @@ -12,6 +12,7 @@ use crate::contracts::ToolResult; use crate::contracts::ToolResultContent; use crate::invocation::ToolContent; use crate::json_schema::JsonSchema; +use crate::shell_exec::{requested_workdir, resolve_shell_dirs}; use crate::tool_handler::ToolHandler; use crate::tool_spec::ToolCapabilityTag; use crate::tool_spec::ToolExecutionMode; @@ -132,31 +133,27 @@ impl ToolHandler for ExecCommandHandler { } }; - let cwd = input["workdir"] - .as_str() - .map(|path| { - let path = std::path::PathBuf::from(path); - if path.is_absolute() { - path - } else { - ctx.workspace_root.join(path) - } - }) - .unwrap_or_else(|| ctx.workspace_root.clone()); - - if !cwd.exists() { - return Ok(ToolResult::error( - ToolResultContent::Text(format!( - "working directory does not exist: {}", - cwd.display() - )), - "Invalid workdir", - ToolCallError::ExecutionFailed(format!( - "working directory does not exist: {}", - cwd.display() - )), - )); - } + let requested = match requested_workdir(&input) { + Ok(requested) => requested, + Err(message) => { + return Ok(ToolResult::error( + ToolResultContent::Text(message.clone()), + "Invalid workdir", + ToolCallError::InvalidInput(message), + )); + } + }; + let dirs = match resolve_shell_dirs(&ctx.workspace_root, requested) { + Ok(dirs) => dirs, + Err(message) => { + return Ok(ToolResult::error( + ToolResultContent::Text(message.clone()), + "Invalid workdir", + ToolCallError::ExecutionFailed(message), + )); + } + }; + let cwd = dirs.cwd; if is_raw_apply_patch_body(&args.cmd) { return Ok(ToolResult::error( @@ -234,6 +231,7 @@ impl ToolHandler for ExecCommandHandler { sandbox_overlay: crate::tools::sandbox_overlay_for_spawn( ctx.sandbox_permission_overlay.as_ref(), ), + sandbox_workspace: Some(dirs.workspace), }, ) .await; diff --git a/crates/core/src/tools/handlers/goal_update.rs b/crates/core/src/tools/handlers/goal_update.rs index c27e6606..4dd8d0e7 100644 --- a/crates/core/src/tools/handlers/goal_update.rs +++ b/crates/core/src/tools/handlers/goal_update.rs @@ -118,7 +118,6 @@ mod tests { #[test] fn schema_only_allows_complete_status() { - // Trace: L2-DES-GOAL-001 let schema = goal_update_schema(); let status_schema = schema .properties @@ -132,7 +131,6 @@ mod tests { #[tokio::test] async fn rejects_non_complete_status() { - // Trace: L2-DES-GOAL-001 let handler = GoalUpdateHandler::new(); let ctx = ToolContext { output_store: None, diff --git a/crates/core/src/tools/handlers/ipython.rs b/crates/core/src/tools/handlers/ipython.rs index ae9f5ddc..8c63f004 100644 --- a/crates/core/src/tools/handlers/ipython.rs +++ b/crates/core/src/tools/handlers/ipython.rs @@ -1,6 +1,5 @@ //! Model-facing `ipython` tool — executes code in the session RLM kernel. -use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; @@ -20,8 +19,12 @@ use devo_tools::python_cell_watch::{ PythonCellWatchDecision, PythonCellWatchInput, effective_first_wait_ms, output_tail, }; -mod kernel_fence; -pub(crate) use kernel_fence::resolve_kernel_fence; +mod startup; +use startup::persist_kernel_namespace_snapshot; +pub use startup::{ + KernelNamespaceRestoreState, ensure_kernel, ensure_kernel_with_restore_state, + kernel_namespace_manifest_path, kernel_namespace_snapshot_path, +}; pub struct IpythonHandler { spec: ToolSpec, @@ -394,538 +397,6 @@ fn build_ipython_result( result } -/// Host-owned site dir where `dill` is provisioned for the kernel interpreter. -fn kernel_site_dir() -> Option { - devo_util_paths::find_devo_home() - .ok() - .map(|home| home.join("kernel-site")) -} - -/// Whether the kernel interpreter imports `module` under its PYTHONPATH. -fn interpreter_imports(module: &str, config: &devo_kernel::KernelSessionConfig) -> bool { - let mut cmd = std::process::Command::new(&config.python); - cmd.arg("-c").arg(format!("import {module}")); - if let Ok(paths) = std::env::join_paths(&config.python_path_entries) { - cmd.env("PYTHONPATH", paths); - } - matches!(cmd.output(), Ok(output) if output.status.success()) -} - -/// Provision `dill` for the kernel: namespace snapshots/restores are -/// host-guaranteed (`repl.py` treats dill as host-provisioned, not a hard -/// dependency). When the interpreter does not already provide it, installs it -/// into `~/.devo/kernel-site` via `uv` (first use only) and prepends that dir -/// to the kernel PYTHONPATH. Failure stays a warning — snapshots degrade, the -/// kernel itself never fails to spawn. -/// -/// The install is atomic against concurrent provisioning (two sessions -/// spawning kernels into a fresh Devo home): each racer installs into a -/// private temp dir and renames it into place; the loser keeps the winner's -/// install instead of interleaving two `--target` installs. -fn ensure_kernel_dill(config: &mut devo_kernel::KernelSessionConfig) { - // Release bundles contain locked dependencies. Never provision over the - // network or shadow them with a user-site installation on first launch. - if devo_util_paths::runtime::RuntimeBundle::current().is_some() { - return; - } - let Some(site) = kernel_site_dir() else { - tracing::warn!( - "could not resolve the Devo home; kernel namespace snapshots will be skipped \ - (dill unavailable)" - ); - return; - }; - if site.join("dill").is_dir() { - // Steady state: provisioned on an earlier spawn — no probe needed. - config.python_path_entries.insert(0, site); - return; - } - if interpreter_imports("dill", config) { - return; - } - let Some(parent) = site.parent() else { - return; - }; - let staging = parent.join(format!( - ".kernel-site.tmp.{}.{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0), - )); - let install = std::process::Command::new("uv") - .arg("pip") - .arg("install") - .arg("--quiet") - .arg("--target") - .arg(&staging) - .arg("dill") - .output(); - match install { - Ok(output) if output.status.success() => { - if std::fs::rename(&staging, &site).is_err() { - // Lost the race (target exists now) or rename failed: either - // the winner's install or no install is in place. - let _ = std::fs::remove_dir_all(&staging); - if !site.join("dill").is_dir() { - tracing::warn!( - site = %site.display(), - "kernel-site staging rename failed; kernel namespace snapshots \ - will be skipped (dill unavailable)" - ); - return; - } - } - } - _ => { - let _ = std::fs::remove_dir_all(&staging); - tracing::warn!( - site = %site.display(), - "`uv pip install dill` failed; kernel namespace snapshots will be skipped \ - (dill unavailable)" - ); - return; - } - } - config.python_path_entries.insert(0, site); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KernelNamespaceRestoreState { - Fresh, - Restored, -} - -pub async fn ensure_kernel( - existing: &Option>, - cwd: &std::path::Path, - session_dir: Option<&std::path::Path>, - collaboration_mode: devo_protocol::CollaborationMode, -) -> Result, ToolCallError> { - ensure_kernel_with_restore_state(existing, cwd, session_dir, collaboration_mode) - .await - .map(|(kernel, _)| kernel) -} - -pub async fn ensure_kernel_with_restore_state( - existing: &Option>, - cwd: &std::path::Path, - session_dir: Option<&std::path::Path>, - collaboration_mode: devo_protocol::CollaborationMode, -) -> Result< - ( - Arc, - Option, - ), - ToolCallError, -> { - let access = match collaboration_mode { - devo_protocol::CollaborationMode::Build => devo_kernel::WorkspaceAccess::ReadWrite, - devo_protocol::CollaborationMode::Plan => devo_kernel::WorkspaceAccess::ReadOnly, - }; - if let Some(k) = existing { - let workspace = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); - if k.workspace_root() == workspace - && k.workspace_access() == access - && (collaboration_mode != devo_protocol::CollaborationMode::Plan - || devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED - || k.fence_state() == devo_kernel::FenceState::Fenced) - { - return Ok((Arc::clone(k), None)); - } - // Old Build cells (including parked background cells) must not keep - // writing after a Plan transition. A new fence cannot restrict an - // already-running process, even when its Arc is dropped by the actor. - if let Err(error) = k.shutdown().await - && collaboration_mode == devo_protocol::CollaborationMode::Plan - { - return Err(ToolCallError::InternalError(format!( - "Plan kernel unavailable: previous kernel shutdown failed: {error}" - ))); - } - } - let startup_started = std::time::Instant::now(); - let mut config = devo_kernel::KernelSessionConfig { - cwd: cwd.to_path_buf(), - ..Default::default() - }; - if let Some(runtime) = resolve_runtime_pythonpath() { - config.python_path_entries.push(runtime); - } - // Prefer installed system skills (`~/.devo/skills/.system/*/src`) over the - // in-tree `rlm_skills` stubs so API names match bundled SKILL.md. - config - .python_path_entries - .extend(resolve_system_python_skill_src_paths()); - if let Some(skills) = resolve_rlm_skills_pythonpath() { - config.python_path_entries.push(skills); - } - // dill is host-provisioned (see `ensure_kernel_dill`); must ride the - // PYTHONPATH before the fence derives its interpreter read roots. - ensure_kernel_dill(&mut config); - config.extra_env.extend(harness_kernel_env(session_dir)); - if let Some(dir) = session_dir { - let temp = dir.join("tmp"); - std::fs::create_dir_all(&temp).map_err(|error| { - ToolCallError::InternalError(format!( - "Cannot create Python kernel temporary directory {}: {error}", - temp.display() - )) - })?; - for key in ["TEMP", "TMP", "TMPDIR"] { - config - .extra_env - .push((key.to_string(), temp.to_string_lossy().into_owned())); - } - } - - // OS fence (design doc §5, P0): request the `rlm-kernel` sandbox profile - // for the kernel. The kernel crate owns the explicit-downgrade path when - // the fence cannot be raised (unprovisioned sandbox, missing platform - // wiring). - // - // Sandbox temporarily disabled (SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED): - // kernels spawn bare with full user permissions. Restore the direct call - // to re-enable the OS fence. - let resolved_fence = if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - None - } else { - resolve_kernel_fence(cwd, session_dir, collaboration_mode) - }; - if let Some(fence) = resolved_fence { - if collaboration_mode == devo_protocol::CollaborationMode::Plan { - // A writable ancestor or descendant overlaps the workspace. - // Sandbox grant unions cannot turn that path read-only. - let workspace = std::fs::canonicalize(cwd).map_err(|error| { - ToolCallError::InternalError(format!( - "Plan kernel cannot resolve workspace {}: {error}", - cwd.display() - )) - })?; - for root in &fence.writable_roots { - let writable = std::fs::canonicalize(root).map_err(|error| { - ToolCallError::InternalError(format!( - "Plan kernel cannot verify writable root {}: {error}", - root.display() - )) - })?; - if workspace.starts_with(&writable) || writable.starts_with(&workspace) { - return Err(ToolCallError::InternalError(format!( - "Plan kernel unavailable: writable root {} overlaps workspace {}", - writable.display(), - workspace.display() - ))); - } - } - } - config.fence = Some(fence); - } - config.workspace_access = access; - - let prepare_ms = startup_started.elapsed().as_millis() as u64; - let spawn_started = std::time::Instant::now(); - let kernel = devo_kernel::KernelSession::spawn(config) - .await - .map_err(|e| ToolCallError::InternalError(e.to_string()))?; - if collaboration_mode == devo_protocol::CollaborationMode::Plan - && !devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED - && kernel.fence_state() != devo_kernel::FenceState::Fenced - { - return Err(ToolCallError::InternalError(format!( - "Plan kernel unavailable: OS workspace read-only fence not active ({:?})", - kernel.fence_state() - ))); - } - tracing::info!( - cwd = %cwd.display(), - spawn_handshake_ms = spawn_started.elapsed().as_millis() as u64, - "RLM kernel spawned and handshake completed" - ); - - // Revive prior namespace before skill bootstrap so bootstrap overwrites - // live handles (rlm, skills) on top of restored user bindings. - let restore_started = std::time::Instant::now(); - let mut namespace_state = KernelNamespaceRestoreState::Fresh; - if let Some(dir) = session_dir { - let dill = kernel_namespace_snapshot_path(dir); - if dill.is_file() { - match kernel.restore(&dill).await { - Ok(out) if out.status == "ok" => { - namespace_state = KernelNamespaceRestoreState::Restored; - tracing::info!( - path = %dill.display(), - "restored RLM kernel namespace from session artifact" - ); - } - Ok(out) => { - tracing::warn!( - path = %dill.display(), - status = %out.status, - stderr = %out.stderr, - "RLM kernel namespace restore finished with non-ok status" - ); - } - Err(error) => { - tracing::warn!( - path = %dill.display(), - %error, - "RLM kernel namespace restore failed" - ); - } - } - } - } - - // Pre-import built-in skill modules so prompts that say they are available match - // the live kernel (the runtime PYTHONPATHs system skills - // `src/` dirs plus in-tree `rlm_skills` stubs). Callable skills with `run` - // are wrapped so `await attach_image(...)` calls `run`. - let restore_ms = restore_started.elapsed().as_millis() as u64; - let bootstrap_started = std::time::Instant::now(); - let bootstrap = concat!( - "import asyncio\n", - "import rlm\n", - "import rlm.mcp as mcp\n", - "import importlib as _devo_importlib\n", - "import inspect as _devo_inspect\n", - "import sys as _devo_sys\n", - "import types as _devo_types\n", - "class _DevoCallableSkillModule(_devo_types.ModuleType):\n", - " async def __call__(self, *args, **kwargs):\n", - " result = self.run(*args, **kwargs)\n", - " if _devo_inspect.isawaitable(result):\n", - " return await result\n", - " return result\n", - "def _devo_wrap_skill_module(module):\n", - " run = getattr(module, 'run', None)\n", - " if not callable(run):\n", - " return module\n", - " if isinstance(module, _DevoCallableSkillModule):\n", - " return module\n", - " wrapped = _DevoCallableSkillModule(module.__name__)\n", - " wrapped.__dict__.update(module.__dict__)\n", - " try:\n", - " wrapped.__signature__ = _devo_inspect.signature(run)\n", - " except Exception:\n", - " pass\n", - " doc = getattr(run, '__doc__', None)\n", - " if doc:\n", - " wrapped.__doc__ = doc\n", - " _devo_sys.modules[module.__name__] = wrapped\n", - " return wrapped\n", - "def _devo_import_skill(name):\n", - " try:\n", - " module = _devo_wrap_skill_module(_devo_importlib.import_module(name))\n", - " globals()[name] = module\n", - " return module\n", - " except Exception as err:\n", - " globals()[f'_devo_{name}_import_error'] = str(err)\n", - " return None\n", - "for _devo_skill in (\n", - " 'compact', 'refine', 'goal', 'agent_observe', 'agent_message',\n", - " 'edit', 'websearch', 'rlm_heartbeat', 'linear', 'notion', 'attach_image',\n", - "):\n", - " _devo_import_skill(_devo_skill)\n", - "try:\n", - " from rlm.bash import bash # noqa: F401\n", - "except ImportError:\n", - " pass\n", - ); - if let Err(error) = kernel.execute(bootstrap).await { - tracing::warn!(%error, "RLM skill bootstrap import failed"); - } - tracing::info!( - cwd = %cwd.display(), - prepare_ms, - restore_ms, - bootstrap_ms = bootstrap_started.elapsed().as_millis() as u64, - ensure_kernel_total_ms = startup_started.elapsed().as_millis() as u64, - "RLM kernel ready (prepare + spawn + restore + skill bootstrap)" - ); - - Ok((kernel, Some(namespace_state))) -} - -/// Canonical dill path under a session artifact directory. -pub fn kernel_namespace_snapshot_path(session_dir: &std::path::Path) -> PathBuf { - session_dir.join("kernel.dill") -} - -/// Canonical JSON manifest path next to [`kernel_namespace_snapshot_path`]. -pub fn kernel_namespace_manifest_path(session_dir: &std::path::Path) -> PathBuf { - session_dir.join("kernel.json") -} - -async fn persist_kernel_namespace_snapshot( - kernel: &devo_kernel::KernelSession, - session_dir: Option<&std::path::Path>, -) { - let Some(dir) = session_dir else { - return; - }; - if let Err(error) = std::fs::create_dir_all(dir) { - tracing::warn!(%error, path = %dir.display(), "could not create session artifact dir for kernel snapshot"); - return; - } - let dill = kernel_namespace_snapshot_path(dir); - let manifest = kernel_namespace_manifest_path(dir); - match kernel.snapshot(&dill, &manifest).await { - Ok(out) if out.status == "ok" => { - tracing::debug!( - path = %dill.display(), - "persisted RLM kernel namespace snapshot" - ); - } - Ok(out) => { - // repl.py reports snapshot errors (e.g. "dill unavailable") in the - // result payload carried by stdout — include it, stderr alone has - // hid the real cause before. - let stdout = out.stdout.trim(); - let detail: String = if !stdout.is_empty() { - stdout.chars().take(300).collect() - } else if !out.stderr.trim().is_empty() { - out.stderr.trim().chars().take(300).collect() - } else { - out.error_value - .unwrap_or_default() - .chars() - .take(300) - .collect() - }; - tracing::warn!( - path = %dill.display(), - status = %out.status, - detail = %detail, - "RLM kernel namespace snapshot finished with non-ok status" - ); - } - Err(error) => { - tracing::warn!( - path = %dill.display(), - %error, - "RLM kernel namespace snapshot failed" - ); - } - } -} - -fn harness_kernel_env(session_dir: Option<&std::path::Path>) -> Vec<(String, String)> { - let mut env = Vec::new(); - let global = global_harness_state_dir(); - env.push(( - "RLM_GLOBAL_HARNESS_STATE_DIR".into(), - global.display().to_string(), - )); - if let Some(dir) = session_dir { - let _ = std::fs::create_dir_all(dir.join("harness")); - env.push(("RLM_SESSION_DIR".into(), dir.display().to_string())); - env.push(( - "RLM_HARNESS_STATE_DIR".into(), - dir.join("harness").display().to_string(), - )); - } - // Skills (websearch/linear/notion) and `rlm.harness` read auth from this - // dir. Always point at Devo home — not only when DEVO_HOME is set — so - // `~/.devo/auth.json` is used under InteractiveMode. - let agent_dir = std::env::var_os("DEVO_HOME") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map(|h| PathBuf::from(h).join(".devo")) - }) - .unwrap_or_else(|| PathBuf::from(".devo")); - env.push(( - "DEVO_CODING_AGENT_DIR".into(), - agent_dir.display().to_string(), - )); - env -} - -pub(crate) fn global_harness_state_dir() -> PathBuf { - let home = std::env::var_os("DEVO_HOME") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map(|h| PathBuf::from(h).join(".devo")) - }) - .unwrap_or_else(|| PathBuf::from(".devo")); - home.join("harness") -} - -fn resolve_rlm_skills_pythonpath() -> Option { - let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let candidate = manifest.join("rlm_skills"); - if candidate.join("refine").join("__init__.py").is_file() { - return Some(candidate); - } - None -} - -/// `~/.devo/skills/.system//src` entries that contain a Python package. -fn resolve_system_python_skill_src_paths() -> Vec { - let Some(system_root) = resolve_system_skills_root() else { - return Vec::new(); - }; - let Ok(entries) = std::fs::read_dir(&system_root) else { - return Vec::new(); - }; - let mut paths = Vec::new(); - for entry in entries.flatten() { - let src = entry.path().join("src"); - if !src.is_dir() { - continue; - } - // Require at least one package `__init__.py` under src/. - let has_package = std::fs::read_dir(&src).ok().is_some_and(|children| { - children.flatten().any(|child| { - let p = child.path(); - p.is_dir() && p.join("__init__.py").is_file() - }) - }); - if has_package { - paths.push(src); - } - } - paths.sort(); - paths -} - -fn resolve_system_skills_root() -> Option { - let home = std::env::var_os("DEVO_HOME") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map(|h| PathBuf::from(h).join(".devo")) - })?; - let root = home.join("skills").join(".system"); - root.is_dir().then_some(root) -} - -fn resolve_runtime_pythonpath() -> Option { - if let Ok(path) = std::env::var("DEVO_RLM_RUNTIME_SRC") { - let p = std::path::PathBuf::from(path); - if p.join("rlm").is_dir() { - return Some(p); - } - } - let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - for base in [&manifest, manifest.parent().unwrap_or(&manifest)] { - if let Some(p) = devo_kernel::default_runtime_pythonpath(base) { - return Some(p); - } - if let Some(parent) = base.parent() - && let Some(p) = devo_kernel::default_runtime_pythonpath(parent) - { - return Some(p); - } - } - devo_kernel::default_runtime_pythonpath(std::path::Path::new(".")) -} - #[cfg(test)] #[path = "kernel_startup_tests.rs"] mod kernel_startup_tests; diff --git a/crates/core/src/tools/handlers/ipython/kernel_fence.rs b/crates/core/src/tools/handlers/ipython/kernel_fence.rs deleted file mode 100644 index 4eae0d49..00000000 --- a/crates/core/src/tools/handlers/ipython/kernel_fence.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::global_harness_state_dir; - -/// Ensure a kernel exists for RLM sessions (created on the turn task, not the actor). -/// -/// `session_dir` is the session **artifact** directory -/// (`session-artifacts//` for roots), not the parent of the rollout JSONL. -/// When provided, the kernel receives harness env so `rlm.harness` and -/// `await refine.run()` share the host's local store. -/// -/// Build the kernel OS-fence spec on the `rlm-kernel` base profile (design doc -/// §5.1, P4): **no default read** — the kernel reads only the system/ -/// interpreter base trees plus the roots granted here (workspace read-only in -/// Plan mode, read-write in Build; temp and session artifacts rw, global -/// harness rw, devo home rw). Reads and writes anywhere else are OS-refused, -/// so model code escalates through the rlm front door (`rlm.read`/`rlm.write`) -/// and the approval pipeline instead of the fence silently allowing it. -pub(crate) fn resolve_kernel_fence( - cwd: &std::path::Path, - session_dir: Option<&std::path::Path>, - collaboration_mode: devo_protocol::CollaborationMode, -) -> Option { - let mut writable_roots = Vec::new(); - if collaboration_mode == devo_protocol::CollaborationMode::Build { - writable_roots.push(cwd.to_path_buf()); - } - // Unix's base profile grants shared scratch roots too: retain them here - // so the Plan overlap check sees every writable root in the actual fence. - // Windows session kernels instead use owned scratch below the artifact - // root, avoiding recursive ACL propagation across large shared temp trees. - if session_dir.is_none() || !cfg!(windows) { - writable_roots.extend(devo_sandbox::temp_writable_paths()); - } - if let Some(dir) = session_dir { - writable_roots.push(dir.to_path_buf()); - } - // Global harness state is agent-owned memory/prompt/skill data — the same - // trust class as session artifacts. The kernel harness API writes it - // directly (`rlm.harness.create_memory(..., global_=True)` and friends), - // and its own guidance points models there, so refusing it broke every - // global create/update/delete with EACCES. Create the directory host-side - // first: fence roots resolve to path-beneath rules against real - // directories, and a missing root would downgrade the whole fence. - let global_harness = global_harness_state_dir(); - if std::fs::create_dir_all(&global_harness).is_ok() && global_harness.is_dir() { - writable_roots.push(global_harness); - } - // Devo home is agent-owned state and remains readable/writable in both modes. - if let Ok(home) = devo_util_paths::find_devo_home() - && std::fs::create_dir_all(&home).is_ok() - && home.is_dir() - { - writable_roots.push(home); - } - Some(devo_kernel::KernelFenceSpec { - readable_roots: if collaboration_mode == devo_protocol::CollaborationMode::Plan { - vec![cwd.to_path_buf()] - } else { - Vec::new() - }, - writable_roots, - deny_read: Vec::new(), - // RLM kernel fence: network is restricted by default (§5.2 — the fence - // spec carries the RLM default, not the shell profile's; kernel network - // goes through the proxy when granted, not through profile inheritance). - restrict_network: true, - }) -} - -#[cfg(test)] -mod tests { - use super::resolve_kernel_fence; - use pretty_assertions::assert_eq; - - #[test] - fn devo_home_is_writable_in_plan_and_build() { - let workspace = tempfile::tempdir().expect("workspace"); - let artifacts = tempfile::tempdir().expect("artifacts"); - let home = devo_util_paths::find_devo_home().expect("devo home"); - for mode in [ - devo_protocol::CollaborationMode::Plan, - devo_protocol::CollaborationMode::Build, - ] { - let fence = resolve_kernel_fence(workspace.path(), Some(artifacts.path()), mode) - .expect("kernel fence"); - assert_eq!( - fence - .writable_roots - .iter() - .filter(|root| *root == &home) - .collect::>(), - vec![&home] - ); - } - } -} diff --git a/crates/core/src/tools/handlers/ipython/startup.rs b/crates/core/src/tools/handlers/ipython/startup.rs new file mode 100644 index 00000000..7b02a11b --- /dev/null +++ b/crates/core/src/tools/handlers/ipython/startup.rs @@ -0,0 +1,481 @@ +//! Trusted product Python startup, namespace persistence, and lifecycle. + +use std::path::PathBuf; +use std::sync::Arc; + +use crate::contracts::ToolCallError; + +/// Host-owned site dir where `dill` is provisioned for the kernel interpreter. +fn kernel_site_dir() -> Option { + devo_util_paths::find_devo_home() + .ok() + .map(|home| home.join("kernel-site")) +} + +/// Whether the kernel interpreter imports `module` under its PYTHONPATH. +fn interpreter_imports(module: &str, config: &devo_kernel::KernelSessionConfig) -> bool { + let mut cmd = std::process::Command::new(&config.python); + cmd.arg("-c").arg(format!("import {module}")); + if let Ok(paths) = std::env::join_paths(&config.python_path_entries) { + cmd.env("PYTHONPATH", paths); + } + matches!(cmd.output(), Ok(output) if output.status.success()) +} + +/// Provision `dill` for the kernel: namespace snapshots/restores are +/// host-guaranteed (`repl.py` treats dill as host-provisioned, not a hard +/// dependency). When the interpreter does not already provide it, installs it +/// into `~/.devo/kernel-site` via `uv` (first use only) and prepends that dir +/// to the kernel PYTHONPATH. Failure stays a warning — snapshots degrade, the +/// kernel itself never fails to spawn. +/// +/// The install is atomic against concurrent provisioning (two sessions +/// spawning kernels into a fresh Devo home): each racer installs into a +/// private temp dir and renames it into place; the loser keeps the winner's +/// install instead of interleaving two `--target` installs. +fn ensure_kernel_dill(config: &mut devo_kernel::KernelSessionConfig) { + // Release bundles contain locked dependencies. Never provision over the + // network or shadow them with a user-site installation on first launch. + if devo_util_paths::runtime::RuntimeBundle::current().is_some() { + return; + } + let Some(site) = kernel_site_dir() else { + tracing::warn!( + "could not resolve the Devo home; kernel namespace snapshots will be skipped \ + (dill unavailable)" + ); + return; + }; + if site.join("dill").is_dir() { + // Steady state: provisioned on an earlier spawn — no probe needed. + config.python_path_entries.insert(0, site); + return; + } + if interpreter_imports("dill", config) { + return; + } + let Some(parent) = site.parent() else { + return; + }; + let staging = parent.join(format!( + ".kernel-site.tmp.{}.{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0), + )); + let install = std::process::Command::new("uv") + .arg("pip") + .arg("install") + .arg("--quiet") + .arg("--target") + .arg(&staging) + .arg("dill") + .output(); + match install { + Ok(output) if output.status.success() => { + if std::fs::rename(&staging, &site).is_err() { + // Lost the race (target exists now) or rename failed: either + // the winner's install or no install is in place. + let _ = std::fs::remove_dir_all(&staging); + if !site.join("dill").is_dir() { + tracing::warn!( + site = %site.display(), + "kernel-site staging rename failed; kernel namespace snapshots \ + will be skipped (dill unavailable)" + ); + return; + } + } + } + _ => { + let _ = std::fs::remove_dir_all(&staging); + tracing::warn!( + site = %site.display(), + "`uv pip install dill` failed; kernel namespace snapshots will be skipped \ + (dill unavailable)" + ); + return; + } + } + config.python_path_entries.insert(0, site); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KernelNamespaceRestoreState { + Fresh, + Restored, +} + +pub async fn ensure_kernel( + existing: &Option>, + cwd: &std::path::Path, + session_dir: Option<&std::path::Path>, + collaboration_mode: devo_protocol::CollaborationMode, +) -> Result, ToolCallError> { + ensure_kernel_with_restore_state(existing, cwd, session_dir, collaboration_mode) + .await + .map(|(kernel, _)| kernel) +} + +pub async fn ensure_kernel_with_restore_state( + existing: &Option>, + cwd: &std::path::Path, + session_dir: Option<&std::path::Path>, + collaboration_mode: devo_protocol::CollaborationMode, +) -> Result< + ( + Arc, + Option, + ), + ToolCallError, +> { + let access = match collaboration_mode { + devo_protocol::CollaborationMode::Build => devo_kernel::WorkspaceAccess::ReadWrite, + devo_protocol::CollaborationMode::Plan => devo_kernel::WorkspaceAccess::ReadOnly, + }; + if let Some(k) = existing { + let workspace = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + if k.workspace_root() == workspace && k.workspace_access() == access { + return Ok((Arc::clone(k), None)); + } + // Retire the previous process and its background cells when workspace + // or mode changes. Plan is host policy, not an OS Python restriction. + if let Err(error) = k.shutdown().await + && collaboration_mode == devo_protocol::CollaborationMode::Plan + { + return Err(ToolCallError::InternalError(format!( + "Plan kernel unavailable: previous kernel shutdown failed: {error}" + ))); + } + } + let startup_started = std::time::Instant::now(); + let mut config = devo_kernel::KernelSessionConfig { + cwd: cwd.to_path_buf(), + ..Default::default() + }; + if let Some(runtime) = resolve_runtime_pythonpath() { + config.python_path_entries.push(runtime); + } + // Prefer installed system skills (`~/.devo/skills/.system/*/src`) over the + // in-tree `rlm_skills` stubs so API names match bundled SKILL.md. + config + .python_path_entries + .extend(resolve_system_python_skill_src_paths()); + if let Some(skills) = resolve_rlm_skills_pythonpath() { + config.python_path_entries.push(skills); + } + // dill is host-provisioned for namespace snapshots and restore. + ensure_kernel_dill(&mut config); + config.extra_env.extend(harness_kernel_env(session_dir)); + if let Some(dir) = session_dir { + let temp = dir.join("tmp"); + std::fs::create_dir_all(&temp).map_err(|error| { + ToolCallError::InternalError(format!( + "Cannot create Python kernel temporary directory {}: {error}", + temp.display() + )) + })?; + for key in ["TEMP", "TMP", "TMPDIR"] { + config + .extra_env + .push((key.to_string(), temp.to_string_lossy().into_owned())); + } + } + + config.workspace_access = access; + + let prepare_ms = startup_started.elapsed().as_millis() as u64; + let spawn_started = std::time::Instant::now(); + let kernel = devo_kernel::KernelSession::spawn(config) + .await + .map_err(|e| ToolCallError::InternalError(e.to_string()))?; + tracing::info!( + cwd = %cwd.display(), + spawn_handshake_ms = spawn_started.elapsed().as_millis() as u64, + "RLM kernel spawned and handshake completed" + ); + + // Revive prior namespace before skill bootstrap so bootstrap overwrites + // live handles (rlm, skills) on top of restored user bindings. + let restore_started = std::time::Instant::now(); + let mut namespace_state = KernelNamespaceRestoreState::Fresh; + if let Some(dir) = session_dir { + let dill = kernel_namespace_snapshot_path(dir); + if dill.is_file() { + match kernel.restore(&dill).await { + Ok(out) if out.status == "ok" => { + namespace_state = KernelNamespaceRestoreState::Restored; + tracing::info!( + path = %dill.display(), + "restored RLM kernel namespace from session artifact" + ); + } + Ok(out) => { + tracing::warn!( + path = %dill.display(), + status = %out.status, + stderr = %out.stderr, + "RLM kernel namespace restore finished with non-ok status" + ); + } + Err(error) => { + tracing::warn!( + path = %dill.display(), + %error, + "RLM kernel namespace restore failed" + ); + } + } + } + } + + // Pre-import built-in skill modules so prompts that say they are available match + // the live kernel (the runtime PYTHONPATHs system skills + // `src/` dirs plus in-tree `rlm_skills` stubs). Callable skills with `run` + // are wrapped so `await attach_image(...)` calls `run`. + let restore_ms = restore_started.elapsed().as_millis() as u64; + let bootstrap_started = std::time::Instant::now(); + let bootstrap = concat!( + "import asyncio\n", + "import rlm\n", + "import rlm.mcp as mcp\n", + "import importlib as _devo_importlib\n", + "import inspect as _devo_inspect\n", + "import sys as _devo_sys\n", + "import types as _devo_types\n", + "class _DevoCallableSkillModule(_devo_types.ModuleType):\n", + " async def __call__(self, *args, **kwargs):\n", + " result = self.run(*args, **kwargs)\n", + " if _devo_inspect.isawaitable(result):\n", + " return await result\n", + " return result\n", + "def _devo_wrap_skill_module(module):\n", + " run = getattr(module, 'run', None)\n", + " if not callable(run):\n", + " return module\n", + " if isinstance(module, _DevoCallableSkillModule):\n", + " return module\n", + " wrapped = _DevoCallableSkillModule(module.__name__)\n", + " wrapped.__dict__.update(module.__dict__)\n", + " try:\n", + " wrapped.__signature__ = _devo_inspect.signature(run)\n", + " except Exception:\n", + " pass\n", + " doc = getattr(run, '__doc__', None)\n", + " if doc:\n", + " wrapped.__doc__ = doc\n", + " _devo_sys.modules[module.__name__] = wrapped\n", + " return wrapped\n", + "def _devo_import_skill(name):\n", + " try:\n", + " module = _devo_wrap_skill_module(_devo_importlib.import_module(name))\n", + " globals()[name] = module\n", + " return module\n", + " except Exception as err:\n", + " globals()[f'_devo_{name}_import_error'] = str(err)\n", + " return None\n", + "for _devo_skill in (\n", + " 'compact', 'refine', 'goal', 'agent_observe', 'agent_message',\n", + " 'edit', 'websearch', 'rlm_heartbeat', 'linear', 'notion', 'attach_image',\n", + "):\n", + " _devo_import_skill(_devo_skill)\n", + "try:\n", + " from rlm.bash import bash # noqa: F401\n", + "except ImportError:\n", + " pass\n", + ); + if let Err(error) = kernel.execute(bootstrap).await { + tracing::warn!(%error, "RLM skill bootstrap import failed"); + } + tracing::info!( + cwd = %cwd.display(), + prepare_ms, + restore_ms, + bootstrap_ms = bootstrap_started.elapsed().as_millis() as u64, + ensure_kernel_total_ms = startup_started.elapsed().as_millis() as u64, + "RLM kernel ready (prepare + spawn + restore + skill bootstrap)" + ); + + Ok((kernel, Some(namespace_state))) +} + +/// Canonical dill path under a session artifact directory. +pub fn kernel_namespace_snapshot_path(session_dir: &std::path::Path) -> PathBuf { + session_dir.join("kernel.dill") +} + +/// Canonical JSON manifest path next to [`kernel_namespace_snapshot_path`]. +pub fn kernel_namespace_manifest_path(session_dir: &std::path::Path) -> PathBuf { + session_dir.join("kernel.json") +} + +pub(super) async fn persist_kernel_namespace_snapshot( + kernel: &devo_kernel::KernelSession, + session_dir: Option<&std::path::Path>, +) { + let Some(dir) = session_dir else { + return; + }; + if let Err(error) = std::fs::create_dir_all(dir) { + tracing::warn!(%error, path = %dir.display(), "could not create session artifact dir for kernel snapshot"); + return; + } + let dill = kernel_namespace_snapshot_path(dir); + let manifest = kernel_namespace_manifest_path(dir); + match kernel.snapshot(&dill, &manifest).await { + Ok(out) if out.status == "ok" => { + tracing::debug!( + path = %dill.display(), + "persisted RLM kernel namespace snapshot" + ); + } + Ok(out) => { + // repl.py reports snapshot errors (e.g. "dill unavailable") in the + // result payload carried by stdout — include it, stderr alone has + // hid the real cause before. + let stdout = out.stdout.trim(); + let detail: String = if !stdout.is_empty() { + stdout.chars().take(300).collect() + } else if !out.stderr.trim().is_empty() { + out.stderr.trim().chars().take(300).collect() + } else { + out.error_value + .unwrap_or_default() + .chars() + .take(300) + .collect() + }; + tracing::warn!( + path = %dill.display(), + status = %out.status, + detail = %detail, + "RLM kernel namespace snapshot finished with non-ok status" + ); + } + Err(error) => { + tracing::warn!( + path = %dill.display(), + %error, + "RLM kernel namespace snapshot failed" + ); + } + } +} + +fn harness_kernel_env(session_dir: Option<&std::path::Path>) -> Vec<(String, String)> { + let mut env = Vec::new(); + let global = global_harness_state_dir(); + env.push(( + "RLM_GLOBAL_HARNESS_STATE_DIR".into(), + global.display().to_string(), + )); + if let Some(dir) = session_dir { + let _ = std::fs::create_dir_all(dir.join("harness")); + env.push(("RLM_SESSION_DIR".into(), dir.display().to_string())); + env.push(( + "RLM_HARNESS_STATE_DIR".into(), + dir.join("harness").display().to_string(), + )); + } + // Skills (websearch/linear/notion) and `rlm.harness` read auth from this + // dir. Always point at Devo home — not only when DEVO_HOME is set — so + // `~/.devo/auth.json` is used under InteractiveMode. + let agent_dir = std::env::var_os("DEVO_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(|h| PathBuf::from(h).join(".devo")) + }) + .unwrap_or_else(|| PathBuf::from(".devo")); + env.push(( + "DEVO_CODING_AGENT_DIR".into(), + agent_dir.display().to_string(), + )); + env +} + +pub(crate) fn global_harness_state_dir() -> PathBuf { + let home = std::env::var_os("DEVO_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(|h| PathBuf::from(h).join(".devo")) + }) + .unwrap_or_else(|| PathBuf::from(".devo")); + home.join("harness") +} + +fn resolve_rlm_skills_pythonpath() -> Option { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let candidate = manifest.join("rlm_skills"); + if candidate.join("refine").join("__init__.py").is_file() { + return Some(candidate); + } + None +} + +/// `~/.devo/skills/.system//src` entries that contain a Python package. +fn resolve_system_python_skill_src_paths() -> Vec { + let Some(system_root) = resolve_system_skills_root() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(&system_root) else { + return Vec::new(); + }; + let mut paths = Vec::new(); + for entry in entries.flatten() { + let src = entry.path().join("src"); + if !src.is_dir() { + continue; + } + // Require at least one package `__init__.py` under src/. + let has_package = std::fs::read_dir(&src).ok().is_some_and(|children| { + children.flatten().any(|child| { + let p = child.path(); + p.is_dir() && p.join("__init__.py").is_file() + }) + }); + if has_package { + paths.push(src); + } + } + paths.sort(); + paths +} + +fn resolve_system_skills_root() -> Option { + let home = std::env::var_os("DEVO_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(|h| PathBuf::from(h).join(".devo")) + })?; + let root = home.join("skills").join(".system"); + root.is_dir().then_some(root) +} + +fn resolve_runtime_pythonpath() -> Option { + if let Ok(path) = std::env::var("DEVO_RLM_RUNTIME_SRC") { + let p = std::path::PathBuf::from(path); + if p.join("rlm").is_dir() { + return Some(p); + } + } + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + for base in [&manifest, manifest.parent().unwrap_or(&manifest)] { + if let Some(p) = devo_kernel::default_runtime_pythonpath(base) { + return Some(p); + } + if let Some(parent) = base.parent() + && let Some(p) = devo_kernel::default_runtime_pythonpath(parent) + { + return Some(p); + } + } + devo_kernel::default_runtime_pythonpath(std::path::Path::new(".")) +} diff --git a/crates/core/src/tools/handlers/ipython_tests.rs b/crates/core/src/tools/handlers/ipython_tests.rs index 4b710db2..d07d9be6 100644 --- a/crates/core/src/tools/handlers/ipython_tests.rs +++ b/crates/core/src/tools/handlers/ipython_tests.rs @@ -40,7 +40,6 @@ fn ctx_with_kernel(kernel: std::sync::Arc) -> ToolCo } } -/// Trace: L2-DES-RLM-001 /// Verifies: IpythonHandler executes code and returns the kernel details JSON shape. #[tokio::test] async fn ipython_handler_executes_and_returns_details() { @@ -81,7 +80,6 @@ async fn ipython_handler_executes_and_returns_details() { } } -/// Trace: L2-DES-RLM-001 /// Verifies: namespace survives across two IpythonHandler invocations (cross-turn). #[tokio::test] async fn ipython_handler_namespace_survives_two_calls() { @@ -124,7 +122,6 @@ async fn ipython_handler_namespace_survives_two_calls() { } } -/// Trace: L2-DES-RLM-001 /// Verifies: ensure_kernel reuses an existing Arc and two executes share namespace. #[tokio::test] async fn ensure_kernel_reuses_arc_across_two_executes() { @@ -165,7 +162,6 @@ async fn ensure_kernel_reuses_arc_across_two_executes() { ); } -/// Trace: L2-DES-RLM-001 /// Verifies: cancel token during a long cell interrupts the kernel and returns Cancelled. #[tokio::test] async fn ipython_handler_cancel_interrupts_long_cell() { @@ -227,7 +223,6 @@ impl devo_tools::PythonCellWatch for FixedWatch { } } -/// Trace: L2-DES-RLM-001 /// Verifies: first-wait expiry + continue_fg eventually completes the cell in foreground. #[tokio::test] async fn ipython_wait_budget_continue_fg_completes() { @@ -279,7 +274,6 @@ async fn ipython_wait_budget_continue_fg_completes() { } } -/// Trace: L2-DES-RLM-001 /// Verifies: wait policy cancel interrupts the cell. #[tokio::test] async fn ipython_wait_budget_cancel_interrupts() { @@ -322,7 +316,6 @@ async fn ipython_wait_budget_cancel_interrupts() { } } -/// Trace: L2-DES-RLM-001 /// Verifies: background parks the cell and returns provisional status. #[tokio::test] async fn ipython_wait_budget_background_parks() { @@ -368,7 +361,6 @@ async fn ipython_wait_budget_background_parks() { tokio::time::sleep(std::time::Duration::from_millis(2500)).await; } -/// Trace: L2-DES-RLM-001 /// Verifies: exhausted continue renewals force background. #[tokio::test] async fn ipython_wait_budget_max_renewals_force_background() { @@ -421,7 +413,6 @@ fn kernel_namespace_paths_are_canonical_under_session_dir() { assert_eq!(kernel_namespace_manifest_path(dir), dir.join("kernel.json")); } -/// Trace: L2-DES-RLM-001 /// Verifies: ensure_kernel restores dill from session_dir before bootstrap. #[tokio::test] async fn ensure_kernel_restores_namespace_from_session_dir() { @@ -559,216 +550,3 @@ async fn rlm_bootstrap_exposes_generic_mcp_module() { .expect("execute MCP bootstrap check"); assert_eq!(output.status, "ok", "stderr={}", output.stderr); } - -#[test] -fn kernel_fence_grants_global_harness_state_writable() { - // The global harness store must be kernel-writable: `rlm.harness` - // create/update/delete with `global_=True` writes it directly, and the - // kernel's own guidance points models there. The grant must come from the - // same helper that builds RLM_GLOBAL_HARNESS_STATE_DIR so env and fence - // can never disagree. - use crate::tools::handlers::ipython::{global_harness_state_dir, resolve_kernel_fence}; - - let fence = resolve_kernel_fence( - std::path::Path::new("/workspace"), - Some(std::path::Path::new("/artifacts/session")), - devo_protocol::CollaborationMode::Build, - ) - .expect("fence spec"); - assert!( - fence - .writable_roots - .contains(&std::path::PathBuf::from("/workspace")) - ); - assert!( - fence - .writable_roots - .contains(&std::path::PathBuf::from("/artifacts/session")) - ); - assert!( - fence.writable_roots.contains(&global_harness_state_dir()), - "global harness dir must be writable by the kernel, got {:?}", - fence.writable_roots - ); -} - -#[test] -fn plan_fence_reads_workspace_but_only_build_fence_writes_it() { - use crate::tools::handlers::ipython::resolve_kernel_fence; - let workspace = tempfile::tempdir().expect("workspace"); - let workspace = workspace.path(); - let plan = resolve_kernel_fence(workspace, None, devo_protocol::CollaborationMode::Plan) - .expect("Plan fence"); - let build = resolve_kernel_fence(workspace, None, devo_protocol::CollaborationMode::Build) - .expect("Build fence"); - assert!(plan.readable_roots.contains(&workspace.to_path_buf())); - assert!(!plan.writable_roots.contains(&workspace.to_path_buf())); - assert!(build.writable_roots.contains(&workspace.to_path_buf())); - assert_eq!(plan.restrict_network, build.restrict_network); -} - -#[cfg(unix)] -#[tokio::test] -async fn plan_kernel_fails_closed_when_workspace_is_in_scratch() { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - // No fence is requested while the sandbox is disabled, so the Plan - // overlap check never runs. Kept for the post-revert contract. - eprintln!("skip: OS sandbox temporarily disabled"); - return; - } - let workspace = tempfile::tempdir().expect("workspace under /tmp"); - let err = match ensure_kernel( - &None, - workspace.path(), - None, - devo_protocol::CollaborationMode::Plan, - ) - .await - { - Ok(_) => panic!("Plan must not use a writable scratch ancestor"), - Err(err) => err, - }; - assert!( - err.to_string().contains("overlaps workspace"), - "unexpected Plan failure: {err}" - ); -} - -/// Native Python file I/O, not just an inspection of the fence spec. -/// Linux is supported when Landlock is provisioned. Unavailable sandbox -/// support must return an error rather than launching a writable Plan kernel. -#[cfg(target_os = "linux")] -#[tokio::test] -async fn plan_kernel_denies_python_workspace_write_but_allows_scratch_and_mode_switch() { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - // Kernels spawn bare while the sandbox is disabled, so the workspace - // write cannot be OS-refused. Kept for the post-revert contract. - eprintln!("skip: OS sandbox temporarily disabled"); - return; - } - let workspace = tempfile::Builder::new() - .prefix(".devo-plan-kernel-") - .tempdir_in(env!("CARGO_MANIFEST_DIR")) - .expect("workspace outside scratch"); - let scratch = tempfile::tempdir().expect("scratch"); - let read_path = workspace.path().join("read.txt"); - let blocked_path = workspace.path().join("blocked.txt"); - let scratch_path = scratch.path().join("scratch.txt"); - std::fs::write(&read_path, "allowed read").expect("seed workspace"); - - let plan = match ensure_kernel( - &None, - workspace.path(), - None, - devo_protocol::CollaborationMode::Plan, - ) - .await - { - Ok(kernel) => kernel, - Err(error) => { - assert!( - error.to_string().contains("Plan kernel unavailable") - || error.to_string().contains("failed to spawn") - || error.to_string().contains("kernel handshake failed"), - "unexpected kernel failure: {error}" - ); - eprintln!("Plan kernel not provisioned; fails closed: {error}"); - return; - } - }; - assert_eq!(plan.fence_state(), devo_kernel::FenceState::Fenced); - assert_eq!( - plan.workspace_access(), - devo_kernel::WorkspaceAccess::ReadOnly - ); - let read_code = format!( - "print(open({}).read())", - serde_json::to_string(&read_path.display().to_string()).expect("path JSON") - ); - let read = plan - .execute(read_code) - .await - .expect("Python workspace read"); - assert_eq!(read.status, "ok", "stderr={}", read.stderr); - assert!(read.stdout.contains("allowed read")); - let write_code = format!( - "open({}, 'w').write('bypass')", - serde_json::to_string(&blocked_path.display().to_string()).expect("path JSON") - ); - let denied = plan - .execute(write_code.clone()) - .await - .expect("Python workspace write"); - assert_eq!(denied.error_name.as_deref(), Some("PermissionError")); - assert!( - !blocked_path.exists(), - "workspace write bypassed Plan fence" - ); - let scratch_code = format!( - "open({}, 'w').write('scratch ok')", - serde_json::to_string(&scratch_path.display().to_string()).expect("path JSON") - ); - let scratch_out = plan - .execute(scratch_code) - .await - .expect("Python scratch write"); - assert_eq!(scratch_out.status, "ok", "stderr={}", scratch_out.stderr); - assert_eq!( - std::fs::read_to_string(&scratch_path).expect("scratch result"), - "scratch ok" - ); - - let build = ensure_kernel( - &Some(std::sync::Arc::clone(&plan)), - workspace.path(), - None, - devo_protocol::CollaborationMode::Build, - ) - .await - .expect("switch to Build"); - assert!(!std::sync::Arc::ptr_eq(&plan, &build)); - assert_eq!( - build.workspace_access(), - devo_kernel::WorkspaceAccess::ReadWrite - ); - let allowed = build.execute(write_code).await.expect("Build Python write"); - assert_eq!(allowed.status, "ok", "stderr={}", allowed.stderr); - assert_eq!( - std::fs::read_to_string(&blocked_path).expect("Build result"), - "bypass" - ); - - let plan_again = ensure_kernel( - &Some(build), - workspace.path(), - None, - devo_protocol::CollaborationMode::Plan, - ) - .await - .expect("switch back to Plan"); - assert_eq!( - plan_again.workspace_access(), - devo_kernel::WorkspaceAccess::ReadOnly - ); - let denied_again = plan_again - .execute(format!( - "open({}, 'w')", - serde_json::to_string(&blocked_path.display().to_string()).expect("path JSON") - )) - .await - .expect("Plan write again"); - assert_eq!(denied_again.error_name.as_deref(), Some("PermissionError")); - // A prior fenced Plan kernel cannot be reused when the workspace moves - // underneath the writable scratch root. - let moved = ensure_kernel( - &Some(plan_again), - scratch.path(), - None, - devo_protocol::CollaborationMode::Plan, - ) - .await; - assert!( - matches!(moved, Err(ref error) if error.to_string().contains("overlaps workspace")), - "workspace move must fail closed" - ); -} diff --git a/crates/core/src/tools/handlers/kernel_startup_tests.rs b/crates/core/src/tools/handlers/kernel_startup_tests.rs index c8fed6aa..8f6df78e 100644 --- a/crates/core/src/tools/handlers/kernel_startup_tests.rs +++ b/crates/core/src/tools/handlers/kernel_startup_tests.rs @@ -1,52 +1,6 @@ use super::*; use pretty_assertions::assert_eq; -#[cfg(windows)] -#[test] -fn session_fence_does_not_grant_shared_temp_roots() { - let workspace = tempfile::tempdir().expect("workspace"); - let artifacts = tempfile::tempdir().expect("artifacts"); - let fence = resolve_kernel_fence( - workspace.path(), - Some(artifacts.path()), - devo_protocol::CollaborationMode::Build, - ) - .expect("fence"); - assert_eq!( - fence.writable_roots, - vec![ - workspace.path().to_path_buf(), - artifacts.path().to_path_buf(), - global_harness_state_dir(), - ] - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn owned_temp_does_not_hide_base_profile_plan_overlap() { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - // No fence is requested while the sandbox is disabled, so the Plan - // overlap check never runs. Kept for the post-revert contract. - eprintln!("skip: OS sandbox temporarily disabled"); - return; - } - let workspace = tempfile::tempdir().expect("workspace under shared scratch"); - let artifacts = tempfile::tempdir().expect("artifacts"); - let error = match ensure_kernel_with_restore_state( - &None, - workspace.path(), - Some(artifacts.path()), - devo_protocol::CollaborationMode::Plan, - ) - .await - { - Ok(_) => panic!("Plan must reject the writable scratch ancestor in the Unix base profile"), - Err(error) => error, - }; - assert!(error.to_string().contains("overlaps workspace"), "{error}"); -} - #[tokio::test] async fn session_kernel_uses_owned_temp_and_reuses_namespace() { let workspace = tempfile::tempdir().expect("workspace"); @@ -100,3 +54,66 @@ print(json.dumps({'owned': Path(tempfile.gettempdir()) == expected, ); kernel.shutdown().await.expect("shutdown"); } + +/// Plan constrains host actions, not native Python running as the user. +#[tokio::test] +async fn trusted_plan_kernel_writes_reuses_and_restarts_on_mode_or_workspace_change() { + let workspace = tempfile::tempdir().expect("workspace"); + let artifacts = tempfile::tempdir().expect("artifacts"); + let mut existing = None; + for (mode, root, reuse) in [ + ( + devo_protocol::CollaborationMode::Plan, + workspace.path(), + false, + ), + ( + devo_protocol::CollaborationMode::Plan, + workspace.path(), + true, + ), + ( + devo_protocol::CollaborationMode::Build, + workspace.path(), + false, + ), + ( + devo_protocol::CollaborationMode::Plan, + workspace.path(), + false, + ), + ( + devo_protocol::CollaborationMode::Plan, + artifacts.path(), + false, + ), + ] { + let (kernel, _) = + ensure_kernel_with_restore_state(&existing, root, Some(artifacts.path()), mode) + .await + .expect("trusted kernel"); + if let Some(previous) = &existing { + assert_eq!(Arc::ptr_eq(previous, &kernel), reuse); + if !reuse { + assert!(previous.execute("print('retired')").await.is_err()); + } + } + let output = kernel.execute( + "from pathlib import Path\nPath('trusted.txt').write_text('user privileges')\nprint(Path('trusted.txt').read_text())" + ).await.expect("native Python write"); + assert_eq!( + (output.status.as_str(), output.stdout.as_str()), + ("ok", "user privileges\n") + ); + assert_eq!( + std::fs::read_to_string(root.join("trusted.txt")).expect("file"), + "user privileges" + ); + existing = Some(kernel); + } + existing + .expect("kernel") + .shutdown() + .await + .expect("shutdown"); +} diff --git a/crates/core/src/tools/handlers/mod.rs b/crates/core/src/tools/handlers/mod.rs index a858bacd..613aeef2 100644 --- a/crates/core/src/tools/handlers/mod.rs +++ b/crates/core/src/tools/handlers/mod.rs @@ -44,7 +44,7 @@ pub use mcp::{McpToolHandler, mcp_search_text, mcp_tool_spec}; pub use plan::PlanHandler; pub use question::QuestionHandler; pub use read::ReadHandler; -pub use shell_command::ShellCommandHandler; +pub use shell_command::{ShellCommandHandler, ShellExecutionMode}; pub use skill::SkillHandler; pub use tool_search::{ToolSearchHandler, tool_search_spec}; pub use webfetch::WebFetchHandler; @@ -381,7 +381,6 @@ mod tests { #[test] fn default_registry_exposes_update_goal_tool() { - // Trace: L2-DES-GOAL-001 let registry = build_registry_from_plan(&ToolPlanConfig::default()); assert!(registry.spec("update_goal").is_some()); diff --git a/crates/core/src/tools/handlers/shell_command.rs b/crates/core/src/tools/handlers/shell_command.rs index 26d8af14..77d0b604 100644 --- a/crates/core/src/tools/handlers/shell_command.rs +++ b/crates/core/src/tools/handlers/shell_command.rs @@ -7,16 +7,21 @@ use crate::invocation::ToolContent; use crate::registry_plan::shell_command_tool_spec; use crate::shell_exec::{ DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_TIMEOUT_MS, DEFAULT_YIELD_TIME_MS, ShellExecRequest, - execute_shell_command, + execute_shell_command, requested_workdir, resolve_shell_dirs, }; use crate::tool_handler::ToolHandler; use crate::tool_spec::ToolSpec; +/// Execution defaults chosen by the trusted caller, never by model input. +#[derive(Clone, Copy)] +pub enum ShellExecutionMode { + Foreground, + HostBackground, +} + /// Tool adapter for `shell_command` (and the legacy `bash` alias). /// -/// Parses model input and runs the command locally via the shell executor. -/// The ToolSpec is built from the shared registry-plan schema so the plan and -/// handler stay aligned. +/// Parses input and uses the shared shell executor and registry-plan schema. pub struct ShellCommandHandler { spec: ToolSpec, } @@ -53,7 +58,20 @@ impl ToolHandler for ShellCommandHandler { &self, ctx: ToolContext, input: serde_json::Value, - _progress: Option, + progress: Option, + ) -> Result { + self.execute(ctx, input, progress, ShellExecutionMode::Foreground) + .await + } +} + +impl ShellCommandHandler { + pub async fn execute( + &self, + ctx: ToolContext, + input: serde_json::Value, + progress: Option, + mode: ShellExecutionMode, ) -> Result { let command = input .get("command") @@ -64,11 +82,30 @@ impl ToolHandler for ShellCommandHandler { let timeout_ms = input["timeout"] .as_u64() .or_else(|| input["timeout_ms"].as_u64()) - .unwrap_or(DEFAULT_TIMEOUT_MS); - let workdir = input["workdir"] - .as_str() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| ctx.workspace_root.clone()); + .unwrap_or(match mode { + ShellExecutionMode::Foreground => DEFAULT_TIMEOUT_MS, + ShellExecutionMode::HostBackground => crate::shell_exec::NO_TIMEOUT_MS, + }); + let requested = match requested_workdir(&input) { + Ok(requested) => requested, + Err(message) => { + return Ok(ToolResult::error( + ToolResultContent::Text(message.clone()), + "Invalid workdir", + ToolCallError::InvalidInput(message), + )); + } + }; + let dirs = match resolve_shell_dirs(&ctx.workspace_root, requested) { + Ok(dirs) => dirs, + Err(message) => { + return Ok(ToolResult::error( + ToolResultContent::Text(message.clone()), + "Invalid workdir", + ToolCallError::ExecutionFailed(message), + )); + } + }; let description = input["description"] .as_str() .unwrap_or("shell command") @@ -84,33 +121,46 @@ impl ToolHandler for ShellCommandHandler { .map(|v| v as usize) .unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS); - let output = execute_shell_command( - ShellExecRequest { - output_capture: ctx.output_store.as_ref().map(|store| { - store - .capture(&ctx.tool_call_id.0) - .map(|capture| std::sync::Arc::new(std::sync::Mutex::new(capture))) - .map_err(|error| error.to_string()) - }), - command: command.to_string(), - workdir, - description, - shell_override, - tty, - login, - timeout_ms, - yield_time_ms, - max_output_tokens, - sandbox_profile: ctx.sandbox_profile.clone(), - sandbox_permission_overlay: crate::tools::sandbox_overlay_for_spawn( - ctx.sandbox_permission_overlay.as_ref(), - ), + let request = ShellExecRequest { + output_capture: ctx.output_store.as_ref().map(|store| { + store + .capture(&ctx.tool_call_id.0) + .map(|capture| std::sync::Arc::new(std::sync::Mutex::new(capture))) + .map_err(|error| error.to_string()) + }), + command: command.to_string(), + sandbox_workspace: Some(dirs.workspace), + workdir: dirs.cwd, + description, + shell_override, + tty, + login, + timeout_ms, + yield_time_ms, + max_output_tokens, + progress_limit_bytes: match mode { + ShellExecutionMode::Foreground => None, + ShellExecutionMode::HostBackground => Some(256 * 1024), }, - None, - ctx.cancel_token.clone(), - ) - .await - .map_err(|e| ToolCallError::ExecutionFailed(e.to_string()))?; + sandbox_profile: ctx.sandbox_profile.clone(), + sandbox_permission_overlay: crate::tools::sandbox_overlay_for_spawn( + ctx.sandbox_permission_overlay.as_ref(), + ), + }; + let output = if let Some(progress) = progress { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let forward = async move { + while let Some(delta) = receiver.recv().await { + let _ = progress.send(crate::contracts::ToolProgress::OutputDelta { delta }); + } + }; + let execution = execute_shell_command(request, Some(sender), ctx.cancel_token.clone()); + let (output, ()) = tokio::join!(execution, forward); + output + } else { + execute_shell_command(request, None, ctx.cancel_token.clone()).await + }; + let output = output.map_err(|e| ToolCallError::ExecutionFailed(e.to_string()))?; let display = output.display_content; let content = tool_result_content(output.content); diff --git a/crates/core/src/tools/host_bash.rs b/crates/core/src/tools/host_bash.rs new file mode 100644 index 00000000..8323ff3e --- /dev/null +++ b/crates/core/src/tools/host_bash.rs @@ -0,0 +1,462 @@ +//! Host-owned shell handles. Callers must authorize before calling `start`. +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::handlers::{ShellCommandHandler, ShellExecutionMode}; +use super::{ + ToolCallError, ToolContext, ToolProgress, ToolProgressSender, ToolResult, ToolResultContent, +}; + +const MAX_HANDLES: usize = 256; +const MAX_SESSION_HANDLES: usize = 32; +const MAX_OUTPUT_BYTES: usize = 256 * 1024; +const RETENTION: Duration = Duration::from_secs(30 * 60); + +/// Whether the authorization permits one retry after a structured sandbox denial. +#[derive(Clone, Copy)] +pub enum SandboxRetry { + Never, + ApprovedDenial, +} + +/// Execute an already-authorized command without changing its frozen sandbox decision. +pub async fn execute( + ctx: ToolContext, + input: Value, + retry: SandboxRetry, + progress: Option, + mode: ShellExecutionMode, +) -> Result { + if ctx.cancel_token.is_cancelled() { + return Err(ToolCallError::ExecutionFailed( + "originating turn cancelled".into(), + )); + } + let outcome = ShellCommandHandler::new() + .execute(ctx.clone(), input.clone(), progress.clone(), mode) + .await; + let denied = outcome.as_ref().is_ok_and(|output| { + let text = match &output.content { + ToolResultContent::Text(text) => text.as_str(), + ToolResultContent::Mixed { + text: Some(text), .. + } => text.as_str(), + ToolResultContent::Json(_) | ToolResultContent::Mixed { text: None, .. } => "", + }; + text.starts_with("SANDBOX_DENIED:") || output.result_summary.starts_with("SANDBOX_DENIED:") + }); + if denied && matches!(retry, SandboxRetry::ApprovedDenial) && !ctx.cancel_token.is_cancelled() { + let mut ctx = ctx; + ctx.sandbox_profile = Some("off".into()); + ShellCommandHandler::new() + .execute(ctx, input, progress, mode) + .await + } else { + outcome + } +} + +struct Entry { + session_id: String, + cancel: CancellationToken, + expiry_cancel: CancellationToken, + started: Instant, + finished: Option, + output: String, + output_truncated: bool, + exit_code: Option, + error: Option, +} +impl Entry { + fn snapshot(&self, handle_id: &str) -> Value { + json!({"status":"ok", "result": { + "handle_id":handle_id, "running":self.finished.is_none(), + "exit_code":self.exit_code, "output":self.output, "output_truncated":self.output_truncated, + "duration":self.finished.unwrap_or_else(Instant::now).duration_since(self.started).as_secs_f64(), + "error":self.error, + }}) + } + fn append(&mut self, text: &str) { + if self.output_truncated { + return; + } + let mut keep = text + .len() + .min(MAX_OUTPUT_BYTES.saturating_sub(self.output.len())); + while !text.is_char_boundary(keep) { + keep -= 1; + } + self.output.push_str(&text[..keep]); + if keep < text.len() { + const NOTICE: &str = "\n... [host bash output truncated]"; + let mut end = self.output.len().min(MAX_OUTPUT_BYTES - NOTICE.len()); + while !self.output.is_char_boundary(end) { + end -= 1; + } + self.output.truncate(end); + self.output.push_str(NOTICE); + self.output_truncated = true; + } + } +} +type Registry = HashMap>>; +static HANDLES: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); + +fn error(message: &str) -> Value { + json!({"status":"error", "error":message}) +} + +/// Schedule an authorized shell command. The child token retains originating turn cancellation. +pub fn start( + mut ctx: ToolContext, + input: Value, + retry: SandboxRetry, + kernel_lifetime: Option, +) -> Value { + if input.get("env").is_some() || input.get("persistent").and_then(Value::as_bool) == Some(true) + { + return error("host bash does not support env or persistent execution"); + } + if input.get("tty").and_then(Value::as_bool) == Some(true) { + return error("host bash handles do not support tty execution"); + } + if ctx.cancel_token.is_cancelled() { + return error("originating turn cancelled"); + } + if kernel_lifetime + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + return error("originating kernel stopped"); + } + // The lifetime token, not an Arc back to the kernel, owns this execution. + ctx.kernel = None; + ctx.cancel_token = ctx.cancel_token.child_token(); + let session_id = ctx.session_id.to_string(); + let handle_id = Uuid::new_v4().to_string(); + let entry = Arc::new(Mutex::new(Entry { + session_id: session_id.clone(), + cancel: ctx.cancel_token.clone(), + expiry_cancel: CancellationToken::new(), + started: Instant::now(), + finished: None, + output: String::new(), + output_truncated: false, + exit_code: None, + error: None, + })); + { + let mut handles = HANDLES.lock().unwrap(); + handles.retain(|_, entry| { + entry + .lock() + .unwrap() + .finished + .is_none_or(|done| done.elapsed() < RETENTION) + }); + loop { + let session_full = handles + .values() + .filter(|entry| entry.lock().unwrap().session_id == session_id) + .count() + >= MAX_SESSION_HANDLES; + if handles.len() < MAX_HANDLES && !session_full { + break; + } + let oldest = handles + .iter() + .filter_map(|(id, entry)| { + let state = entry.lock().unwrap(); + if session_full && state.session_id != session_id { + return None; + } + state.finished.map(|finished| (id.clone(), finished)) + }) + .min_by_key(|(_, finished)| *finished); + let Some((id, _)) = oldest else { + return error("host bash running handle limit reached"); + }; + if let Some(entry) = handles.remove(&id) { + entry.lock().unwrap().expiry_cancel.cancel(); + } + } + handles.insert(handle_id.clone(), Arc::clone(&entry)); + } + let initial = entry.lock().unwrap().snapshot(&handle_id); + let task_entry = Arc::clone(&entry); + tokio::spawn(async move { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let collect = async { + while let Some(event) = receiver.recv().await { + match event { + ToolProgress::OutputDelta { delta } => { + task_entry.lock().unwrap().append(&delta) + } + ToolProgress::StatusUpdate { .. } | ToolProgress::Completion { .. } => {} + } + } + }; + let cancel = ctx.cancel_token.clone(); + let execution = async { + let execution = execute( + ctx, + input, + retry, + Some(sender), + ShellExecutionMode::HostBackground, + ); + tokio::pin!(execution); + if let Some(lifetime) = kernel_lifetime { + tokio::select! { + biased; + _ = lifetime.cancelled() => { + cancel.cancel(); + execution.await + } + result = &mut execution => result, + } + } else { + execution.await + } + }; + let (outcome, ()) = tokio::join!(execution, collect); + { + let mut state = task_entry.lock().unwrap(); + match outcome { + Ok(result) => { + let (text, meta) = match result.content { + ToolResultContent::Text(text) => (Some(text), None), + ToolResultContent::Json(meta) => (None, Some(meta)), + ToolResultContent::Mixed { text, json } => (text, json), + }; + state.exit_code = meta + .as_ref() + .and_then(|meta| meta.get("exit")) + .and_then(Value::as_i64); + // Final executor formatting includes cancellation / timeout / sandbox errors. + if let Some(text) = text { + state.output.clear(); + state.output_truncated = false; + state.append(&text); + } + if !matches!( + result.structured_status, + super::ToolTerminalStatus::Completed + ) { + state.error = Some(if state.output.is_empty() { + result.result_summary + } else { + state.output.clone() + }); + } + } + Err(err) => state.error = Some(err.to_string()), + } + state.finished = Some(Instant::now()); + } + // Completed records expire even if the session never makes another request. + let expiry_cancel = task_entry.lock().unwrap().expiry_cancel.clone(); + tokio::select! { + _ = tokio::time::sleep(RETENTION) => {}, + _ = expiry_cancel.cancelled() => {}, + } + HANDLES.lock().unwrap().remove(&handle_id); + }); + initial +} + +/// Inspect or cancel only handles owned by this session; never accepts an OS PID. +pub fn control(session_id: &str, action: &str, params: &Value) -> Value { + let Some(id) = params.get("handle_id").and_then(Value::as_str) else { + return error("missing bash handle_id"); + }; + let mut handles = HANDLES.lock().unwrap(); + let Some(entry) = handles.get(id).cloned() else { + return error("unknown bash handle"); + }; + let state = entry.lock().unwrap(); + if state.session_id != session_id { + return error("unknown bash handle"); + } + match action { + "bash.poll" => state.snapshot(id), + "bash.kill" => { + state.cancel.cancel(); + state.snapshot(id) + } + "bash.forget" => { + if state.finished.is_none() { + return error("cannot forget a running bash handle"); + } + state.expiry_cancel.cancel(); + handles.remove(id); + json!({"status":"ok", "result":{"forgotten":true}}) + } + _ => error("unknown bash control action"), + } +} + +/// Cancel owned processes and release records when a kernel/session is torn down. +pub fn cancel_session(session_id: &str) { + HANDLES.lock().unwrap().retain(|_, entry| { + let state = entry.lock().unwrap(); + if state.session_id == session_id { + state.cancel.cancel(); + state.expiry_cancel.cancel(); + false + } else { + true + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn output_is_bounded_on_utf8_boundary() { + let mut entry = Entry { + session_id: "bounds".into(), + cancel: CancellationToken::new(), + expiry_cancel: CancellationToken::new(), + started: Instant::now(), + finished: None, + output: "x".repeat(MAX_OUTPUT_BYTES - 1), + output_truncated: false, + exit_code: None, + error: None, + }; + entry.append("é"); + assert_eq!(entry.output.len(), MAX_OUTPUT_BYTES); + assert!(entry.output.ends_with("[host bash output truncated]")); + assert!(entry.output_truncated); + let output = entry.output.clone(); + entry.append("abc"); + assert_eq!(entry.output, output); + } + + #[cfg(unix)] + #[tokio::test] + async fn origin_cancellation_finishes_started_handle() { + use crate::contracts::ToolBudgets; + use crate::invocation::ToolCallId; + use devo_protocol::{CollaborationMode, SessionId}; + use devo_tools::ToolAgentScope; + + let cancel = CancellationToken::new(); + let session = Uuid::new_v4().to_string(); + let ctx = ToolContext { + output_store: None, + tool_call_id: ToolCallId("host-bash-cancel".into()), + session_id: SessionId::from(session.clone()), + turn_id: None, + workspace_root: std::env::temp_dir(), + budgets: ToolBudgets { + output_limit_bytes: 32 * 1024, + wall_time_limit_ms: Some(10_000), + }, + cancel_token: cancel.clone(), + agent_scope: ToolAgentScope::Parent, + collaboration_mode: CollaborationMode::Build, + agent_coordinator: None, + client_filesystem: None, + file_read_ledger: None, + network_proxy: None, + network_no_proxy: None, + sandbox_profile: Some("off".into()), + sandbox_permission_overlay: None, + kernel: None, + python_cell_first_wait_ms: None, + python_cell_watch: None, + python_cell_completion: None, + session_dir: None, + }; + let started = start( + ctx, + json!({ + "command": "printf streaming; sleep 30", + "workdir": std::fs::canonicalize(std::env::temp_dir()) + .expect("canonical temp dir") + .to_string_lossy() + }), + SandboxRetry::Never, + None, + ); + let id = started["result"]["handle_id"] + .as_str() + .expect("handle id") + .to_string(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let snapshot = control(&session, "bash.poll", &json!({"handle_id": id})); + if snapshot["result"]["output"] + .as_str() + .is_some_and(|output| output.contains("streaming")) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("streaming output"); + cancel.cancel(); + let done = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let snapshot = control(&session, "bash.poll", &json!({"handle_id": id})); + if snapshot["result"]["running"] == false { + break snapshot; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancel completes"); + assert!( + done["result"]["output"] + .as_str() + .is_some_and(|output| output.contains("cancelled")), + "cancelled result: {done}" + ); + cancel_session(&session); + } + + #[test] + fn cancel_session_cancels_owned_token_and_removes_handle() { + let session = Uuid::new_v4().to_string(); + let id = Uuid::new_v4().to_string(); + let cancel = CancellationToken::new(); + HANDLES.lock().unwrap().insert( + id.clone(), + Arc::new(Mutex::new(Entry { + session_id: session.clone(), + cancel: cancel.clone(), + expiry_cancel: CancellationToken::new(), + started: Instant::now(), + finished: None, + output: String::new(), + output_truncated: false, + exit_code: None, + error: None, + })), + ); + cancel_session(&session); + assert_eq!( + ( + cancel.is_cancelled(), + control(&session, "bash.poll", &json!({"handle_id":id})) + ), + ( + true, + json!({"status":"error", "error":"unknown bash handle"}) + ) + ); + } +} diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index f6fc38f3..eb9c3a32 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -14,6 +14,7 @@ pub mod handler_kind { } pub mod handlers; mod hook_events; +pub mod host_bash; pub mod invocation { pub use devo_tools::invocation::*; } diff --git a/crates/core/src/tools/registry.rs b/crates/core/src/tools/registry.rs index 71df4bbc..0b026017 100644 --- a/crates/core/src/tools/registry.rs +++ b/crates/core/src/tools/registry.rs @@ -901,7 +901,6 @@ mod tests { ) } - /// Trace: L2-DES-SAFETY-002 /// Verifies: sandbox escalation fields are advertised on exec_command, shell_command, and bash. #[test] fn registry_adds_permission_fields_for_shell_family_command_tools() { @@ -914,7 +913,6 @@ mod tests { } } - /// Trace: L2-DES-SAFETY-002 /// Verifies: write_stdin and non-command tools do not advertise sandbox escalation fields. #[test] fn registry_omits_permission_fields_for_non_spawn_tools() { diff --git a/crates/core/src/tools/registry_plan.rs b/crates/core/src/tools/registry_plan.rs index efbcf0f4..378d0a79 100644 --- a/crates/core/src/tools/registry_plan.rs +++ b/crates/core/src/tools/registry_plan.rs @@ -895,7 +895,6 @@ mod tests { assert!(!provider_spec_names.contains(&"webfetch")); } - /// Trace: kernel-tools-only design (user direction, R27) /// Verifies: the stale host-side file tools (read/write/edit/find/grep) /// are gone from every registry plan — file work goes through the shell /// tool (and the python kernel's rlm front door on the kernel surface). @@ -919,7 +918,6 @@ mod tests { } } - /// Trace: prompt-payload audit (R27) /// Verifies: the shell description never steers the model toward the /// removed file tools — it used to instruct "Use Read (NOT cat/head/tail)" /// in kernel sessions where no such tool exists, sending the model on @@ -959,7 +957,6 @@ mod tests { } } - /// Trace: L2-DES-RLM-001 /// Verifies: the internal RLM plan retains Python, shell, plan, and question handlers. #[test] fn rlm_runtime_plan_includes_ipython_and_bash_handlers() { @@ -987,7 +984,6 @@ mod tests { ); } - /// Trace: L2-DES-RLM-001 /// Verifies: the discrete internal plan retains shell and patch handlers; the /// registry exposure policy hides their schemas from model requests. #[test] @@ -1012,7 +1008,6 @@ mod tests { ); } - /// Trace: L2-DES-RLM-001 /// Verifies: Rlm execution_surface selects the RLM root plan. #[test] fn rlm_surface_selects_rlm_root_plan() { diff --git a/crates/core/src/tools/router.rs b/crates/core/src/tools/router.rs index 695db8ae..c1285afd 100644 --- a/crates/core/src/tools/router.rs +++ b/crates/core/src/tools/router.rs @@ -19,6 +19,7 @@ use tracing::warn; use crate::invocation::ToolContent; use crate::registry::ToolRegistry; +use crate::shell_exec::{requested_workdir, resolve_shell_dirs}; use crate::tool_spec::ToolCapabilityTag; use crate::tools::deferred_loading::is_subagent_agent_coordination_tool; use devo_tools::AgentToolCoordinator; @@ -99,6 +100,12 @@ pub struct ToolRuntime { execution_options: ToolExecutionOptions, } +#[derive(Clone, Copy)] +enum ExecutionTarget { + Inline, + HostBash, +} + impl ToolRuntime { pub fn new(registry: Arc, permission: PermissionChecker) -> Self { ToolRuntime { @@ -254,10 +261,43 @@ impl ToolRuntime { self.context.agent_scope } + /// Authorize a host shell through the normal tool pipeline before returning a handle. + pub async fn start_host_bash( + &self, + request_id: String, + input: serde_json::Value, + ) -> ToolCallResult { + self.execute_single_target( + &ToolCall { + id: request_id, + name: "bash".to_string(), + input, + }, + &None, + ExecutionTarget::HostBash, + ) + .await + } + + /// Inspect or cancel shell handles scoped to this runtime's session. + pub fn control_host_bash(&self, action: &str, params: &serde_json::Value) -> serde_json::Value { + super::host_bash::control(self.context.session_id.as_str(), action, params) + } + pub(crate) async fn execute_single( &self, call: &ToolCall, on_progress: &Option, + ) -> ToolCallResult { + self.execute_single_target(call, on_progress, ExecutionTarget::Inline) + .await + } + + async fn execute_single_target( + &self, + call: &ToolCall, + on_progress: &Option, + target: ExecutionTarget, ) -> ToolCallResult { let tool_name = canonical_tool_name(&self.registry, &call.name); if self.context.agent_scope == ToolAgentScope::Subagent @@ -325,20 +365,12 @@ impl ToolRuntime { } } - // Sandbox temporarily disabled (SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED): - // spawn every tool process with full user permissions regardless of the - // session's configured, persisted, or live-updated profile. Restore the - // plain match below to re-enable enforcement. - let mut sandbox_profile = if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - None - } else { - match &self.context.sandbox_profile_live { - Some(live) => live - .lock() - .expect("sandbox profile live mutex poisoned") - .clone(), - None => self.context.sandbox_profile.clone(), - } + let mut sandbox_profile = match &self.context.sandbox_profile_live { + Some(live) => live + .lock() + .expect("sandbox profile live mutex poisoned") + .clone(), + None => self.context.sandbox_profile.clone(), }; let mut already_bypassed_sandbox = sandbox_profile_is_inactive(sandbox_profile.as_deref()); // `already_approved`: only user/session/auto-review approvals unlock @@ -465,7 +497,28 @@ impl ToolRuntime { } Err(crate::contracts::ToolCallError::Cancelled) } - result = tool.handle(build_ctx(sandbox_profile.clone()), input.clone(), progress_sender) => result, + result = async { + match target { + ExecutionTarget::Inline => tool.handle(build_ctx(sandbox_profile.clone()), input.clone(), progress_sender).await, + ExecutionTarget::HostBash => { + let retry = if !already_bypassed_sandbox && already_approved + && sandbox_permission_overlay.is_none() + && devo_sandbox::unsandboxed_execution_allowed(sandbox_profile.as_deref(), &self.context.cwd) + { + super::host_bash::SandboxRetry::ApprovedDenial + } else { + super::host_bash::SandboxRetry::Never + }; + let kernel_lifetime = self.context.kernel.as_ref().map(|kernel| kernel.lifetime_token()); + let reply = super::host_bash::start(build_ctx(sandbox_profile.clone()), input.clone(), retry, kernel_lifetime); + if reply["status"] == "error" { + Err(crate::contracts::ToolCallError::ExecutionFailed(reply["error"].as_str().unwrap_or("host bash start failed").to_string())) + } else { + Ok(crate::contracts::ToolResult::success(crate::contracts::ToolResultContent::Json(reply["result"].clone()), "host bash started")) + } + } + } + } => result, }; // UnlessTrusted-style failure upgrade (tool orchestrator): @@ -626,27 +679,38 @@ impl ToolRuntime { return Ok(None); } - let path = path_for_tool_input(tool_name, &call.input, &self.context.cwd); + // Shell approvals, command matching, and the child spawn must name the + // same canonical directory. Resolve relative workdirs against the + // session workspace, never the process cwd. + let approval_cwd = if matches!(tool_name, "bash" | "shell_command" | "exec_command") { + let requested = requested_workdir(&call.input) + .map_err(|error| format!("invalid workdir: {error}"))?; + resolve_shell_dirs(&self.context.cwd, requested) + .map_err(|error| format!("invalid workdir: {error}"))? + .cwd + } else { + self.context.cwd.clone() + }; + + let path = path_for_tool_input(tool_name, &call.input, &approval_cwd); let host = host_for_tool_input(tool_name, &call.input); let target = target_for_tool_input(tool_name, &call.input); let command_prefix = command_prefix_for_tool_input(tool_name, &call.input); let command_argv = command_str_for_tool_input(tool_name, &call.input).and_then(safe_shell_argv); let command_pattern = command_str_for_tool_input(tool_name, &call.input) - .and_then(|command| generalize_command_pattern(command, &self.context.cwd)); + .and_then(|command| generalize_command_pattern(command, &approval_cwd)); + let action_summary = + crate::tool_summary::tool_summary(tool_name, &call.input, &approval_cwd); Ok(Some(ToolPermissionRequest { tool_call_id: call.id.clone(), tool_name: tool_name.to_string(), input: call.input.clone(), - cwd: self.context.cwd.clone(), + cwd: approval_cwd, session_id: self.context.session_id, turn_id: self.context.turn_id, resource, - action_summary: crate::tool_summary::tool_summary( - tool_name, - &call.input, - &self.context.cwd, - ), + action_summary, justification: justification_for_tool_input(&call.input), path, host, diff --git a/crates/core/src/tools/router_tests.rs b/crates/core/src/tools/router_tests.rs index 8ea9297d..efd12fb4 100644 --- a/crates/core/src/tools/router_tests.rs +++ b/crates/core/src/tools/router_tests.rs @@ -543,7 +543,6 @@ fn command_prefix_rejects_complex_shell_features() { } } -/// Trace: L2-DES-SAFETY-002 /// Verifies: prefix_rule on exec_command, shell_command, and bash overrides the derived command prefix. #[test] fn shell_family_prefix_rule_overrides_derived_prefix() { @@ -563,7 +562,6 @@ fn shell_family_prefix_rule_overrides_derived_prefix() { } } -/// Trace: L2-DES-SAFETY-002 /// Verifies: a banned prefix_rule is not offered for exec_command, shell_command, or bash. #[test] fn shell_family_banned_prefix_rule_is_not_offered() { @@ -690,7 +688,6 @@ fn command_pattern_match_cases() { } } -/// Trace: L2-DES-SAFETY-002 /// Verifies: sandbox permission inputs are classified into explicit tiers. #[test] fn explicit_sandbox_permissions_are_classified() { @@ -748,7 +745,6 @@ fn explicit_sandbox_permissions_are_classified() { ); } -/// Trace: L2-DES-SAFETY-002 /// Verifies: an all-unchanged additional_permissions object (codex-style /// clients always echo the full object) is semantically Default, not an /// error — with `use_default` mode, no mode at all, or an empty object. @@ -781,7 +777,6 @@ fn all_unchanged_additional_permissions_are_default() { } } -/// Trace: L2-DES-SAFETY-002 /// Verifies: malformed or ambiguous sandbox requests fail closed. #[test] fn sandbox_permission_request_rejects_invalid_inputs() { @@ -805,7 +800,6 @@ fn sandbox_permission_request_rejects_invalid_inputs() { } } -/// Trace: L2-DES-SAFETY-002 /// Verifies: permission-cache keys use normalized tiers and path sets. #[test] fn sandbox_permission_cache_key_normalizes_additional_permissions() { @@ -950,15 +944,79 @@ fn sandbox_runtime( (runtime, attempts) } +#[test] +fn shell_permission_request_uses_resolved_child_cwd() { + let workspace = tempfile::tempdir().expect("workspace"); + let child = workspace.path().join("child"); + std::fs::create_dir(&child).expect("child cwd"); + let outside = tempfile::tempdir().expect("outside cwd"); + let attempts = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = ToolRegistryBuilder::new(); + let shell = shell_spec(); + register( + &mut builder, + "shell_command", + Arc::new(SandboxAwareTool { + spec: shell.clone(), + attempts, + succeed_outside: true, + }), + shell, + ); + let mut runtime = ToolRuntime::new_without_permissions(Arc::new(builder.build())); + runtime.context.cwd = workspace.path().to_path_buf(); + + let relative = runtime + .permission_request_for_call( + &call( + "relative", + "shell_command", + serde_json::json!({"command": "echo hi", "workdir": "child"}), + ), + "shell_command", + ) + .expect("relative request") + .expect("mutating shell request"); + assert_eq!( + relative.cwd, + std::fs::canonicalize(&child).expect("child canonical") + ); + + let absolute = runtime + .permission_request_for_call( + &call( + "absolute", + "shell_command", + serde_json::json!({ + "command": "echo hi", + "workdir": outside.path().to_str().expect("utf8 outside path") + }), + ), + "shell_command", + ) + .expect("absolute request") + .expect("mutating shell request"); + assert_eq!( + absolute.cwd, + std::fs::canonicalize(outside.path()).expect("outside canonical") + ); + + let invalid = runtime.permission_request_for_call( + &call( + "invalid", + "shell_command", + serde_json::json!({"command": "echo hi", "workdir": "missing"}), + ), + "shell_command", + ); + assert!( + invalid.is_err(), + "missing workdir must fail before approval" + ); +} + #[tokio::test] async fn execute_single_retries_without_sandbox_when_already_approved() { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - // The router never profiles spawns while the sandbox is disabled, so - // the SANDBOX_DENIED retry ladder cannot be exercised. Kept for the - // post-revert contract. - eprintln!("skip: OS sandbox temporarily disabled"); - return; - } let permission_checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let permission_checks_for_checker = Arc::clone(&permission_checks); let attempts = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -1008,11 +1066,6 @@ async fn execute_single_retries_without_sandbox_when_already_approved() { #[tokio::test] async fn execute_single_does_not_silent_unsandbox_without_approval() { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - // See execute_single_retries_without_sandbox_when_already_approved. - eprintln!("skip: OS sandbox temporarily disabled"); - return; - } let (runtime, attempts) = sandbox_runtime(false, false, "workspace", None); let result = runtime .execute_single( @@ -1038,11 +1091,6 @@ async fn execute_single_does_not_silent_unsandbox_without_approval() { #[tokio::test] async fn execute_single_skips_unsandbox_retry_when_profile_has_deny_read() { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - // See execute_single_retries_without_sandbox_when_already_approved. - eprintln!("skip: OS sandbox temporarily disabled"); - return; - } let temp = tempfile::tempdir().expect("tempdir"); let workspace = temp.path(); let config_dir = workspace.join(".devo"); diff --git a/crates/core/src/tools/shell_exec/launch.rs b/crates/core/src/tools/shell_exec/launch.rs index e85ce7f9..b1680a01 100644 --- a/crates/core/src/tools/shell_exec/launch.rs +++ b/crates/core/src/tools/shell_exec/launch.rs @@ -48,7 +48,10 @@ pub(crate) struct SandboxLaunchPlan { child_apply_plan: Option, #[cfg_attr(not(unix), allow(dead_code))] sandbox_profile: Option, + /// Trusted workspace root used for profile/overlay resolution. #[cfg_attr(not(unix), allow(dead_code))] + sandbox_workspace: PathBuf, + /// Requested child working directory. workdir: PathBuf, } @@ -57,6 +60,7 @@ impl SandboxLaunchPlan { pub(crate) fn prepare_pipe( sandbox_profile: Option<&str>, sandbox_permission_overlay: Option<&devo_sandbox::SandboxPermissionOverlay>, + sandbox_workspace: &Path, workdir: &Path, shell: &ShellSpec, command: &str, @@ -64,11 +68,11 @@ impl SandboxLaunchPlan { Self::prepare( sandbox_profile, sandbox_permission_overlay, + sandbox_workspace, workdir, shell, command, devo_sandbox::WrapMode::PipeComposed, - /*attach_child_apply*/ true, ) } @@ -76,6 +80,7 @@ impl SandboxLaunchPlan { pub(crate) fn prepare_pty( sandbox_profile: Option<&str>, sandbox_permission_overlay: Option<&devo_sandbox::SandboxPermissionOverlay>, + sandbox_workspace: &Path, workdir: &Path, shell: &ShellSpec, command: &str, @@ -83,23 +88,26 @@ impl SandboxLaunchPlan { Self::prepare( sandbox_profile, sandbox_permission_overlay, + sandbox_workspace, workdir, shell, command, devo_sandbox::WrapMode::PtyOnly, - /*attach_child_apply*/ false, ) } fn prepare( sandbox_profile: Option<&str>, sandbox_permission_overlay: Option<&devo_sandbox::SandboxPermissionOverlay>, + sandbox_workspace: &Path, workdir: &Path, shell: &ShellSpec, command: &str, mode: devo_sandbox::WrapMode, - attach_child_apply: bool, ) -> Result { + // PTY wrappers carry the full policy; pipe spawns apply the child plan. + #[cfg(unix)] + let attach_child_apply = matches!(mode, devo_sandbox::WrapMode::PipeComposed); // Platform wrap decision (details in module docs): // - macOS → sandbox-exec / Seatbelt // - Linux → optional bwrap / linux-sandbox helper (composes with pre_exec) @@ -107,7 +115,7 @@ impl SandboxLaunchPlan { #[cfg(unix)] let wrap = match devo_sandbox::wrap_command_for_profile_with_overlay( sandbox_profile, - workdir, + sandbox_workspace, mode, &devo_sandbox::SandboxLogger::new(), sandbox_permission_overlay, @@ -125,14 +133,13 @@ impl SandboxLaunchPlan { let windows_launch = try_windows_sandbox_launch( sandbox_profile, sandbox_permission_overlay, + sandbox_workspace, workdir, shell, command, )?; #[cfg(unix)] let _ = (shell, command); - #[cfg(not(unix))] - let _ = attach_child_apply; #[cfg(unix)] let child_apply_plan = if attach_child_apply && wrap.requires_child_apply() { @@ -141,7 +148,7 @@ impl SandboxLaunchPlan { // policy. Otherwise resolve Landlock/seccomp for `pre_exec`. match devo_util_process::sandbox::resolve_profile_for_spawn_with_overlay( sandbox_profile, - workdir, + sandbox_workspace, sandbox_permission_overlay, ) { Ok(plan) => plan, @@ -160,6 +167,7 @@ impl SandboxLaunchPlan { #[cfg(unix)] child_apply_plan, sandbox_profile: sandbox_profile.map(str::to_string), + sandbox_workspace: sandbox_workspace.to_path_buf(), workdir: workdir.to_path_buf(), }) } @@ -246,11 +254,12 @@ impl SandboxLaunchPlan { #[cfg(unix)] for (key, value) in devo_sandbox::proxy_env_for_sandbox_profile( self.sandbox_profile.as_deref(), - &self.workdir, + &self.sandbox_workspace, ) { child.env(key, value); } + child.current_dir(&self.workdir); child } @@ -305,11 +314,12 @@ impl SandboxLaunchPlan { #[cfg(unix)] for (key, value) in devo_sandbox::proxy_env_for_sandbox_profile( self.sandbox_profile.as_deref(), - &self.workdir, + &self.sandbox_workspace, ) { builder.env(key, value); } + builder.cwd(&self.workdir); builder } } @@ -324,6 +334,7 @@ impl SandboxLaunchPlan { fn try_windows_sandbox_launch( sandbox_profile: Option<&str>, sandbox_permission_overlay: Option<&devo_sandbox::SandboxPermissionOverlay>, + sandbox_workspace: &Path, workdir: &Path, shell: &ShellSpec, command: &str, @@ -336,10 +347,10 @@ fn try_windows_sandbox_launch( let profile_name = profile .parse::() .map_err(|error| format!("invalid sandbox profile '{profile}': {error}"))?; - let config = devo_sandbox::load_sandbox_config(workdir) + let config = devo_sandbox::load_sandbox_config(sandbox_workspace) .map_err(|error| format!("failed to set up Windows sandbox: {error}"))?; let mut resolved = profile_name - .resolve_profile(workdir, &config) + .resolve_profile(sandbox_workspace, &config) .map_err(|error| format!("failed to set up Windows sandbox: {error}"))?; if let Some(overlay) = sandbox_permission_overlay { resolved @@ -364,7 +375,6 @@ fn try_windows_sandbox_launch( writable_roots: resolved.read_write, deny_read: resolved.deny, restrict_network: resolved.restrict_network, - session_credential_sid: None, env_extra: Vec::new(), }; match devo_windows_sandbox::prepare_windows_sandbox_launch(&request) { @@ -388,3 +398,28 @@ fn try_windows_sandbox_launch( Err(error) => Err(format!("failed to set up Windows sandbox: {error}")), } } + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::*; + + #[cfg(unix)] + #[test] + fn sandbox_workspace_is_independent_of_child_cwd() { + let workspace = tempfile::tempdir().expect("workspace"); + let child = tempfile::tempdir().expect("child cwd"); + let shell = crate::shell_exec::resolve::resolve_shell(Some("bash"), false); + let plan = SandboxLaunchPlan::prepare_pipe( + Some("workspace"), + None, + workspace.path(), + child.path(), + &shell, + "echo hi", + ) + .expect("prepare pipe plan"); + assert_eq!(plan.sandbox_workspace, workspace.path()); + assert_eq!(plan.workdir, child.path()); + } +} diff --git a/crates/core/src/tools/shell_exec/mod.rs b/crates/core/src/tools/shell_exec/mod.rs index 71814a5f..109459c1 100644 --- a/crates/core/src/tools/shell_exec/mod.rs +++ b/crates/core/src/tools/shell_exec/mod.rs @@ -17,7 +17,7 @@ mod pipe_cancellation_tests; #[cfg(test)] mod tests; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use devo_protocol::approx_bytes_for_tokens; use tokio_util::sync::CancellationToken; @@ -36,10 +36,83 @@ use pty::run_with_pty; use resolve::ResolvedShellRun; pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000; +/// Host-background execution waits for exit or cancellation unless a timeout is supplied. +pub(crate) const NO_TIMEOUT_MS: u64 = u64::MAX; pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 1_000; pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000; const TRUNCATED_SUFFIX: &str = "\n\n... [truncated]"; +/// Resolved workspace/child-cwd pair for one shell request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShellDirs { + /// Canonical trusted workspace root used for sandbox resolution. + pub workspace: PathBuf, + /// Canonical child working directory used for process spawn. + pub cwd: PathBuf, +} + +/// Extract the optional requested `workdir` from tool input. +/// +/// Absent and `null` mean "use the workspace". A non-string value is rejected +/// so approval and execution fail closed on malformed input. +pub(crate) fn requested_workdir(input: &serde_json::Value) -> Result, String> { + match input.get("workdir") { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(value)) => Ok(Some(value.as_str())), + Some(_) => Err("workdir must be a string".to_string()), + } +} + +/// Resolve the trusted sandbox workspace and the requested child directory. +/// +/// Relative workdirs are resolved against `workspace_root` (never the process +/// cwd), and both paths are canonicalized so the approval request, sandbox +/// profile, and child spawn all name the same directories. +pub(crate) fn resolve_shell_dirs( + workspace_root: &Path, + requested: Option<&str>, +) -> Result { + // Tests and standalone callers may leave the workspace unset. Fall back to + // the process cwd only for that empty sentinel; a configured session + // workspace is always used as the trust root. + let workspace_root = if workspace_root.as_os_str().is_empty() { + Path::new(".") + } else { + workspace_root + }; + let workspace = std::fs::canonicalize(workspace_root).map_err(|error| { + format!( + "workspace root {} is not accessible: {error}", + workspace_root.display() + ) + })?; + let candidate = match requested { + Some(raw) if !raw.is_empty() => { + let path = Path::new(raw); + if path.is_absolute() { + path.to_path_buf() + } else { + workspace.join(path) + } + } + Some(_) => return Err("workdir must not be empty".to_string()), + None => workspace.clone(), + }; + let cwd = std::fs::canonicalize(&candidate).map_err(|error| { + format!( + "working directory {} is not accessible: {error}", + candidate.display() + ) + })?; + if !cwd.is_dir() { + return Err(format!( + "working directory is not a directory: {}", + cwd.display() + )); + } + Ok(ShellDirs { workspace, cwd }) +} + /// Input to [`execute_shell_command`]: the caller's raw request before shell /// resolution or pipe/PTY branching. /// @@ -50,6 +123,12 @@ pub(crate) struct ShellExecRequest { pub(crate) output_capture: Option>, pub command: String, + /// Trusted workspace used to resolve the sandbox profile, overlay, and + /// writable roots. `None` preserves legacy callers by falling back to + /// `workdir`; production callers pass the session workspace. + pub sandbox_workspace: Option, + /// Requested child working directory. May differ from + /// `sandbox_workspace` without widening the sandbox grants. pub workdir: PathBuf, pub description: String, /// Optional shell name/alias (`bash`, `pwsh`, `cmd`, …). `None` uses the @@ -62,6 +141,8 @@ pub(crate) struct ShellExecRequest { pub timeout_ms: u64, pub yield_time_ms: u64, pub max_output_tokens: usize, + /// Optional bound on live progress bytes per stream; final capture is independent. + pub progress_limit_bytes: Option, pub sandbox_profile: Option, pub sandbox_permission_overlay: Option, } @@ -78,6 +159,7 @@ pub(crate) async fn execute_shell_command( let ShellExecRequest { output_capture, command, + sandbox_workspace, workdir, description, shell_override, @@ -86,6 +168,7 @@ pub(crate) async fn execute_shell_command( timeout_ms, yield_time_ms, max_output_tokens, + progress_limit_bytes, sandbox_profile, sandbox_permission_overlay, } = request; @@ -96,6 +179,7 @@ pub(crate) async fn execute_shell_command( workdir.display() ))); } + let sandbox_workspace = sandbox_workspace.unwrap_or_else(|| workdir.clone()); let shell = resolve_shell(shell_override.as_deref(), login); let command_to_run = normalize_command_for_shell(&shell, command); @@ -103,11 +187,13 @@ pub(crate) async fn execute_shell_command( output_capture, shell, command_to_run, + sandbox_workspace, workdir, description, timeout_ms, yield_time_ms, max_output_tokens, + progress_limit_bytes, sandbox_profile, sandbox_permission_overlay, }; diff --git a/crates/core/src/tools/shell_exec/pipe.rs b/crates/core/src/tools/shell_exec/pipe.rs index 7e6fa0ee..28a2a419 100644 --- a/crates/core/src/tools/shell_exec/pipe.rs +++ b/crates/core/src/tools/shell_exec/pipe.rs @@ -31,11 +31,13 @@ pub(crate) async fn run_with_pipes( output_capture, shell, command_to_run, + sandbox_workspace, workdir, description, timeout_ms, yield_time_ms, max_output_tokens, + progress_limit_bytes, sandbox_profile, sandbox_permission_overlay, } = run; @@ -45,6 +47,7 @@ pub(crate) async fn run_with_pipes( let plan = match SandboxLaunchPlan::prepare_pipe( sandbox_profile.as_deref(), sandbox_permission_overlay.as_ref(), + &sandbox_workspace, &workdir, &shell, &command_to_run, @@ -91,10 +94,15 @@ pub(crate) async fn run_with_pipes( let stdout_task = spawn_stream_reader( child.0.stdout().take(), progress.clone(), + progress_limit_bytes, + output_capture.clone(), + ); + let stderr_task = spawn_stream_reader( + child.0.stderr().take(), + progress, + progress_limit_bytes, output_capture.clone(), ); - let stderr_task = - spawn_stream_reader(child.0.stderr().take(), progress, output_capture.clone()); enum WaitOutcome { Exited(std::process::ExitStatus), @@ -109,7 +117,8 @@ pub(crate) async fn run_with_pipes( Err(error) => WaitOutcome::WaitError(error), }, _ = cancel_token.cancelled() => WaitOutcome::Cancelled, - _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => WaitOutcome::TimedOut, + _ = async { tokio::time::sleep(Duration::from_millis(timeout_ms)).await }, + if timeout_ms != super::NO_TIMEOUT_MS => WaitOutcome::TimedOut, }; // Measure at the moment the outcome was decided, before teardown waits // (kill-and-wait) inflate the span the model is told about. @@ -121,7 +130,6 @@ pub(crate) async fn run_with_pipes( } WaitOutcome::Exited(_) | WaitOutcome::WaitError(_) => {} } - let stdout = String::from_utf8_lossy(&stdout_task.await.unwrap_or_default()).into_owned(); let stderr = String::from_utf8_lossy(&stderr_task.await.unwrap_or_default()).into_owned(); let mut result_text = truncate_output(&merge_streams(&stdout, &stderr), max_output_tokens); @@ -197,6 +205,7 @@ pub(crate) async fn run_with_pipes( fn spawn_stream_reader( pipe: Option, progress: Option, + progress_limit_bytes: Option, output_capture: Option>, ) -> tokio::task::JoinHandle> where @@ -212,7 +221,9 @@ where match pipe.read(&mut chunk).await { Ok(0) => break, Ok(n) => { - if let Some(ref sender) = progress { + if let Some(ref sender) = progress + && progress_limit_bytes.is_none_or(|limit| buffer.len() < limit) + { let _ = sender.send(String::from_utf8_lossy(&chunk[..n]).into_owned()); } if let Some(Ok(capture)) = &output_capture { @@ -231,8 +242,56 @@ where }) } +/// Terminate a cancelled/timed-out command and reap its direct child. +/// +/// The caller owns the only `Child::wait` consumer for this process. On Unix, +/// the leader is intentionally kept unreaped while `waitid(WNOWAIT)` observes +/// its exit, so the pid/pgid is reserved before the final group signal. If the +/// observation fails or the leader was already reaped, the final signal is +/// skipped: a reaped numeric pgid may have been reused. +/// +/// The `spawn_blocking` waitid helper is signal-free. If this future is +/// dropped, the helper may finish unobserved; `PipeChild::Drop` still performs +/// only the initial group kill while a live child id exists. async fn kill_and_wait(child: &mut super::pipe_child::PipeChild) { + #[cfg(unix)] + let process_group_id = child.process_id(); child.terminate_tree(); + #[cfg(unix)] + if let Some(process_group_id) = process_group_id { + let observed = tokio::task::spawn_blocking(move || { + devo_util_process::process_group::wait_exit_without_reaping(process_group_id) + }) + .await; + match observed { + Ok(Ok(true)) => { + // The leader is an unreaped zombie: its pid/pgid is still + // reserved, so this final group signal cannot target a reused + // numeric pgid. It covers descendants forked before exit. + child.terminate_tree(); + } + Ok(Ok(false)) => { + tracing::warn!( + pid = process_group_id, + "child exit was already reaped before the final sandbox group signal; skipping" + ); + } + Ok(Err(error)) => { + tracing::warn!( + pid = process_group_id, + %error, + "could not reserve child exit for the final sandbox group signal; skipping" + ); + } + Err(error) => { + tracing::warn!( + pid = process_group_id, + %error, + "waitid task failed before the final sandbox group signal; skipping" + ); + } + } + } let _ = child.0.wait().await; } diff --git a/crates/core/src/tools/shell_exec/pipe_cancellation_tests.rs b/crates/core/src/tools/shell_exec/pipe_cancellation_tests.rs index f1e9c2fd..7ce692d2 100644 --- a/crates/core/src/tools/shell_exec/pipe_cancellation_tests.rs +++ b/crates/core/src/tools/shell_exec/pipe_cancellation_tests.rs @@ -13,6 +13,7 @@ async fn dropping_pipe_call_stops_descendant_work() { output_capture: None, command: command.into(), workdir: directory.path().to_path_buf(), + sandbox_workspace: None, description: "cancellation regression".into(), shell_override: None, tty: false, @@ -20,6 +21,7 @@ async fn dropping_pipe_call_stops_descendant_work() { timeout_ms: 30000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, diff --git a/crates/core/src/tools/shell_exec/pipe_child.rs b/crates/core/src/tools/shell_exec/pipe_child.rs index 96d0052c..31ad90a7 100644 --- a/crates/core/src/tools/shell_exec/pipe_child.rs +++ b/crates/core/src/tools/shell_exec/pipe_child.rs @@ -5,6 +5,12 @@ use process_wrap::tokio::ChildWrapper; pub(super) struct PipeChild(pub(super) Box); impl PipeChild { + /// OS process id of the direct child while it is still live. + #[cfg(unix)] + pub(super) fn process_id(&self) -> Option { + self.0.id() + } + pub(super) fn terminate_tree(&mut self) { #[cfg(unix)] if let Some(pid) = self.0.id() { diff --git a/crates/core/src/tools/shell_exec/pty.rs b/crates/core/src/tools/shell_exec/pty.rs index 80aeed33..57773f34 100644 --- a/crates/core/src/tools/shell_exec/pty.rs +++ b/crates/core/src/tools/shell_exec/pty.rs @@ -83,11 +83,13 @@ pub(crate) async fn run_with_pty( output_capture, shell, command_to_run, + sandbox_workspace, workdir, description, timeout_ms, yield_time_ms, max_output_tokens, + progress_limit_bytes: _, sandbox_profile, sandbox_permission_overlay, } = run; @@ -107,6 +109,7 @@ pub(crate) async fn run_with_pty( let plan = match SandboxLaunchPlan::prepare_pty( sandbox_profile.as_deref(), sandbox_permission_overlay.as_ref(), + &sandbox_workspace, &workdir, &shell, &command_to_run, diff --git a/crates/core/src/tools/shell_exec/resolve.rs b/crates/core/src/tools/shell_exec/resolve.rs index 3821ea65..07e0dabc 100644 --- a/crates/core/src/tools/shell_exec/resolve.rs +++ b/crates/core/src/tools/shell_exec/resolve.rs @@ -94,11 +94,14 @@ pub(crate) struct ResolvedShellRun { Option>, pub(crate) shell: ShellSpec, pub(crate) command_to_run: String, + /// Trusted workspace rooted in the session, used for sandbox resolution. + pub(crate) sandbox_workspace: PathBuf, pub(crate) workdir: PathBuf, pub(crate) description: String, pub(crate) timeout_ms: u64, pub(crate) yield_time_ms: u64, pub(crate) max_output_tokens: usize, + pub(crate) progress_limit_bytes: Option, pub(crate) sandbox_profile: Option, pub(crate) sandbox_permission_overlay: Option, } diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs index a5672ba1..d940dbf8 100644 --- a/crates/core/src/tools/shell_exec/tests.rs +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -17,6 +17,7 @@ async fn execute_shell_command_non_tty_sends_progress() { output_capture: None, command: cmd.to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "test".into(), shell_override: None, tty: false, @@ -24,6 +25,7 @@ async fn execute_shell_command_non_tty_sends_progress() { timeout_ms: 5000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -47,6 +49,7 @@ async fn execute_shell_command_progress_none_does_not_crash() { output_capture: None, command: cmd.to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "test".into(), shell_override: None, tty: false, @@ -54,6 +57,7 @@ async fn execute_shell_command_progress_none_does_not_crash() { timeout_ms: 5000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -80,6 +84,7 @@ async fn execute_shell_command_cancels_non_tty_process() { output_capture: None, command: "echo cancelled_output; sleep 5; echo should_not_print".to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "cancel test".into(), shell_override: None, tty: false, @@ -87,6 +92,7 @@ async fn execute_shell_command_cancels_non_tty_process() { timeout_ms: 10_000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -132,6 +138,7 @@ async fn execute_shell_command_cancels_tty_process() { output_capture: None, command: "echo cancelled_output; sleep 5; echo should_not_print".to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "pty cancel test".into(), shell_override: Some("bash".to_string()), tty: true, @@ -139,6 +146,7 @@ async fn execute_shell_command_cancels_tty_process() { timeout_ms: 10_000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -177,6 +185,7 @@ async fn execute_shell_command_pty_times_out_reports_decision_instant_duration() output_capture: None, command: "echo before_timeout; sleep 5".to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "pty timeout test".into(), shell_override: Some("bash".to_string()), tty: true, @@ -184,6 +193,7 @@ async fn execute_shell_command_pty_times_out_reports_decision_instant_duration() timeout_ms: 200, yield_time_ms: 50, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -247,6 +257,7 @@ async fn aborting_tty_command_kills_pty_child() { output_capture: None, command, workdir: temp_dir.path().to_path_buf(), + sandbox_workspace: None, description: "abort PTY test".into(), shell_override: Some("bash".to_string()), tty: true, @@ -254,6 +265,7 @@ async fn aborting_tty_command_kills_pty_child() { timeout_ms: 10_000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -286,6 +298,7 @@ async fn execute_shell_command_success_metadata_is_mixed() { output_capture: None, command: "echo metadata_test".to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "metadata test".into(), shell_override: None, tty: false, @@ -293,6 +306,7 @@ async fn execute_shell_command_success_metadata_is_mixed() { timeout_ms: 5000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -341,6 +355,7 @@ async fn execute_shell_command_error_output_is_text_only() { output_capture: None, command: "exit 7".to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "error test".into(), shell_override: None, tty: false, @@ -348,6 +363,7 @@ async fn execute_shell_command_error_output_is_text_only() { timeout_ms: 5000, yield_time_ms: 100, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -392,6 +408,7 @@ async fn execute_shell_command_pipe_times_out() { output_capture: None, command: "echo before_timeout; sleep 5".to_string(), workdir: std::env::current_dir().unwrap_or_default(), + sandbox_workspace: None, description: "timeout test".into(), shell_override: None, tty: false, @@ -399,6 +416,7 @@ async fn execute_shell_command_pipe_times_out() { timeout_ms: 200, yield_time_ms: 50, max_output_tokens: 100, + progress_limit_bytes: None, sandbox_profile: None, sandbox_permission_overlay: None, }, @@ -450,11 +468,25 @@ fn macos_pipe_and_pty_launch_plans_wrap_via_sandbox_exec() { for (label, plan) in [ ( "pipe", - SandboxLaunchPlan::prepare_pipe(Some("wrapdeny"), None, &workspace, &shell, "echo hi"), + SandboxLaunchPlan::prepare_pipe( + Some("wrapdeny"), + None, + &workspace, + &workspace, + &shell, + "echo hi", + ), ), ( "pty", - SandboxLaunchPlan::prepare_pty(Some("wrapdeny"), None, &workspace, &shell, "echo hi"), + SandboxLaunchPlan::prepare_pty( + Some("wrapdeny"), + None, + &workspace, + &workspace, + &shell, + "echo hi", + ), ), ] { let plan = plan.unwrap_or_else(|error| panic!("{label} prepare failed: {error}")); @@ -477,7 +509,7 @@ fn macos_pipe_and_pty_launch_plans_wrap_via_sandbox_exec() { fn windows_inactive_profile_prepare_pipe_builds_direct_command() { let workdir = std::env::current_dir().unwrap_or_default(); let shell = resolve_shell(None, false); - let plan = SandboxLaunchPlan::prepare_pipe(None, None, &workdir, &shell, "echo hi") + let plan = SandboxLaunchPlan::prepare_pipe(None, None, &workdir, &workdir, &shell, "echo hi") .expect("inactive profile should prepare"); assert!(matches!(plan.wrap(), devo_sandbox::SandboxWrap::None)); let _cmd = plan.build_tokio_command(&shell, "echo hi"); @@ -488,7 +520,7 @@ fn windows_inactive_profile_prepare_pipe_builds_direct_command() { fn windows_inactive_profile_prepare_pty_builds_builder() { let workdir = std::env::current_dir().unwrap_or_default(); let shell = resolve_shell(None, false); - let plan = SandboxLaunchPlan::prepare_pty(None, None, &workdir, &shell, "echo hi") + let plan = SandboxLaunchPlan::prepare_pty(None, None, &workdir, &workdir, &shell, "echo hi") .expect("inactive profile should prepare"); assert!(matches!(plan.wrap(), devo_sandbox::SandboxWrap::None)); let _builder = plan.build_pty_command_builder(&shell, "echo hi"); @@ -611,3 +643,40 @@ fn merge_streams_combines_stdout_and_stderr() { fn merge_streams_no_output() { assert_eq!(super::pipe::merge_streams("", ""), ""); } + +#[test] +fn resolve_shell_dirs_keeps_workspace_trusted_for_relative_and_absolute_cwd() { + let workspace = tempfile::tempdir().expect("workspace"); + let nested = workspace.path().join("nested"); + std::fs::create_dir(&nested).expect("nested cwd"); + let outside = tempfile::tempdir().expect("outside cwd"); + let workspace_canonical = std::fs::canonicalize(workspace.path()).expect("workspace canonical"); + let nested_canonical = std::fs::canonicalize(&nested).expect("nested canonical"); + let outside_canonical = std::fs::canonicalize(outside.path()).expect("outside canonical"); + + let relative = super::resolve_shell_dirs(workspace.path(), Some("nested")) + .expect("relative workdir resolves"); + assert_eq!(relative.workspace, workspace_canonical); + assert_eq!(relative.cwd, nested_canonical); + + let absolute = super::resolve_shell_dirs( + workspace.path(), + Some(outside.path().to_str().expect("utf8 outside path")), + ) + .expect("absolute workdir resolves"); + assert_eq!(absolute.workspace, workspace_canonical); + assert_eq!(absolute.cwd, outside_canonical); +} + +#[test] +fn requested_workdir_rejects_non_string_values() { + assert_eq!(super::requested_workdir(&serde_json::json!({})), Ok(None)); + assert_eq!( + super::requested_workdir(&serde_json::json!({"workdir": "child"})), + Ok(Some("child")) + ); + assert_eq!( + super::requested_workdir(&serde_json::json!({"workdir": 42})), + Err("workdir must be a string".to_string()) + ); +} diff --git a/crates/core/src/tools/unified_exec/process.rs b/crates/core/src/tools/unified_exec/process.rs index 1a3a3e31..9c6e3028 100644 --- a/crates/core/src/tools/unified_exec/process.rs +++ b/crates/core/src/tools/unified_exec/process.rs @@ -1,7 +1,7 @@ use devo_protocol::approx_bytes_for_tokens; use devo_protocol::approx_tokens_from_byte_count; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; @@ -163,6 +163,9 @@ pub struct SandboxExecutionOptions { pub sandbox_profile: Option, /// Per-invocation permissions merged into the named profile. pub sandbox_overlay: Option, + /// Trusted session workspace used for profile/overlay resolution. + /// `None` preserves legacy callers by falling back to the execution cwd. + pub sandbox_workspace: Option, } impl UnifiedExecProcess { @@ -197,6 +200,7 @@ impl UnifiedExecProcess { output_capture: None, sandbox_profile, sandbox_overlay: None, + sandbox_workspace: None, }, ) .await @@ -211,29 +215,29 @@ impl UnifiedExecProcess { tty: bool, sandbox: SandboxExecutionOptions, ) -> Result<(Self, broadcast::Receiver>), String> { - let SandboxExecutionOptions { - output_capture, - sandbox_profile, - sandbox_overlay, - } = sandbox; + let mut sandbox = sandbox; + let output_capture = sandbox.output_capture.take(); + let sandbox_workspace = sandbox + .sandbox_workspace + .clone() + .unwrap_or_else(|| cwd.to_path_buf()); let shell_spec = resolve_shell(shell, login); let command = command_for_shell(cmd, &shell_spec); let mut args = shell_spec.args.clone(); args.push(command); - let env = unified_exec_env(cwd, sandbox_profile.as_deref()); + let env = unified_exec_env(cwd, &sandbox_workspace, sandbox.sandbox_profile.as_deref()); let spawned = if tty { spawn_pty_with_sandbox_wrap( &shell_spec.program, &args, cwd, + &sandbox, &env, TerminalSize { rows: PTY_ROWS, cols: PTY_COLS, }, - sandbox_profile.as_deref(), - sandbox_overlay.as_ref(), ) .await? } else { @@ -245,8 +249,9 @@ impl UnifiedExecProcess { /*arg0*/ &None, &[], SandboxProcessOptions { - sandbox_profile, - sandbox_overlay, + sandbox_profile: sandbox.sandbox_profile, + sandbox_overlay: sandbox.sandbox_overlay, + sandbox_workspace, }, ) .await @@ -275,8 +280,12 @@ impl UnifiedExecProcess { shell, login, size, - sandbox_profile, - None, + SandboxExecutionOptions { + output_capture: None, + sandbox_profile, + sandbox_overlay: None, + sandbox_workspace: None, + }, ) .await } @@ -287,24 +296,26 @@ impl UnifiedExecProcess { shell: Option<&str>, login: bool, size: Option, - sandbox_profile: Option, - sandbox_overlay: Option, + sandbox: SandboxExecutionOptions, ) -> Result<(Self, broadcast::Receiver>), String> { + let sandbox_workspace = sandbox + .sandbox_workspace + .clone() + .unwrap_or_else(|| cwd.to_path_buf()); let shell_spec = resolve_shell(shell, login); let args = interactive_shell_args(&shell_spec.program, login); - let env = unified_exec_env(cwd, sandbox_profile.as_deref()); + let env = unified_exec_env(cwd, &sandbox_workspace, sandbox.sandbox_profile.as_deref()); let spawned = spawn_pty_with_sandbox_wrap( &shell_spec.program, &args, cwd, + &sandbox, &env, size.unwrap_or(TerminalSize { rows: PTY_ROWS, cols: PTY_COLS, }), - sandbox_profile.as_deref(), - sandbox_overlay.as_ref(), ) .await?; @@ -435,17 +446,17 @@ async fn spawn_pty_with_sandbox_wrap( program: &str, args: &[String], cwd: &Path, + sandbox: &SandboxExecutionOptions, env: &HashMap, size: TerminalSize, - sandbox_profile: Option<&str>, - sandbox_overlay: Option<&SandboxPermissionOverlay>, ) -> Result { + let sandbox_workspace = sandbox.sandbox_workspace.as_deref().unwrap_or(cwd); let wrap = devo_sandbox::wrap_command_for_profile_with_overlay( - sandbox_profile, - cwd, + sandbox.sandbox_profile.as_deref(), + sandbox_workspace, devo_sandbox::WrapMode::PtyOnly, &devo_sandbox::SandboxLogger::new(), - sandbox_overlay, + sandbox.sandbox_overlay.as_ref(), ) .map_err(|error| format!("failed to set up sandbox: {error}"))?; let (program, args) = match &wrap { @@ -474,7 +485,11 @@ async fn spawn_pty_with_sandbox_wrap( Ok(spawned) } -fn unified_exec_env(cwd: &Path, sandbox_profile: Option<&str>) -> HashMap { +fn unified_exec_env( + _cwd: &Path, + sandbox_workspace: &Path, + sandbox_profile: Option<&str>, +) -> HashMap { let mut env = std::env::vars().collect::>(); for (key, value) in UNIFIED_EXEC_ENV { env.insert(key.to_string(), value.to_string()); @@ -483,11 +498,13 @@ fn unified_exec_env(cwd: &Path, sandbox_profile: Option<&str>) -> HashMap/harness/harness_state.json # local state +/harness/refinements.jsonl # host refinement audit log +/harness/harness_state.json # global state +``` + +`HarnessState::file_path(session_dir)` returns the local state path. +`refinements_log_path(session_dir)` returns the JSONL path. The same module also +exposes the canonical kernel snapshot paths used by the server: +`/kernel.dill` and `/kernel.json`. + +A simplified state file looks like this: + +```json +{ + "schema": 1, + "entries": { + "prompt": {}, + "memory": { + "prefer_targeted_tests": { + "id": "prefer_targeted_tests", + "kind": "memory", + "title": "Prefer targeted tests", + "content": "Run the smallest relevant test target before the full suite.", + "path": "general", + "scope": "local", + "reference": {}, + "arguments": {}, + "metadata": {}, + "source": "agent", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "version": 1 + } + }, + "skill": {}, + "subagent": {} + }, + "refinements": [] +} +``` + +`HarnessState::load` returns an empty default state when the file does not +exist. A corrupt or schema-invalid file returns +`HarnessStateError::Corrupt`; the crate does not silently wipe it. + +`HarnessState::save_atomic` creates parent directories, writes pretty JSON to a +temporary file in the same directory, and renames it over the target. Readers +never see a partially written state file. + +`HarnessState::merged_with_global` produces a copy with global entries added. +Session-local entries win on an id collision because they are more specific. + +## Crate map + +| Module | Role | +|---|---| +| `src/state.rs` | Schema types, default state, load, atomic save, local/global merge. | +| `src/digest.rs` | Format a state into prompt text; strip and reattach digest blocks. | +| `src/inject.rs` | `HarnessDigestInjector` for the server's prompt assembly. | +| `src/apply.rs` | Refine proposals, edits, drift checks, and atomic apply. | +| `src/refinements.rs` | `refinements.jsonl` records and snapshot path helpers. | +| `src/auto.rs` | Root-session auto-refine settings and turn-based decision. | +| `src/lib.rs` | Public exports. | + +## Prompt digest + +The harness is only useful if the model sees it. `HarnessDigestInjector` loads +the session state, optionally merges the global state, and formats a digest for +the next prompt. + +The digest: + +- is injected during cold prompt assembly, after skills and before the goal + section; +- groups entries under `Prompt notes`, `Memories`, `Harness skills`, and + `Subagent specs`; +- labels global entries with `(global)`; +- includes a short `await refine.run()` hint; +- is wrapped in the `[harness-digest]` / `` markers. + +The marker constants in `src/digest.rs` must stay aligned with the server's +history stripper. During compaction, the host removes old digest blocks from +the text sent to the summarizer. A fresh digest is injected into the compacted +head; the crate provides `reattach_harness_digest` for callers that assemble +history as a list of texts. This keeps stale memories out of the summary and +gives the next turn the current state. + +The relevant helpers are: + +- `format_harness_state_for_prompt`: format a state for a prompt; +- `wrap_harness_digest`: wrap a body in the digest markers; +- `strip_harness_digests`: remove digest blocks from arbitrary text; +- `is_harness_digest_only`: detect a message that contains only a digest; +- `filter_texts_for_summarizer`: drop digest-only messages and strip embedded + digests; +- `reattach_harness_digest`: put a fresh digest ahead of compacted history. + +`HarnessDigestInjector::digest_for_session_dir` propagates a corrupt-state +error. The convenience methods `digest_or_empty` and +`digest_or_empty_with_global` omit the digest instead, so a bad file cannot +break prompt assembly. + +## Refinement + +A refinement is planned by the server and applied through this crate. + +`src/apply.rs` defines: + +- `RefineProposal`: an id, trigger, summary, evidence, expected outcome, and a + list of edits. +- `RefineEdit`: `create`, `update`, or `delete`, a kind, an id, and optional + `before` / `after` entries. +- `apply_proposal_re_read`: load the current state, validate the proposal + against the on-disk state, apply the edits, append a `RefinementEvent`, and + save atomically. + +`before` is the baseline captured when the proposal was planned. If the current +entry no longer equals that baseline, `apply_proposal_re_read` returns +`ApplyError::Drift` instead of overwriting a concurrent edit. A create fails +with drift if the id already exists. The id `base_system_prompt` is immutable +and is always rejected. + +The server records applied refinements in two places: + +- a `RefinementEvent` inside `harness_state.json`; +- a `RefinementResultRecord` appended to + `/harness/refinements.jsonl`. + +The JSONL record includes the applied edits, the harness state path, and an +optional `rollback_of` and `scope`. `append_refinement` creates the directory, +appends one JSON object per line, and calls `sync_all`. + +The full flow is: + +```text +model calls refine.run() + -> refine skill sends a host_request + -> server schedules a pending refinement + -> turn boundary: planner builds a RefineProposal + -> apply_proposal_re_read re-reads the target state + -> drift check, apply, save_atomic + -> append_refinement writes refinements.jsonl + -> next turn's digest includes the new state +``` + +This crate does not decide what to refine. The server planner does that, using +the session model with a heuristic fallback. This crate only accepts a proposal +and applies it safely. + +## Auto-refine settings + +`src/auto.rs` defines `AutoRefineSettings`: + +| Field | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Enable turn-based auto-refine. | +| `turn_interval` | `25` | Successful user-visible turns between reviews. | +| `compact_enabled` | `true` | Enable the compact-review switch. | +| `compact_cooldown_ms` | `1_200_000` | Default 20-minute cooldown between compact reviews. | + +`should_auto_refine` implements the turn-based decision. It returns `false` for +child sessions, goal-continuation turns, and disabled settings. The compact +fields are available to the server's scheduling code; the server owns the +actual schedule. + +## Relationship to the Python kernel + +The Python module `rlm.harness` in `crates/kernel/rlm-runtime` is a separate +implementation of the same JSON schema. It is not generated from this crate and +does not link to Rust. The two sides meet on disk. + +- The RLM kernel exposes `rlm.harness` to cells and skills for direct CRUD. +- The Rust host reads the same file to build the prompt digest. +- The host applies refinements and writes the audit log. +- The kernel uses the same `schema: 1` shape and the same local/global paths. + +The host passes the paths to the kernel through environment variables set in +`crates/core/src/tools/handlers/ipython/startup.rs`: + +| Variable | Meaning | +|---|---| +| `RLM_SESSION_DIR` | Session artifact root. | +| `RLM_HARNESS_STATE_DIR` | Local harness directory. | +| `RLM_GLOBAL_HARNESS_STATE_DIR` | Global harness directory under Devo home. | +| `DEVO_CODING_AGENT_DIR` | Devo config directory, including `auth.json`. | + +### Concurrent writers + +The host and the kernel may write the same state file from different processes. +There is no cross-process lock; the protocol is optimistic. + +- The kernel keeps a long-lived `HarnessState` and checks the file mtime before + each local mutation. If another process changed the file, it reloads first. +- The host's `apply_proposal_re_read` re-reads immediately before applying and + rejects per-entry drift. +- Both sides use atomic replacement, so readers see either the old file or the + new file, never a torn one. + +The two sides treat corruption differently on purpose: + +- Rust's `HarnessState::load` rejects corrupt JSON. The digest injector either + propagates that error or omits the digest; it never invents content. +- Python's loader treats an unreadable or corrupt file as empty so a bad file + cannot kill the RLM kernel. The next kernel save rewrites a clean file. + +If you change a field, update both implementations and bump `SCHEMA_VERSION`. + +See [`crates/kernel/rlm-runtime/README.md`](../kernel/rlm-runtime/README.md) +for the Python side. + +## Relationship to `devo-server` + +`devo-server` depends on `devo-harness`. Important call sites: + +- `crates/server/src/runtime/turn_exec/query.rs` injects the prompt digest at + turn start. +- `crates/server/src/runtime/refine.rs` schedules, plans, and applies + refinements. +- `crates/server/src/runtime/refine_planner.rs` builds the LLM proposal and + falls back to the heuristic planner. +- `crates/core/src/history/harness_digest_strip.rs` uses matching digest + marker constants when preparing compaction input. + +The crate is deliberately small. It does not depend on session, model, or turn +types, and it does not talk to the Python process. That keeps the file format +and apply rules testable without a running server. + +## Design rules + +- **Schema is shared, not owned by one language.** Rust and Python mirror the + same JSON shape. +- **Local is more specific than global.** On an id collision, the session entry + wins in the merged digest. +- **Refinement is optimistic and drift-checked.** A proposal never overwrites + an entry that changed after planning. +- **The base system prompt is immutable.** `base_system_prompt` is rejected by + the apply path. +- **Atomic writes.** Temporary file plus rename; no partial reads. +- **Fail closed at the host boundary.** A corrupt state file is an error for + the digest loader, not invented prompt content. +- **Keep prompt formatting stable.** Digest markers are a compatibility + surface with compaction and history filtering. + +## Development + +Run the crate tests: + +```bash +cargo test -p devo-harness +``` + +Check formatting and docs: + +```bash +cargo fmt --check -p devo-harness +RUSTDOCFLAGS="-D warnings" cargo doc -p devo-harness --no-deps +``` + +The tests use `tempfile` and `pretty_assertions`; they do not need a server or +a Python interpreter. + +## Where to start reading + +| Order | File | Why | +|---|---|---| +| 1 | `src/state.rs` | The schema and the on-disk contract. | +| 2 | `src/digest.rs` | How state becomes prompt text and survives compaction. | +| 3 | `src/inject.rs` | The server-facing digest entry point. | +| 4 | `src/apply.rs` | Refine proposals, drift checks, and apply. | +| 5 | `src/refinements.rs` | The JSONL audit log and path helpers. | +| 6 | `src/auto.rs` | Root auto-refine settings. | +| 7 | `crates/server/src/runtime/refine.rs` | The host scheduling and apply pipeline. | +| 8 | `crates/server/src/runtime/refine_planner.rs` | LLM and heuristic planning. | +| 9 | `crates/kernel/rlm-runtime/src/rlm/harness.py` | The Python CRUD view of the same files. | + +Related documents: + +- [`crates/kernel/rlm-runtime/README.md`](../kernel/rlm-runtime/README.md) — + the Python kernel and its `rlm.harness` API. +- [`crates/kernel/README.md`](../kernel/README.md) — the Rust host that starts + the Python kernel. +- [`crates/server/src/runtime/refine.rs`](../server/src/runtime/refine.rs) — + the host refine pipeline. +- [`crates/core/src/history/harness_digest_strip.rs`](../core/src/history/harness_digest_strip.rs) + — digest filtering during compaction. + +## Glossary + +| Term | Meaning | +|---|---| +| Continual Harness | Persistent prompt-visible guidance and memory. | +| entry | One prompt note, memory, skill, or subagent spec. | +| local scope | State for one session. | +| global scope | State shared across sessions. | +| refinement | A small, evidence-backed edit to harness state. | +| proposal | A planned set of refinements, with evidence and expected outcome. | +| drift | An entry changed between planning and apply. | +| digest | The formatted harness state injected into a prompt. | +| base system prompt | The immutable prompt id that refinements cannot edit. | diff --git a/crates/harness/src/apply.rs b/crates/harness/src/apply.rs index 20fbd3bb..f69908a4 100644 --- a/crates/harness/src/apply.rs +++ b/crates/harness/src/apply.rs @@ -141,7 +141,6 @@ mod tests { } } - /// Trace: L2-DES-HARNESS-001 /// Verifies: apply rejects drift when the on-disk entry changed during planning. #[test] fn apply_rejects_drift() { @@ -183,7 +182,6 @@ mod tests { assert!(matches!(err, ApplyError::Drift { .. })); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: create apply persists the new entry. #[test] fn apply_create_persists() { diff --git a/crates/harness/src/auto.rs b/crates/harness/src/auto.rs index 38b832b1..016596c4 100644 --- a/crates/harness/src/auto.rs +++ b/crates/harness/src/auto.rs @@ -1,4 +1,4 @@ -//! Root-session auto-refine interval settings (L2-DES-HARNESS-001 DD-3). +//! Root-session auto-refine interval settings. use serde::{Deserialize, Serialize}; @@ -49,7 +49,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-HARNESS-001 /// Verifies: defaults are enabled with interval 25. #[test] fn defaults_match_spec() { @@ -64,7 +63,6 @@ mod tests { ); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: goal-continuation turns never trigger auto-refine. #[test] fn goal_continuation_skipped() { @@ -76,7 +74,6 @@ mod tests { )); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: children never auto-refine. #[test] fn children_skipped() { @@ -88,7 +85,6 @@ mod tests { )); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: interval fires on multiples of N for root user-visible turns. #[test] fn interval_fires_on_multiple() { diff --git a/crates/harness/src/digest.rs b/crates/harness/src/digest.rs index 3ded1ef1..f57bae72 100644 --- a/crates/harness/src/digest.rs +++ b/crates/harness/src/digest.rs @@ -157,7 +157,6 @@ mod tests { use chrono::Utc; use pretty_assertions::assert_eq; - /// Trace: L2-DES-HARNESS-001 /// Verifies: empty harness produces an empty digest (no empty heading spam). #[test] fn empty_state_digest_is_empty() { @@ -167,7 +166,6 @@ mod tests { ); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: digest includes memory entries and refine.run guidance. #[test] fn digest_includes_memory_and_refine_hint() { @@ -197,7 +195,6 @@ mod tests { assert!(digest.contains("[harness-digest]")); } - /// Trace: L2-DES-CONTEXT-002, L2-DES-HARNESS-001 /// Verifies: digest blocks are excluded from summarizer input and reattached. #[test] fn strip_and_reattach_digest() { diff --git a/crates/harness/src/inject.rs b/crates/harness/src/inject.rs index deb83d33..ef14384a 100644 --- a/crates/harness/src/inject.rs +++ b/crates/harness/src/inject.rs @@ -1,7 +1,6 @@ //! Host helper that loads H and formats the prompt digest. //! //! Call sites: cold prompt assembly (after catalog skills, before hidden goal). -//! See L2-DES-HARNESS-001 DD-5 and docs/rlm-native-api.md section 7. use std::path::Path; @@ -60,7 +59,6 @@ mod tests { use chrono::Utc; use pretty_assertions::assert_eq; - /// Trace: L2-DES-HARNESS-001 /// Verifies: injector returns digest content after a successful state write. #[test] fn injector_loads_digest_for_prompt_site() { @@ -96,7 +94,6 @@ mod tests { assert!(digest.contains("Continual harness") || digest.contains("harness-digest")); } - /// Trace: bug #46 — global harness entries must surface in every digest. /// Verifies: merged digest includes both scopes, labels global entries, /// and prefers the session entry on id collision. #[test] @@ -157,7 +154,6 @@ mod tests { assert!(!digest.contains("Global shadowed")); } - /// Trace: bug #46 — missing global store degrades to the local digest. #[test] fn merged_digest_without_global_store_is_local_only() { let session_dir = tempfile::tempdir().unwrap(); diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index 1be7505d..3fac61b4 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -1,6 +1,6 @@ //! Continual Harness \(H = (\rho, G, K, M)\) — types and shared-file IO. //! -//! See `L2-DES-HARNESS-001`. Full refine planning lives in the server host. +//! Full refine planning lives in the server host. mod apply; mod auto; diff --git a/crates/harness/src/refinements.rs b/crates/harness/src/refinements.rs index 247705e5..dcf6b0b7 100644 --- a/crates/harness/src/refinements.rs +++ b/crates/harness/src/refinements.rs @@ -84,7 +84,6 @@ mod tests { use crate::state::HarnessKind; use pretty_assertions::assert_eq; - /// Trace: L2-DES-HARNESS-001 /// Verifies: refinements.jsonl appends one JSON object per line. #[test] fn append_writes_jsonl_line() { diff --git a/crates/harness/src/state.rs b/crates/harness/src/state.rs index 7866467e..920eecb7 100644 --- a/crates/harness/src/state.rs +++ b/crates/harness/src/state.rs @@ -166,7 +166,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-HARNESS-001 /// Verifies: corrupt harness JSON fails closed (no silent empty wipe). #[test] fn corrupt_json_fails_closed() { @@ -177,7 +176,6 @@ mod tests { assert!(matches!(err, HarnessStateError::Corrupt(_))); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: round-trip save/load preserves entries. #[test] fn save_load_round_trip() { diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 72ed9144..242ec0dd 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -12,6 +12,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-util = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } dunce = { workspace = true } @@ -22,10 +23,5 @@ pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full", "test-util"] } -[target.'cfg(windows)'.dependencies] -devo-windows-sandbox = { workspace = true } - [target.'cfg(unix)'.dependencies] -devo-sandbox = { workspace = true } -devo-util-process = { workspace = true } libc = { workspace = true } diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 138a714f..61018568 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -2,15 +2,123 @@ CPython RLM REPL host (protocol v3). Spawns `python -m rlm.repl`. +## What this crate is + +`devo-kernel` does not run Python code itself. It starts a long-lived Python +process, keeps it alive, and talks to it over stdin/stdout. The wire format is +newline-delimited JSON: one complete JSON object per line, UTF-8, nothing else. + +Two names are used throughout this crate: + +- **host** — the Rust side, meaning this crate. It spawns and owns the child + process. +- **kernel** — the Python side, the process started by `python -m rlm.repl`. + This is not the operating-system kernel. + +The kernel is a persistent REPL. The model's Python code runs in one +`__main__` namespace on one asyncio event loop, so variables, imports, and +background tasks survive from one turn to the next. + +``` ++-------------------------------+ +---------------------------------+ +| Rust host (`devo-kernel`) | newline-delimited | Python kernel | +| | JSON on stdin/stdout| `python -m rlm.repl` | +| `KernelSession` | <------------------> | persistent `__main__` namespace | +| - spawns and owns the child | | one asyncio event loop | +| - reads events from stdout | | `bash()`, MCP, harness, skills | +| - answers `host_request`s | | | ++-------------------------------+ +---------------------------------+ +``` + +One execute call looks like this: + +``` +model tool call + -> host writes {"type":"execute","id":"c1","code":"..."} to kernel stdin + -> kernel runs the cell and streams events tagged with id "c1" + stdout / stderr / result / display / host_request / error + -> kernel writes {"event":"done","id":"c1","status":"ok"} + -> host returns one `CellOutput` to the caller +``` + +The crate also exposes interrupt, snapshot, restore, `list_names`, and shutdown +over the same protocol. Alongside `execute`, these make up the whole surface +between the two processes. + +The Python package itself is described in +[`rlm-runtime/README.md`](rlm-runtime/README.md). + +## Sessions and kernel processes + +A session owns one kernel process. The server starts that kernel when the +session's first RLM turn begins and reuses it for later turns, so its Python +namespace stays warm. + +- Two concurrent RLM sessions run two Python child processes. Each has its own + `__main__` namespace, imports, background asyncio tasks, and `bash()` child + processes. They share no Python state. +- A subagent is a separate session, so each running subagent also gets its own + kernel. A parent and its child never share one. +- Within a session, the trusted kernel is reused while the workspace root and + collaboration mode stay the same. Changing either shuts the old kernel down + and starts a new one, restoring the session namespace from its snapshot when + one exists. Plan mode does not make native Python read-only. +- `Discrete` execution starts no kernel. `devo prompt` starts one temporary + kernel and shuts it down after the query. +- Deleting a session terminates its kernel and discards its namespace; other + sessions are unaffected. + +The crate does not pool kernels. `KernelSession` is owned per session by the +session actor (`crates/server/src/runtime/session_actor/state.rs`), so process +count grows with the number of sessions that actively use Python. + +## Glossary + +| Term | Meaning | +|---|---| +| host | The Rust side of the pair; this crate. | +| kernel | The Python side; the `python -m rlm.repl` process. | +| session | One host instance, one kernel process, and one Python namespace. | +| cell | One `execute` request plus all events up to and including its `done`. | +| event | A message from kernel to host: `ready`, `stdout`, `stderr`, `result`, `display`, `host_request`, `error`, or `done`. | +| `host_request` | A call from Python to Rust when kernel code needs the host to act: bash approval and execution, web search/fetch, MCP token refresh, subagents, and so on. | +| pump | The single background task that reads kernel stdout and routes each event. | +| idle route | The narrow path for `host_request`s that arrive while no cell is running. All other idle requests are denied. | +| execution surface | The set of tool schemas advertised to the model. With a kernel it is exactly `ipython`. | +| `Discrete` | Execution mode that does not start a kernel, so no `ipython` tool is advertised. | + +## Where to start reading + +| Order | File | Why | +|---|---|---| +| 1 | `src/protocol.rs` | All request and event types on the wire. | +| 2 | `rlm-runtime/src/rlm/repl.md` | The behavior behind those types. | +| 3 | `rlm-runtime/src/rlm/repl.py` | Kernel implementation. | +| 4 | `src/session.rs` | Host implementation. | +| 5 | `src/session/session_event_pump.rs` | Event routing and the idle route. | + +## Trust boundary + +Product Python is trusted code running with the user's OS privileges in both +Build and Plan. The kernel launcher does not apply an OS sandbox. +Native Python can read or write files, +use the network, and create subprocesses with those privileges. Plan is not an +OS read-only guarantee for Python. Host-mediated actions still use their host +policy checks, including Plan guards and configured shell sandbox policy. + +Provider credential environment stripping remains in the kernel launcher. This +reduces accidental inheritance; it does not isolate trusted Python from files +or other resources the user can access. Per-session temporary directories, +namespace snapshots, and shutdown behavior remain in place. + ## Runtime path Set `DEVO_RLM_RUNTIME_SRC` to the directory that contains the `rlm` package, or use the default `crates/kernel/rlm-runtime/src`. On Windows the REPL uses an IOCP event loop with a named-pipe wakeup channel. -CPython's default loopback TCP wakeup channel can block during initialization -inside the network-restricted sandbox. The pipe keeps thread wakeups and -asynchronous subprocess I/O working without requiring network access. +The pipe keeps thread wakeups and asynchronous subprocess I/O working without +requiring a loopback TCP connection. ## Session deletion @@ -25,19 +133,63 @@ running cell to finish. Normal shutdown retains the longer graceful timeout. is captured for the live request and matched by the REPL `cell_id`; it cannot be reused for a late detached request or a later turn. Replies use a **separate stdin lock** (Prime deadlock rule). `set_idle_bash_completion_handler` is the -only idle route and the pump invokes it only for `bash.completed` and the -PID-scoped `bash.consumed` withdrawal; all other idle host requests are denied. -Default deny when either handler is unset. - -Background Bash may complete after its IPython cell ends. The `bash.completed` -idle route queues a follow-up while the server process is alive. Pending Bash -completion notices are process-local and are not recovered after a server -restart. Session teardown requests bounded graceful REPL shutdown; its cleanup -hook kills Bash process groups owned by that kernel. If the kernel dies -unexpectedly or graceful shutdown times out, child cleanup is best-effort, and -no completion-notice guarantee spans server or kernel loss. The -`application/vnd.devo.bash-activity+json` display marker is not part of a -client tool result and has no TUI/Desktop consumer. +narrow idle route for Bash completion/consumption notices and session-scoped +`bash.poll` / `bash.kill` / `bash.forget` controls. Starting a command still requires the live +host authorization path. All other idle requests are denied, as are requests +when the applicable handler is unset. + +### Python Bash API + +`bash(command, ...)` schedules `bash.start` through `host_request` and returns a +synchronous, awaitable `BashHandle`. It never falls back to a local subprocess +when the host is absent or denies authorization. It requires a running asyncio +loop. The initial state is pending while the host authorizes and starts the +command. The creating cell stays active until its scheduled starts are admitted; +a denied background start is a cell error, not a silent detached failure. +`await handle` returns `BashResult(exit_code, output, duration)`. + +- `handle.handle_id` is an opaque session-scoped string, initially `None`. + `handle.pid` is always `None`; a host ID must not be used as an OS PID. +- `running`, `poll()`, `output()`, and `tail(n=50)` read cached snapshots without + blocking the event loop. An async monitor refreshes them every 0.1 seconds + while the command runs. `poll()` returns `None` until completion, then the + result. Host failures surface from awaiting or polling the handle; output() + and tail() retain the captured bytes for inspection even after failure. Terminal + host failures without an exit status raise `RuntimeError` with the host error + and captured output; the facade never fabricates an exit code. +- `kill()` schedules host cancellation, including during pending admission. + It takes no signal or grace arguments. Await the handle to observe exit. +- Cancelling a first await of an otherwise untouched handle schedules host + cancellation. Accessing a handle first marks it as background; cancelling + later awaits does not kill it. Pending approval cannot be dismissed by this + API: cancellation is applied when admission returns. Cleanup after connection + loss is the host's responsibility. +- `workdir` defaults to Python's current working directory at the call. Explicit + relative paths are resolved by the host. Shell selection and environment are + host-owned: Python `os.environ` changes are **not** propagated to commands. +- Permission keywords match `rlm.shell`: `sandbox_permissions`, `justification`, + `additional_permissions`, `prefix_rule`, and `timeout_ms`. `rlm.shell` remains + a one-shot host request; it does not return a live handle. + +Snapshots contain `handle_id`, `running`, `exit_code`, `output`, and `duration` +(seconds). `bash.poll`, `bash.kill`, and `bash.forget` take only `handle_id`. +The monitor forgets completed host entries after caching their final snapshot; +results remain readable locally. Host retention limits backstop failed forget +requests. Completion notices do not depend on registry retention. No PID is fabricated. +The old standalone local implementation is explicitly available as +`from rlm.local_bash import bash`; it is not the product default or an automatic +fallback. It runs directly in the trusted Python process's environment and does +not provide host shell authorization. + +Background Bash may complete after its IPython cell ends. `bash.completed` +with `{handle_id, command, exitCode}` queues a follow-up; `bash.consumed` with +`{handle_id, command}` withdraws it when a live cell consumes the result. +Notices wait for the creating cell's completion barrier, so foreground awaits +do not also produce background follow-ups. Legacy explicit local handles still +use PID-scoped notices. Notices and handles are process-local, not recovered +across server/kernel loss. The `application/vnd.bash-activity+json` display +marker carries `id`, `active`, and (once available) `handle_id`; it is not part +of a client tool result and has no TUI/Desktop consumer. Server dispatch lives in `crates/server/src/runtime/kernel_host.rs`. @@ -57,5 +209,3 @@ The one-shot `devo prompt` command starts a temporary kernel for `ipython` and shuts it down after the query. Its host bridge supports configured local web search/fetch; interactive plan display and user questions are unavailable in this one-shot mode. - -Trace: `L2-DES-RLM-001`. diff --git a/crates/kernel/rlm-runtime/README.md b/crates/kernel/rlm-runtime/README.md new file mode 100644 index 00000000..69f0513a --- /dev/null +++ b/crates/kernel/rlm-runtime/README.md @@ -0,0 +1,569 @@ +# `devo-kernel-runtime` + +The Python side of Devo's persistent RLM kernel. + +`devo-kernel-runtime` is the child process that the Rust crate `devo-kernel` +starts with `python -m rlm.repl`. It does two jobs: + +1. Run model-written Python cells in one persistent `__main__` namespace on one + asyncio event loop. +2. Export the `rlm` namespace that cells, Python skills, and MCP integrations + use to call back into the Rust host. + +It is not the Rust host. It does not own approval policy, provider credentials, +subagent lifecycle, or shell authorization. Those belong to the host. + +The Rust side calls this process the **kernel**. It is a normal CPython process, +not an operating-system kernel. + +## How the host starts it + +`devo-kernel` sets `PYTHONPATH` to this package's `src/` directory (or uses a +bundled copy under `python-site/`) and starts: + +```text +python -m rlm.repl +``` + +The two processes speak newline-delimited JSON protocol v3 over stdin/stdout. +The complete wire behavior is in [`src/rlm/repl.md`](src/rlm/repl.md); the +matching Rust types are in `crates/kernel/src/protocol.rs`. + +`DEVO_RLM_RUNTIME_SRC` can override the source directory during development. +The production bundle copies `src/rlm` into the application; see +`scripts/runtime/bundle.ts`. + +## Architecture + +```text ++-------------------------------+ JSON lines on stdin/stdout +-------------------------------+ +| Rust host (`devo-kernel`) | <----------------------------------> | Python kernel | +| | | `python -m rlm.repl` | +| session actor, tool bridge | | one asyncio event loop | +| approvals, credentials, | | persistent `__main__` name- | +| subagents, MCP configuration | | space | +| `devo-harness` (host state) | | `rlm` API + Python skills | ++-------------------------------+ +-------------------------------+ +``` + +A session owns one kernel. Two concurrent sessions run two Python processes. +A subagent is a separate session, so it has its own kernel and namespace. The +runtime does not pool kernels. + +## Runtime lifecycle + +`src/rlm/repl.py` is the implementation. Its `main()` does the following: + +1. `_setup_fds()` reserves a private protocol fd and redirects the process's + normal stdout/stderr into capture pipes. +2. Starts the owner watchdog. If the Rust host exits, the kernel exits too. +3. Creates a real `__main__` module for dill snapshots and a persistent + namespace for cells. +4. Creates one asyncio event loop. On Windows it uses the IOCP loop from + `_windows_event_loop.py`. +5. Starts a reader thread for protocol requests. +6. Sends `{"event":"ready","protocol":3,...}`. +7. Serves requests from an asyncio queue until `shutdown` or stdin EOF. + +Cells are serialized: one request at a time. Cells compile with +`PyCF_ALLOW_TOP_LEVEL_AWAIT`, so top-level `await` works. Background asyncio +tasks created by a cell keep running between cells. + +`interrupt` and `host_reply` bypass the request queue. They are handled on the +reader thread because the cell that is waiting for a host reply is itself the +in-flight execute request. Queuing the reply behind that request would deadlock. + +### Captured output + +The kernel's OS stdout is the protocol channel, but user output must not write +plain text into it. `_setup_fds()` therefore: + +- duplicates the original fd 1 into a private protocol fd; +- redirects fd 1 and fd 2 into pipes; +- replaces `sys.stdout` and `sys.stderr` with tagged writers. + +Python-level writes such as `print()` become protocol events immediately: + +```json +{"event":"stdout","id":"c1","text":"hello\n"} +``` + +Raw fd writes (`os.write`, C extensions, subprocesses, `sys.stdout.buffer`) +go through the capture pipes and arrive as `id: null` events. The runtime cannot +prove which cell wrote those bytes, so it does not attribute them. + +This is why `KernelEvent` has `stdout` and `stderr` variants even though the +transport is stdout: the events preserve the logical stream and the cell +attribution. A `done` event is sent after both capture channels are drained. + +## Protocol in one page + +The full behavior, ordering rules, and examples live in +[`src/rlm/repl.md`](src/rlm/repl.md). The short version: + +| Request | Purpose | +|---|---| +| `execute` | Run one code cell in the persistent namespace. | +| `interrupt` | Raise `KeyboardInterrupt` in a running or queued cell. No reply. | +| `host_reply` | Answer a kernel `host_request`. No reply. | +| `snapshot` | Serialize the namespace with dill and write a manifest. | +| `restore` | Load names from a snapshot. | +| `list_names` | List user-defined top-level names. | +| `shutdown` | Stop the runtime, close MCP children, and exit. | + +| Event | Purpose | +|---|---| +| `ready` | Handshake; sent once at startup. | +| `stdout` / `stderr` | Captured logical output. | +| `result` | `repr` of a cell's trailing expression, when non-`None`. | +| `display` | MIME-keyed payload from `emit()`. | +| `host_request` | Kernel-to-host RPC. | +| `error` | An exception, with `ename`, `evalue`, and `traceback`. | +| `done` | Terminal event for one request. | + +`result` is data: it is optional and appears at most once. `done` is lifecycle: +exactly one per id'd request, always last, and the collector stops there. An +`error` event is not terminal by itself; `done` follows it. `snapshot`, +`restore`, and `list_names` have no `result`; their payload is carried by +`done`. + +## Module map + +| Module | Role | +|---|---| +| `repl.py` | The protocol runtime: fd setup, request loop, cells, interrupts, output capture, snapshots, and `host_request`. | +| `repl.md` | Authoritative protocol behavior and wire examples. | +| `__init__.py` | Public `rlm` facade: typed host requests, file helpers, recursion API, harness proxy, lazy MCP exports. | +| `bash.py` | Host-owned, awaitable `BashHandle` and `bash()`. | +| `local_bash.py` | Explicit local shell implementation. Not the product default. | +| `shell.py` | One-shot shell command through the host approval pipeline. | +| `harness.py` | Kernel-side CRUD for persistent harness state. | +| `mcp.py` | Kernel-owned generic MCP client registry. | +| `mcp_base.py` | Base class for MCP integration skill packages. | +| `skill.py` | CLI helpers for Python skill packages. | +| `windows_shell.py` | Git Bash discovery on Windows without trusting `PATH`. | +| `_winjob.py` | Windows Job Object containment for Bash children. | +| `_windows_event_loop.py` | IOCP event loop with a named-pipe wakeup channel. | +| `_snapshot_files.py` | Exclusive snapshot staging files. | +| `_snapshot_serialization.py` | Pickle filters that never reopen persisted OS file handles. | + +## The `rlm` namespace + +`rlm` is a namespace object, not a callable function. Use `rlm.spawn(...)`, not +`rlm(...)`. The old `rlm.run` name is deliberately gone; it raises an +`AttributeError` that points at `rlm.spawn`. + +`rlm.repl` is the low-level runtime module. `rlm` is the typed facade. For +example, `rlm.repl.host_request(data)` returns the raw reply dict, while +`rlm.host_request(type, payload)` wraps the `{status, result}` envelope and +raises `RuntimeError` on failure. + +The main groups are: + +| API | Purpose | +|---|---| +| `rlm.bash`, `rlm.shell` | Host-owned shell execution. | +| `rlm.host_request` | Typed kernel-to-host RPC. | +| `rlm.web_search`, `rlm.web_fetch` | Host-mediated network access. | +| `rlm.update_plan`, `rlm.request_user_input` | Session UI integration. | +| `rlm.read`, `rlm.write`, `rlm.read_once`, `rlm.write_once` | Direct file I/O, with host-mediated fallback on OS denial. | +| `rlm.spawn`, `rlm.create_session`, `rlm.find_models`, `rlm.list_subagents`, `rlm.delete_subagent` | Recursive subagent control. | +| `rlm.harness`, `rlm.get_harness_state` | Persistent harness CRUD. | +| `rlm.emit` | Ship a MIME-keyed display event. | + +A few examples: + +```python +import rlm + +result = await rlm.bash("cargo test -p devo-kernel") +print(result.exit_code, result.output) + +reviewer = await rlm.spawn( + "Review the current diff for concurrency bugs.", + name="reviewer", + model="openai-codex/gpt-5.4-mini", +) +print(reviewer.model) +``` + +`rlm.spawn` returns when the child is admitted, not when it finishes. The child +runs as a separate session; results arrive through `agent_message` replies or +files, usually on a later turn. + +`rlm.read` and `rlm.write` try the direct filesystem call first. On `OSError` +they ask the host to perform the operation through its approval pipeline. A +host denial surfaces as `PermissionError`. These helpers are an entry point, not +an OS sandbox. + +## Host bridge and trust boundary + +The host bridge is the package's main trust boundary. + +A kernel cell calls `await rlm.host_request("bash", {...})`. The runtime emits: + +```json +{"event":"host_request","id":"h1","cell_id":"c1","data":{"type":"bash","command":"cargo test"}} +``` + +The Rust host answers with a `host_reply` request carrying the same `id`: + +```json +{"type":"host_reply","id":"h1","data":{"status":"ok","result":{}}} +``` + +The `cell_id` is a capability. The host only uses the host callback that was +captured for that live cell. A detached task that outlives its cell cannot +reuse a later turn's handler. Requests that arrive while no cell is active fall +back to the narrow idle Bash route or are denied. + +Python is trusted code running with the user's OS privileges. The kernel +launcher does not apply an OS sandbox. Plan mode is not an OS read-only +guarantee for Python. Host-mediated actions still go through their own policy +checks. The Rust launcher strips provider credentials from the kernel +environment; it does not isolate trusted Python from files or the network. + +## Bash and shell + +The product path is `rlm.bash`: + +```python +handle = rlm.bash("cargo test -p devo-kernel") +print(handle.running) # pending or running +result = await handle +print(result.exit_code, result.output) +``` + +`BashHandle` is synchronous and non-blocking. It schedules `bash.start` through +the host and then refreshes a cached snapshot every 0.1 seconds. `handle_id` is +an opaque host handle; `pid` is always `None`. `kill()` schedules host +cancellation, including during pending approval. Awaiting the handle waits for +exit. There is no local subprocess fallback: a missing host or a denied request +is an error. + +`rlm.shell` is the one-shot form. It runs one host command and returns the +structured result. `rlm.local_bash` is the explicit local implementation for +standalone use and legacy tests; it is not selected automatically and does not +provide host shell authorization. + +The host owns shell selection, environment, workspace resolution, sandbox +policy, and timeout behavior. Python's `os.environ` changes are not propagated +to host commands. + +## MCP integrations + +There are two layers: + +- `rlm.mcp` is the kernel-owned generic MCP client registry for servers + configured in `mcpServers`. It supports stdio and streamable HTTP servers, + reads credentials from the host's `auth.json`, and asks the host to refresh + expired tokens through `mcp.refresh`. +- `rlm.mcp_base.McpIntegration` is the base class for a Python skill package + that exposes one MCP server as ordinary async Python methods. Tools are + discovered on first use and bound through `__getattr__`; `call_tool(name, + args)` is the explicit escape hatch. + +The MCP exports are lazy. `import rlm` does not import the `mcp` SDK; only an +integration package that subclasses `McpIntegration` needs it. + +## Continual Harness and `crates/harness` + +`rlm.harness` and the Rust crate `devo-harness` are two views of one file-backed +state store. They do not import each other. They meet on disk, through a shared +JSON schema and a set of environment variables. + +### What the harness does + +The Continual Harness is the agent's persistent, prompt-visible guidance and +memory. It is not the agent loop, and it does not execute tools. It answers +questions such as: + +- What durable fact or preference should this session remember? +- Which reusable skill or subagent spec has the agent already defined? +- Which narrow prompt note corrects a repeated behavior? +- What changed during refinement, and why? + +It stores four kinds of entries: + +| Kind | Purpose | +|---|---| +| `prompt` | A narrow behavioral note added to the system prompt. | +| `memory` | A durable fact, preference, or lesson. | +| `skill` | A reusable Python skill definition with a Python `reference` and `arguments`. | +| `subagent` | A reusable delegation spec or task pattern. | + +A `skill` entry is still a definition; it does not grant tools, MCP access, or +sandbox permissions. A `subagent` entry is guidance; it does not spawn a child. +Actual spawning still goes through `rlm.spawn`. + +Entries are **local** to one session by default, or **global** across sessions +when written with `global_=True`. Python reserves the word `global`, hence the +trailing underscore. + +A **refinement** is a small, evidence-backed edit to the harness. It can create, +update, or delete entries. For a refinement, the host plans and applies the +edits; the kernel only schedules the request. + +### Host side: `crates/harness` (`devo-harness`) + +The Rust crate is the host-side source of truth for the schema and the host +operations. It is linked into `devo-server`; it is not part of the Python +process. See [`crates/harness/README.md`](../../harness/README.md) for the crate +README. It owns: + +- the serde types `HarnessState`, `HarnessEntry`, `HarnessKind`, `HarnessScope`, + and `RefinementEvent`, with `SCHEMA_VERSION = 1`; +- `HarnessState::load`, `save_atomic`, and `file_path`; +- prompt digest formatting and strip/reattach helpers; +- `HarnessDigestInjector`, which loads session state, merges global entries, and + formats the digest injected into the next turn; +- `RefineProposal` / `RefineEdit` and `apply_proposal_re_read`, which re-reads + the file, rejects per-entry drift, applies edits, and saves; +- the append-only `refinements.jsonl` audit/undo log; +- `AutoRefineSettings` and `should_auto_refine`: root sessions default to a + review every 25 successful user-visible turns, with a 20-minute compact + cooldown; child sessions never auto-refine. + +The host uses `crates/harness` for two jobs: + +- `crates/server/src/runtime/turn_exec/query.rs` injects the digest during cold + prompt assembly, after skills and before the goal section. Local and global + stores are merged; on an id collision the session-local entry wins. +- The digest helpers strip existing digests from compaction input and reattach + a fresh digest to the compacted head. They also expose the marker constants + used by the host's history stripper. + +### Kernel side: `rlm.harness` + +`rlm.harness` is the Python CRUD API over the same JSON files. It provides: + +```python +entry = rlm.harness.create_memory( + "Prefer targeted tests", + "Run the smallest relevant test target before the full suite.", +) +rlm.harness.update_memory(entry.id, entry.title, entry.content + " Keep it fast.") +rlm.harness.delete_memory(entry.id) + +rlm.harness.create_prompt_note( + "Patch size", + "Do not rewrite a whole file for a small change.", + id="patch_size", +) +rlm.harness.create_subagent( + "Reviewer", + "Review one diff, list concrete defects, and cite files and lines.", + id="reviewer", +) +``` + +`rlm.harness.overview()` prints a bounded summary, and +`rlm.get_harness_state(global_=True)` returns the global store explicitly. + +The Python store is long-lived and cached. Before every local mutation it +checks the file's mtime and reloads if another process changed it. This is what +prevents a kernel write from silently overwriting a host `/refine` edit. Writes +use a unique temporary file and `os.replace`, preserving the existing file +mode. + +`rlm.harness` falls back to an in-memory view when no local session directory +is configured (for example, a kernel started without a session artifact +directory). Reads see an empty store; local writes raise an instructive error +instead of disappearing when the kernel exits. +`global_=True` still works when a global store is configured. + +### How the two sides cooperate + +```text +Rust host (`devo-server`) Python kernel +-------------------------- ------------- +devo-harness::HarnessState::load <── same JSON ── rlm.harness.HarnessState +HarnessDigestInjector ── prompt ────► next model turn +refine.rs: schedule / plan / apply refine skill -> host_request +apply_proposal_re_read ── writes ────► harness_state.json +append_refinement ── writes ────► refinements.jsonl +``` + +The shared paths are: + +| Scope | Path | Written by | +|---|---|---| +| local | `/harness/harness_state.json` | host refine, kernel CRUD | +| global | `/harness/harness_state.json` | host refine, kernel CRUD | +| local audit | `/harness/refinements.jsonl` | host refinement apply | + +The kernel receives the paths through environment variables set by +`crates/core/src/tools/handlers/ipython/startup.rs`: + +| Variable | Meaning | +|---|---| +| `RLM_SESSION_DIR` | Session artifact root. | +| `RLM_HARNESS_STATE_DIR` | Local harness directory. | +| `RLM_GLOBAL_HARNESS_STATE_DIR` | Global harness directory under Devo home. | +| `DEVO_CODING_AGENT_DIR` | Devo config directory, including `auth.json`. | + +### Refinement flow + +1. A cell or skill calls `await refine.run(...)`. +2. The `refine` skill sends a `refine.run` `host_request`. +3. `crates/server/src/runtime/refine.rs` schedules a pending refinement. Child + sessions cannot schedule one. +4. At the turn boundary (or immediately when the session is idle), the host + planner produces a `RefineProposal`. It prefers the session model through + `UsagePurpose::Refine` and falls back to a heuristic planner. +5. `apply_proposal_re_read` re-reads the target file, rejects an edit if the + entry changed since planning, applies the proposal, saves atomically, and + appends a record to `refinements.jsonl`. +6. The next prompt digest includes the new state. The host merges global + entries into the local view; local entries win on an id collision. + +`refine.run()` is therefore different from direct CRUD: + +| API | Who plans the edit | When it applies | +|---|---|---| +| `await refine.run(...)` | Host LLM or heuristic planner | Turn boundary, or immediately when idle | +| `rlm.harness.create_memory(...)` / `update_*` / `delete_*` | The caller | Immediately | +| `rlm.harness.record_refinement(...)` | The caller | Immediately, as a history event | + +Direct CRUD is useful for explicit, known facts. `refine.run()` is useful when +the agent wants the host to review evidence and decide the smallest edit. + +### Failure and schema notes + +- Rust's `HarnessState::load` rejects corrupt JSON. The digest injector either + propagates that error or omits the digest, depending on the caller; it never + invents content. +- The Python loader treats an unreadable or corrupt file as empty so a bad file + cannot kill the REPL; the next kernel save rewrites a clean file. +- The Rust and Python schemas are mirrored by hand. If you add or change a + field, update both sides and bump `SCHEMA_VERSION`. +- The host's `refinements.jsonl` is an append-only audit/undo log. Python's + `record_refinement` writes a `RefinementEvent` into the state JSON's + `refinements` array; it does not append to the JSONL log. + +## Snapshot and restore + +`repl.py` implements snapshot and restore with `dill`: + +- `snapshot` serializes user-defined top-level names, skips runtime names and + `_`-prefixed names, caps the total and per-variable sizes, and writes a JSON + manifest next to the payload. +- `restore` revives names independently and reports per-name failures. +- `_snapshot_serialization.py` rejects OS file handles anywhere in an object + graph. Reopening a file handle from a completed write cell could truncate the + file again; in-memory `BytesIO` and `StringIO` remain supported. +- `_snapshot_files.py` creates exclusive temporary files in the target + directory without retrying permission denials. + +The host calls snapshot after a successful cell and restores when it rebuilds a +kernel. The full behavior, including the `done` fields, is in +[`src/rlm/repl.md`](src/rlm/repl.md). + +## Windows support + +Windows behavior is isolated in small modules: + +- `_windows_event_loop.py` replaces CPython's loopback-socket self-pipe with an + IOCP loop and a named-pipe wakeup channel. This keeps wakeups and subprocess + I/O working when network access is restricted. +- `_winjob.py` puts each Bash child in a kill-on-close Job Object. The child is + created suspended and atomically inside the job, so there is no assignment + window; descendants die when the job closes, including on kernel crash. +- `windows_shell.py` finds Git Bash from installer registrations without + trusting a workspace `PATH`. + +CI runs the Python suite on Windows. The Windows-specific tests mock the +`kernel32` boundary where the real API is unavailable. + +## Development and tests + +The package requires Python 3.11 or newer. Runtime dependencies are `mcp>=2,<3` +and `tyro`; `dill` is a development dependency for snapshot tests. + +From this directory: + +```bash +uv sync +uv run python -m unittest discover -s tests -v +``` + +Or without `uv`: + +```bash +PYTHONPATH=src python -m unittest discover -s tests -v +``` + +The tests use fake host callbacks; they do not need a running Devo server. The +main test files are: + +| Test file | Coverage | +|---|---| +| `test_host_bash.py` | Pending state, approval, cancellation, notices, opaque handles. | +| `test_rlm_shell.py` | Shell approval arguments and denial propagation. | +| `test_file_helpers.py` | Direct file I/O and host-mediated fallback. | +| `test_mcp_host_list_tools.py` | MCP tool listing and host fallback behavior. | +| `test_snapshot_files.py` | File-handle rejection in snapshots. | +| `test_snapshot_staging.py` | Exclusive staging and collision handling. | +| `test_windows_*` | Windows loop, shell discovery, and environment mapping. | + +CI also runs the suite on Windows for Python 3.11 and 3.13. + +## Design rules + +- **Host policy stays in the host.** Python may request an action, but the host + decides whether it is allowed. +- **No silent local fallback for host-owned actions.** If the host is missing + or denies a request, the kernel raises an error instead of running the action + locally. +- **Protocol framing must stay intact.** User output is captured into events; + it never writes plain text to the protocol fd. +- **One event loop, one namespace.** Cells share state and background tasks; + sessions and subagents do not. +- **Disk state is atomic and shared.** Harness writes use temporary files plus + rename, and both sides tolerate the other process writing the same file. +- **Snapshots never reopen persisted OS handles.** Prefer `BytesIO`/`StringIO` + for in-memory data. +- **Cross-platform code is isolated.** Windows behavior lives in the Windows + modules; the common runtime stays platform-neutral. + +## Glossary + +| Term | Meaning | +|---|---| +| kernel / runtime | This Python process, started by `python -m rlm.repl`. | +| host | The Rust `devo-kernel` process that owns the child and the policy. | +| cell | One `execute` request and all events through its `done`. | +| protocol v3 | The newline-delimited JSON request/event format in `repl.md`. | +| `host_request` | A kernel-to-host RPC; the host replies with `host_reply`. | +| skill | A Python package imported into the kernel and exposed to the agent. | +| harness | Persistent prompt notes, memories, skills, subagent specs, and refinement history. | +| local harness | State for one session, under `/harness/`. | +| global harness | State shared across sessions, under `/harness/`. | +| refinement | A small host-planned edit to harness state, scheduled by `refine.run`. | +| digest | The formatted harness state injected into the prompt for one turn. | +| MCP | Model Context Protocol; external tools exposed through `rlm.mcp`. | +| snapshot | A dill serialization of the persistent Python namespace. | + +## Where to start reading + +| Order | File | Why | +|---|---|---| +| 1 | [`src/rlm/repl.md`](src/rlm/repl.md) | The wire protocol and runtime behavior. | +| 2 | [`src/rlm/repl.py`](src/rlm/repl.py) | How the runtime serves requests and runs cells. | +| 3 | [`src/rlm/__init__.py`](src/rlm/__init__.py) | The public `rlm` facade and typed host helpers. | +| 4 | [`src/rlm/bash.py`](src/rlm/bash.py) | The host-owned Bash handle. | +| 5 | [`src/rlm/harness.py`](src/rlm/harness.py) | Kernel-side harness CRUD. | +| 6 | `crates/harness/src/state.rs` | The Rust side of the harness schema and file IO. | +| 7 | `crates/harness/src/digest.rs` | Prompt digest and compaction strip/reattach. | +| 8 | `crates/harness/src/apply.rs` | Refine proposals and drift-checked apply. | +| 9 | `crates/server/src/runtime/refine.rs` | Host scheduling, planning, and apply pipeline. | +| 10 | [`crates/kernel/README.md`](../README.md) | The Rust host that starts and supervises this process. | + +Related documents: + +- [`crates/kernel/README.md`](../README.md) — the Rust host, sessions, and + `host_request` routing. +- [`crates/core/src/tools/handlers/ipython/startup.rs`](../../core/src/tools/handlers/ipython/startup.rs) + — environment variables passed to the kernel. +- [`crates/server/src/runtime/turn_exec/query.rs`](../../server/src/runtime/turn_exec/query.rs) + — harness digest injection at turn start. diff --git a/crates/kernel/rlm-runtime/src/rlm/__init__.py b/crates/kernel/rlm-runtime/src/rlm/__init__.py index ae949112..efecc9a8 100644 --- a/crates/kernel/rlm-runtime/src/rlm/__init__.py +++ b/crates/kernel/rlm-runtime/src/rlm/__init__.py @@ -6,7 +6,6 @@ import types from dataclasses import dataclass from pathlib import Path -import os from typing import Any from .bash import BashHandle, BashResult, bash @@ -163,16 +162,6 @@ async def request_user_input(questions: list[dict[str, Any]]) -> dict[str, Any]: return await host_request("question", {"questions": questions}) -# Credential grants (design doc §9): start the fd receiver when the host -# passed a delivery channel (fenced kernels only). -try: - from . import grants as _grants_mod - - _grants_mod.start_from_env() -except Exception: # noqa: BLE001 - grants are best-effort native I/O - _grants_mod = None - - def emit(data: dict[str, Any]) -> None: """Ship one display event (dict of MIME type -> JSON payload) to the host.""" from . import repl @@ -183,10 +172,9 @@ def emit(data: dict[str, Any]) -> None: async def read(path, *, encoding="utf-8") -> str: """Front-door file read (design doc rlm-permissions.md §6.1). - Try a direct ``open()`` first: inside the OS fence this always succeeds. - Only when the OS refuses (``OSError`` — including ``ENOENT`` for paths - hidden by a Linux mount fence, not just ``EACCES``) does this ask the host - to read the file through the approval pipeline. The kernel process is + Try a direct ``open()`` with the user's OS privileges first. On + ``OSError``, ask the host to read through the approval pipeline. + The kernel process is never restarted. A host denial surfaces as ``PermissionError`` with the host's self-explanatory message. """ @@ -194,17 +182,6 @@ async def read(path, *, encoding="utf-8") -> str: with open(path, encoding=encoding) as f: return f.read() except OSError as direct_error: - # Delivered credential (§9): a granted dirfd makes this a native - # kernel-side read — no host round-trip, no restart. - if _grants_mod is not None: - grant = _grants_mod.grant_fd_for(os.fspath(path)) - if grant is not None: - fd, _access, rel = grant - try: - with os.fdopen(os.open(rel, os.O_RDONLY, dir_fd=fd), encoding=encoding) as f: - return f.read() - except OSError: - pass # fall through to the mediated front door try: reply = await host_request( "fs.read", {"path": str(path), "errno": direct_error.errno} @@ -218,9 +195,7 @@ async def read(path, *, encoding="utf-8") -> str: async def read_once(path, *, encoding="utf-8") -> str: - """Ephemeral single-read credential (design doc §6.1/§9): ask once, the - host approves once, the bytes come back bound to the caller's context. - Nothing outlives the reply — no ACE, no dirfd, no cache, no restart.""" + """Read directly, or request a one-time host-mediated read on OS failure.""" try: with open(path, encoding=encoding) as f: return f.read() @@ -239,8 +214,7 @@ async def read_once(path, *, encoding="utf-8") -> str: async def write_once(path, content) -> None: - """Ephemeral single-write (§6.1/§9): one approval, host executes with the - kernel's context honored, nothing persists (no cache, no ACE).""" + """Write directly, or request a one-time host-mediated write on OS failure.""" if not isinstance(content, str): raise TypeError(f"content must be str, got {type(content).__name__}") try: @@ -265,7 +239,7 @@ async def write_once(path, content) -> None: async def write(path, content) -> None: """Front-door file write (design doc rlm-permissions.md §6.1). - Same contract as :func:`read`: direct write inside the fence, host-mediated + Same contract as :func:`read`: direct write with user privileges, host-mediated write (approval pipeline) when the OS refuses. ``content`` must be ``str``. """ if not isinstance(content, str): @@ -275,20 +249,6 @@ async def write(path, content) -> None: f.write(content) return except OSError as direct_error: - if _grants_mod is not None: - grant = _grants_mod.grant_fd_for(os.fspath(path)) - if grant is not None: - fd, access, rel = grant - if access == "write": - try: - fd_open = os.open( - rel, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644, dir_fd=fd - ) - with os.fdopen(fd_open, "w", encoding="utf-8", newline="") as f: - f.write(content) - return - except OSError: - pass # fall through to the mediated front door try: # `filePath` matches the mediated write tool contract; the bridge's # approval-path extraction also accepts `path`/`file_path`. diff --git a/crates/kernel/rlm-runtime/src/rlm/bash.py b/crates/kernel/rlm-runtime/src/rlm/bash.py index f31f0116..60dfbf39 100644 --- a/crates/kernel/rlm-runtime/src/rlm/bash.py +++ b/crates/kernel/rlm-runtime/src/rlm/bash.py @@ -1,1181 +1,271 @@ -"""Async-by-default shell execution: bash() spawns immediately and returns a live handle.""" - +"""Host-owned shell commands with non-blocking, cached synchronous handles.""" from __future__ import annotations import asyncio -import atexit -import functools -import json import os import secrets -import selectors -import shutil -import signal -import socket -import struct -import subprocess -import sys -import threading -import time -from collections import deque -from collections.abc import Callable, Generator, Mapping -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any, cast - -from . import _winjob -from .windows_shell import find_windows_bash - -_IS_POSIX = os.name == "posix" - -if _IS_POSIX: - import fcntl - import termios - -_HEAD_CAP = 512 * 1024 -_TAIL_CAP = 3 * 512 * 1024 -_READ_CHUNK = 65536 -# Fixed child-side fd for the status channel; POSIX shells (notably dash) only -# guarantee single-digit fds in redirection syntax. -_STATUS_FD = 9 -_OUTPUT_FD = 8 -_COMPLETION_PREFIX = b"\x1eprime-agent-complete:" -_COMPLETION_SUFFIX = b"\x1f" -# Cancelled one-shot awaits: TERM grace before the group KILL, then the bounded -# wait for a confirmed group exit before CancelledError propagates. -_CANCEL_TERM_GRACE = 0.5 -_CANCEL_KILL_WAIT = 2.0 +from typing import Any + +from .local_bash import ( + BashResult, + _consume_notice_task, + _creating_cell_waits_for, + _current_cell_completion_context, + _live_cell_owner, +) + +# Polling only refreshes cached state; synchronous access never performs RPC. +_POLL_INTERVAL = 0.1 _COMPLETION_NOTICE_COMMAND_CAP = 1000 -_ASYNCIO_WRAPPER_CALLBACKS = { - ("asyncio.tasks", "gather.._done_callback"), - ("asyncio.tasks", "shield.._inner_done_callback"), - ("asyncio.tasks", "_wait.._on_completion"), - ("asyncio.tasks", "as_completed.._on_completion"), - ("asyncio.tasks", "_release_waiter"), -} - -_live_handles: set["BashHandle"] = set() -_live_lock = threading.Lock() -_hook_installed = False -_hook_lock = threading.Lock() - - -def _current_cell_completion_context() -> tuple[asyncio.Event, asyncio.Task[Any] | None] | None: - """Get the creating REPL cell's lifecycle without coupling standalone use to repl.""" - try: - from . import repl - - if repl.is_active(): - return repl.current_cell_completion_context() - except (ImportError, RuntimeError): - pass - return None - - -def _consume_notice_task(task: asyncio.Task[None]) -> None: - """Retrieve detached notifier failures so they never become loop warnings.""" - if not task.cancelled(): - task.exception() - - -def _completion_reaches( - start: asyncio.Future[Any], targets: tuple[asyncio.Future[Any], ...] -) -> bool: - """Follow asyncio's wrapper and TaskGroup ownership callbacks.""" - pending = [start] - seen_futures: set[int] = set() - seen_values: set[int] = set() - - def collect(value: Any, depth: int = 0) -> None: - if isinstance(value, asyncio.Future): - pending.append(value) - return - identity = id(value) - if depth >= 4 or identity in seen_values: - return - seen_values.add(identity) - - nested: list[Any] = [] - if isinstance(value, asyncio.Queue): - pending.extend(value._getters) - elif isinstance(value, functools.partial): - nested.extend((value.func, value.args, value.keywords)) - elif isinstance(value, dict): - nested.extend(value.keys()) - nested.extend(value.values()) - elif isinstance(value, (tuple, list, set, frozenset)): - nested.extend(value) - else: - closure = getattr(value, "__closure__", None) or () - for cell in closure: - try: - nested.append(cell.cell_contents) - except ValueError: - pass - bound_self = getattr(value, "__self__", None) - if bound_self is not None: - nested.append(bound_self) - for item in nested: - collect(item, depth + 1) - - while pending: - future = pending.pop() - if any(future is target for target in targets): - return True - if id(future) in seen_futures: - continue - seen_futures.add(id(future)) - for entry in getattr(future, "_callbacks", None) or (): - callback = entry[0] if isinstance(entry, tuple) else entry - base = callback.func if isinstance(callback, functools.partial) else callback - identity = (getattr(base, "__module__", None), getattr(base, "__qualname__", None)) - if identity in _ASYNCIO_WRAPPER_CALLBACKS: - collect(callback) - elif identity == ("asyncio.tasks", "_AsCompletedIterator._handle_completion"): - collect(base.__self__._done) - elif identity == (None, "Task.task_wakeup"): - task = getattr(callback, "__self__", None) - if isinstance(task, asyncio.Task): - pending.append(task) - elif identity == ("asyncio.taskgroups", "TaskGroup._on_task_done"): - parent = getattr(getattr(callback, "__self__", None), "_parent_task", None) - if isinstance(parent, asyncio.Future): - pending.append(parent) - return False - - -def _creating_cell_waits_for( - owner: asyncio.Task[Any] | None, awaiter: asyncio.Task[Any] | None -) -> bool: - """Return whether the cell owner directly or transitively waits for awaiter.""" - if owner is None or awaiter is None: - return False - if owner is awaiter: - return True - waiter = getattr(owner, "_fut_waiter", None) - targets: tuple[asyncio.Future[Any], ...] = (owner,) - if isinstance(waiter, asyncio.Future): - targets += (waiter,) - return _completion_reaches(awaiter, targets) - - -def _live_cell_owner() -> asyncio.Task[Any] | None: - """Body task of the cell executing right now, ignoring detached context copies.""" - try: - from . import repl - - if repl.is_active(): - return repl.active_cell_task() - except (ImportError, RuntimeError): - pass - return None - - -@dataclass(frozen=True) -class BashResult: - exit_code: int - output: str - duration: float - - -class _BoundedBuffer: - """First _HEAD_CAP bytes plus a rolling _TAIL_CAP-byte tail; the middle is dropped.""" - - def __init__(self) -> None: - self._head = bytearray() - self._tail: deque[bytes] = deque() - self._tail_size = 0 - self._dropped = 0 - self._lock = threading.Lock() - - def write(self, chunk: bytes) -> None: - with self._lock: - if len(self._head) < _HEAD_CAP: - take = _HEAD_CAP - len(self._head) - self._head.extend(chunk[:take]) - chunk = chunk[take:] - if not chunk: - return - self._tail.append(chunk) - self._tail_size += len(chunk) - # Trim the oldest chunk instead of dropping it whole so exactly _TAIL_CAP bytes stay. - while self._tail_size > _TAIL_CAP: - excess = self._tail_size - _TAIL_CAP - oldest = self._tail[0] - if len(oldest) <= excess: - self._tail.popleft() - self._tail_size -= len(oldest) - self._dropped += len(oldest) - else: - self._tail[0] = oldest[excess:] - self._tail_size -= excess - self._dropped += excess - - def size(self) -> int: - with self._lock: - return len(self._head) + self._tail_size - - def text(self) -> str: - with self._lock: - head = bytes(self._head) - tail = b"".join(self._tail) - dropped = self._dropped - if not dropped: - return (head + tail).decode("utf-8", errors="replace") - marker = f"\n... [{dropped} bytes dropped] ...\n" - return head.decode("utf-8", errors="replace") + marker + tail.decode("utf-8", errors="replace") class BashHandle: - """Live handle to a shell command; await it for the BashResult. + """Awaitable host command. Creation schedules start; it does not block on approval. - A handle awaited before any other API use (the `await bash(cmd)` one-shot - form, including `h = bash(cmd)` awaited immediately) owns the command: - cancelling that await kills the process group. Touching .pid/.running/ - .output()/.tail()/.poll()/.kill() first marks the handle as a background - handle; later awaits only wait and cancelling them leaves it running. + Accessors return the latest snapshot. ``handle_id`` is None until admission; + ``pid`` is always None because a host handle is not an OS process identifier. + ``kill()`` schedules host cancellation, and awaiting the handle waits for exit. """ - def __init__(self, command: str) -> None: + def __init__(self, command: str, payload: dict[str, Any]) -> None: + loop = asyncio.get_running_loop() self.command = command - completion_context = _current_cell_completion_context() - self._creating_cell_finished = completion_context[0] if completion_context else None - self._creating_cell_task = completion_context[1] if completion_context else None - self._awaited_by_creating_cell = False - self._buffer = _BoundedBuffer() - self._done = threading.Event() - self._eof = threading.Event() - self._completion_terminal = threading.Event() - self._completion_output: str | None = None - self._completion_lock = threading.Lock() - self._completion_pending = b"" - self._status: int | None = None - self._status_known = threading.Event() - self._reaped = False + self._handle_id: str | None = None + self._output = "" self._result: BashResult | None = None - self._callbacks: list[Callable[[], None]] = [] - self._reap_callback: Callable[[], None] | None = None - self._result_consumed = False - self._consumed_notice: Callable[[], None] | None = None - self._callback_lock = threading.Lock() - # Serializes kill/reap so a pid fallback can never outlive the process handle. - self._kill_lock = threading.Lock() - self._started = time.monotonic() - # POSIX: own process group so kill() signals the whole pipeline; Windows - # contains the tree in a kill-on-close job object. - self._status_read = -1 - self._wake_read = -1 - self._wake_write = -1 - # True only while the pump moves a chunk from the pipe into the buffer. - self._pump_transfer = False - self._job: int | None = None - self._completion_marker: bytes | None = None - status_write = -1 - if _IS_POSIX: - # Full-duplex status channel: the child end rides in as stdin (fd 0) - # and the script remaps it to _STATUS_FD before swapping in /dev/null - # (dash rejects multi-digit fds in redirections at parse time). The - # parent end doubles as the gate: the child blocks on it until the - # pid is journaled, so a kernel kill in that window cannot leak an - # unjournaled command (parent death closes the socket -> child exits). - parent_sock, child_sock = socket.socketpair() - self._status_read = parent_sock.detach() - status_write = child_sock.detach() - try: - self._wake_read, self._wake_write = os.pipe() - except BaseException: - os.close(self._status_read) - os.close(status_write) - raise - completion_token = secrets.token_hex(32) - # Halves stop passive echoes; a deliberate forgery freezes only this call while later bytes stay live. - token_midpoint = len(completion_token) // 2 - self._completion_marker = ( - _COMPLETION_PREFIX + completion_token.encode("ascii") + _COMPLETION_SUFFIX - ) - script = _status_script( - _with_prefix(command), - completion_token[:token_midpoint], - completion_token[token_midpoint:], - ) - else: - # Windows lacks a foreground-status channel, so its exit drain stays best-effort. - script = _with_prefix(command) - self._job = _winjob.create_job() - if self._job is None: - # Nothing spawned yet, so nothing can leak: refuse to start. - raise RuntimeError("bash(): Windows job containment could not be established") - try: - self._proc: subprocess.Popen[bytes] | _winjob.JobProcess - if _IS_POSIX: - self._proc = subprocess.Popen( - [_shell(), "-c", script], - cwd=os.getcwd(), - env=_child_env(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - start_new_session=True, - stdin=status_write, - ) - else: - self._proc = _winjob.spawn_in_job( - self._job, [_shell(), "-c", script], cwd=os.getcwd(), env=_child_env() - ) - except BaseException: - for fd in (self._status_read, self._wake_read, self._wake_write): - if fd >= 0: - os.close(fd) - if self._job is not None: - job, self._job = self._job, None - _winjob.close(job) - raise - finally: - if status_write >= 0: - os.close(status_write) - self._pid: int = self._proc.pid + self._terminal = False + self._error: Exception | None = None self._released = False - with _live_lock: - _live_handles.add(self) - enrolled = _record_journal(self._pid, active=True) - if not enrolled: - # Fail closed: a configured journal that cannot enroll the pid must - # not let the command run (the host reaper would never see it). - self._abort_spawn() - raise RuntimeError( - "bash(): orphan-journal enrollment failed (journal configured but the " - "pid could not be recorded); the spawned process was killed" - ) - if _IS_POSIX: - # Journal first, then open the gate: the child does not run the user - # command until this byte arrives. A failed write means the child - # already died; the status/EOF paths report that normally. - try: - os.write(self._status_read, b"\n") - except OSError: - pass - else: - # The child is already job-contained and journaled; resume is the - # last step. A failed resume would strand a permanently suspended - # child: fail closed via the assigned job. - if not cast("_winjob.JobProcess", self._proc).resume(): - self._abort_spawn() - raise RuntimeError("bash(): Windows job containment could not be established") - threading.Thread(target=self._pump, daemon=True).start() - threading.Thread(target=self._report, daemon=True).start() - threading.Thread(target=self._watch, daemon=True).start() - self._schedule_background_completion_notice() + self._consumed = False + self._notice_accepted = False + self._awaited_by_creating_cell = False + self._started = asyncio.Event() + self._admission: asyncio.Future[None] = loop.create_future() + self._admission.add_done_callback(_consume_notice_task) + from . import repl + repl.register_bash_admission(self._admission) + self._kill_task: asyncio.Task[None] | None = None + context = _current_cell_completion_context() + self._cell_finished = context[0] if context else None + self._cell_task = context[1] if context else None + self._activity = {"id": secrets.token_hex(16), "active": True} + if context: + from . import repl + repl.emit({"application/vnd.bash-activity+json": self._activity}) + self._task = loop.create_task(self._run(payload)) + self._task.add_done_callback(_consume_notice_task) + if context: + self._notice_task = loop.create_task(self._notify_completion()) + self._notice_task.add_done_callback(_consume_notice_task) @property - def pid(self) -> int: + def handle_id(self) -> str | None: self._released = True - return self._pid + return self._handle_id @property - def running(self) -> bool: - # Group liveness, matching kill()'s guard and the journal; poll()/await - # keep foreground result semantics after `cmd &` returns early. + def pid(self) -> None: self._released = True - return not self._reaped - - def output(self) -> str: - self._released = True - self._note_result_consumed() - return self._buffer.text() + return None - def tail(self, n: int = 50) -> str: + @property + def running(self) -> bool: self._released = True - self._note_result_consumed() - return "\n".join(self._buffer.text().splitlines()[-n:]) + return not self._task.done() def poll(self) -> BashResult | None: self._released = True - self._note_result_consumed() - return self._result if self._done.is_set() else None + self._raise_error() + self._note_consumed() + return self._result - def kill(self, sig: int = signal.SIGTERM, grace: float = 5.0) -> None: - # Guard on group death, not _done: kill() must still reach a lingering - # background group after the foreground result was already delivered. + def output(self) -> str: self._released = True - if self._reaped: - return - if not _IS_POSIX: - with self._kill_lock: - if self._reaped: # re-check: _watch may have reaped while we waited - return - if self._job is not None and _winjob.terminate(self._job): - return - # TerminateJobObject failed or reap raced: taskkill fallback. - if not _taskkill_tree(self._pid): - try: - self._proc.kill() - except OSError: - pass - return - _signal_group(self._pid, sig) - if sig == signal.SIGTERM: - timer = threading.Timer(grace, self._force_kill) - timer.daemon = True - timer.start() - - def _force_kill(self) -> None: - if not self._reaped: - _signal_group(self._pid, signal.SIGKILL) - - def _pump(self) -> None: - stdout = self._proc.stdout - assert stdout is not None - if not _IS_POSIX: - try: - while chunk := stdout.read1(_READ_CHUNK): - self._buffer.write(chunk) - except (OSError, ValueError): - pass - stdout.close() - self._eof.set() - return - fd = stdout.fileno() - try: - with selectors.DefaultSelector() as sel: - sel.register(fd, selectors.EVENT_READ) - while True: - sel.select() - self._pump_transfer = True - try: - chunk = os.read(fd, _READ_CHUNK) - if not chunk: - break - self._consume_output(chunk) - finally: - self._pump_transfer = False - except (OSError, ValueError): - pass - self._abandon_completion() - try: - stdout.close() - except OSError: - pass - self._eof.set() - - def _consume_output(self, chunk: bytes) -> None: - marker = self._completion_marker - assert marker is not None - with self._completion_lock: - if self._completion_terminal.is_set(): - self._buffer.write(chunk) - return - data = self._completion_pending + chunk - marker_at = data.find(marker) - if marker_at >= 0: - self._buffer.write(data[:marker_at]) - self._completion_pending = b"" - self._completion_output = self._buffer.text() - self._completion_terminal.set() - self._buffer.write(data[marker_at + len(marker) :]) - return - retained = 0 - for size in range(min(len(data), len(marker) - 1), 0, -1): - if data.endswith(marker[:size]): - retained = size - break - self._buffer.write(data[:-retained] if retained else data) - self._completion_pending = data[-retained:] if retained else b"" - - def _abandon_completion(self) -> None: - with self._completion_lock: - if self._completion_terminal.is_set(): - return - self._buffer.write(self._completion_pending) - self._completion_pending = b"" - self._completion_terminal.set() + self._note_consumed() + return self._output - def _wait_for_completion(self) -> str | None: - self._completion_terminal.wait() - return self._completion_output + def tail(self, n: int = 50) -> str: + output = self.output() + return "\n".join(output.splitlines()[-n:]) if n > 0 else "" - def _report(self) -> None: - # Finalize at foreground completion (status channel), not EOF, so - # `cmd &` does not hang the await; the shell then `wait`s for its - # background jobs, keeping the journaled group identity alive. - status: int | None = None - try: - status = self._read_status() - # Reserve the delivered status before draining so a shell death during - # the drain window cannot override it with wait()'s signal exit code. - with self._callback_lock: - self._status = status - finally: - # _watch blocks on this event without a timeout, so every exit path - # (parsed status, EOF, garbage, exception) must set it. - self._status_known.set() - if status is not None: - output = self._wait_for_completion() - if output is None: - self._drain_grace() - self._finalize(status, output) + def kill(self) -> None: + """Schedule host cancellation, including when start/approval is pending. - def _watch(self) -> None: - # Observe shell death independently of the status socket: an early - # `exit`/`exec`/`set -e`/fatal signal skips `printf`, and background - # children can hold the socket open past the shell's lifetime. - exit_code = self._proc.wait() - if self._wake_write >= 0: - # Unblock _read_status: background children can hold the status socket - # open past the shell's lifetime via bash's saved-fd duplicate. + Signals and grace periods belong to the host, not this opaque handle. + """ + self._released = True + if self._kill_task is None: + self._kill_task = asyncio.create_task(self._kill()) + self._kill_task.add_done_callback(_consume_notice_task) + + async def _kill(self) -> None: + from . import host_request + await self._started.wait() + if self._handle_id is not None and self._result is None: try: - os.write(self._wake_write, b"x") - except OSError: - pass - os.close(self._wake_write) - # _report always sets _status_known (try/finally), so wait indefinitely: - # a slow reporter can never lose a delivered status to wait()'s code. - self._status_known.wait() - with self._callback_lock: - delivered = self._status - if delivered is None and not self._done.is_set(): - self._abandon_completion() - self._drain_grace() - self._finalize(exit_code) - with self._kill_lock: - delivered = self._reap_group() - self._reaped = True - if not _IS_POSIX: - # Reaped: pid fallbacks are gone, so the handle may finally close. - cast("_winjob.JobProcess", self._proc).close() - with self._callback_lock: - callback, self._reap_callback = self._reap_callback, None - if callback is not None: - callback() - if delivered: - _record_journal(self._pid, active=False) - with _live_lock: - _live_handles.discard(self) - - def _reap_group(self) -> bool: - # Group liveness, not leader death, gates the inactive record: members - # that outlive the leader would leak behind a stale journal anchor. - if not _IS_POSIX: - # Terminate then close the last handle: kill-on-close reaps - # stragglers. An unproven terminate falls back to taskkill; if - # that also fails the record stays active for the host reaper. - delivered = False - if self._job is not None: - delivered = _winjob.terminate(self._job) - job, self._job = self._job, None - _winjob.close(job) - return delivered or _taskkill_tree(self._pid) - try: - os.killpg(self._pid, 0) - except ProcessLookupError: - return True # group already gone - except PermissionError: - pass - return _signal_group(self._pid, signal.SIGKILL) + await host_request("bash.kill", {"handle_id": self._handle_id}) + except Exception as exc: + self._error = exc + raise - def _read_status(self) -> int | None: - if self._status_read < 0: - return None + def _raise_error(self) -> None: + if self._error is not None: + raise self._error + + def _apply(self, snapshot: dict[str, Any]) -> bool: + handle_id = snapshot.get("handle_id") + output = snapshot.get("output") + running = snapshot.get("running") + if not isinstance(handle_id, str) or not handle_id or not isinstance(output, str) or not isinstance(running, bool): + raise RuntimeError("bash host returned an invalid snapshot") + if self._handle_id is not None and handle_id != self._handle_id: + raise RuntimeError("bash host changed the command handle") + self._handle_id = handle_id + self._output = output + self._terminal = not running + if not running: + code, duration = snapshot.get("exit_code"), snapshot.get("duration") + if code is None: + error = snapshot.get("error") or "host command ended without an exit status" + raise RuntimeError(f"{error}\n{output}".rstrip()) + if not isinstance(code, int) or not isinstance(duration, (int, float)): + raise RuntimeError("bash host returned an invalid completion") + self._result = BashResult(code, output, float(duration)) + return running + + async def _run(self, payload: dict[str, Any]) -> BashResult: + from . import host_request try: - # DefaultSelector (kqueue/epoll) instead of select(): select() rejects - # fds >= FD_SETSIZE (1024) even when the process fd limit is higher. - with selectors.DefaultSelector() as sel: - sel.register(self._status_read, selectors.EVENT_READ) - sel.register(self._wake_read, selectors.EVENT_READ) - line = b"" - while b"\n" not in line: - ready = {key.fd for key, _ in sel.select()} - # Prefer status bytes: any status write happens before shell exit, - # so it is already readable whenever the wake fd fires. - if self._status_read not in ready: - break # shell died without writing a status - chunk = os.read(self._status_read, 64) - if not chunk: - break # EOF without a full status line - line += chunk - return int(line) - except (OSError, ValueError): - return None + running = self._apply(await host_request("bash.start", payload)) + self._started.set() + self._admission.set_result(None) + while running: + await asyncio.sleep(_POLL_INTERVAL) + running = self._apply(await host_request("bash.poll", {"handle_id": self._handle_id})) + self._raise_error() + assert self._result is not None + return self._result + except Exception as exc: + self._error = exc + if not self._admission.done(): + self._admission.set_exception(exc) + elif self._handle_id is not None and not self._terminal: + self.kill() + raise finally: - os.close(self._status_read) - os.close(self._wake_read) - - def _drain_grace(self) -> None: - # Best-effort fallback when process exit/EOF arrives without a sentinel. - deadline = time.monotonic() + 0.5 - size = self._buffer.size() - while time.monotonic() < deadline: - if self._eof.wait(0.05): - return - # A chunk between pipe read and buffer commit (transfer flag) is - # invisible to both FIONREAD and the buffer size; wait it out. - if self._pipe_pending() or self._pump_transfer: - size = self._buffer.size() - continue - current = self._buffer.size() - if current == size: - return - size = current - - def _pipe_pending(self) -> bool: - # POSIX only: FIONREAD on the capture pipe; Windows keeps the - # quiescence heuristic (best-effort parity). - if not _IS_POSIX or self._eof.is_set(): - return False - stdout = self._proc.stdout - if stdout is None: - return False - try: - pending = struct.unpack("i", fcntl.ioctl(stdout.fileno(), termios.FIONREAD, struct.pack("i", 0)))[0] - except (OSError, ValueError): - return False - return pending > 0 - - def _finalize(self, exit_code: int, output: str | None = None) -> None: - with self._callback_lock: - if self._done.is_set(): - return - self._result = BashResult( - exit_code=exit_code, - output=self._buffer.text() if output is None else output, - duration=time.monotonic() - self._started, - ) - self._done.set() - callbacks = self._callbacks - self._callbacks = [] - for callback in callbacks: - callback() - - def _add_done_callback(self, callback: Callable[[], None]) -> None: - with self._callback_lock: - if not self._done.is_set(): - self._callbacks.append(callback) - return - callback() + self._started.set() + if self._terminal and self._handle_id is not None: + try: + await host_request("bash.forget", {"handle_id": self._handle_id}) + except (OSError, RuntimeError): + pass # Host TTL is the backstop; cached results remain usable. - def _note_result_consumed(self, awaiter: asyncio.Task[Any] | None = None) -> None: - """Record a result read that reaches the model: only reads during a live - cell count (a detached reader between turns must keep the notice — it is - the idle session's only wake-up), and an awaiting reader must be one the - live cell waits for.""" - if not self._done.is_set(): - return + def _note_consumed(self, awaiter: asyncio.Task[Any] | None = None) -> None: owner = _live_cell_owner() - if owner is None: + if self._result is None or self._consumed or owner is None: return if awaiter is not None and not _creating_cell_waits_for(owner, awaiter): return - with self._callback_lock: - if self._result_consumed: - return - self._result_consumed = True - notice, self._consumed_notice = self._consumed_notice, None - if notice is not None: - notice() - - def _schedule_background_completion_notice(self) -> None: - cell_finished = self._creating_cell_finished - if cell_finished is None: - return + self._consumed = True + if self._notice_accepted: + task = asyncio.create_task(self._withdraw_notice()) + task.add_done_callback(_consume_notice_task) + + def _notice_payload(self) -> dict[str, Any]: + command = self.command + if len(command) > _COMPLETION_NOTICE_COMMAND_CAP: + command = command[:_COMPLETION_NOTICE_COMMAND_CAP] + "\n... [command truncated]" + return {"handle_id": self._handle_id, "command": command} + + async def _withdraw_notice(self) -> None: + from . import host_request try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - from . import repl - - activity = {"id": secrets.token_hex(16), "pid": self._pid, "active": True} - # Publish synchronously before bash() returns and the creating cell can end. - repl.emit({"application/vnd.devo.bash-activity+json": activity}) - notice = self._notify_background_completion(cell_finished, activity) - try: - task = loop.create_task(notice) - except BaseException: - self.kill(signal.SIGKILL if _IS_POSIX else signal.SIGTERM) - notice.close() - repl.emit({"application/vnd.devo.bash-activity+json": {**activity, "active": False}}) - raise - task.add_done_callback(_consume_notice_task) - - async def _notify_background_completion( - self, cell_finished: asyncio.Event, activity: dict[str, Any] - ) -> None: - from . import repl - - try: - result = await self._wait() - await self._wait_reaped() - # The cell may do other work before awaiting this handle. Do not classify - # it as detached until that whole cell has crossed its completion barrier. - await cell_finished.wait() - if self._awaited_by_creating_cell or self._result_consumed or not repl.is_active(): - return - command = self.command - if len(command) > _COMPLETION_NOTICE_COMMAND_CAP: - command = command[:_COMPLETION_NOTICE_COMMAND_CAP] + "\n... [command truncated]" - reply = await repl.host_request( - { - "type": "bash.completed", - "pid": self._pid, - "command": command, - "exitCode": result.exit_code, - } - ) - if isinstance(reply, dict) and reply.get("status") == "ok": - # Notice accepted by the host; later reads must ask it to withdraw. - self._arm_consumed_notice(command) - else: - sys.stderr.write( - f"Background bash completion follow-up for pid {self._pid} was not accepted. " - "Inspect the saved handle with poll(), output(), or tail().\n" - ) + await host_request("bash.consumed", self._notice_payload()) except (OSError, RuntimeError): - # Standalone runtimes have no host handler, and teardown can close - # the bridge while a process is finishing. Shell results stay usable. - return - finally: - # Reap and deliver (or report rejection) before releasing kernel residency. - repl.emit({"application/vnd.devo.bash-activity+json": {**activity, "active": False}}) - - def _arm_consumed_notice(self, command: str) -> None: - # Armed only post-acceptance: the withdrawal can never overtake its notice. - loop = asyncio.get_running_loop() - - def dispatch() -> None: - def start() -> None: - task = loop.create_task(self._notify_result_consumed(command)) - task.add_done_callback(_consume_notice_task) - - try: - loop.call_soon_threadsafe(start) - except RuntimeError: - pass # notifying loop already closed - - with self._callback_lock: - if not self._result_consumed: - self._consumed_notice = dispatch - return - dispatch() - - async def _notify_result_consumed(self, command: str) -> None: - from . import repl + pass - if not repl.is_active(): - return + async def _notify_completion(self) -> None: + from . import host_request, repl try: - await repl.host_request( - {"type": "bash.consumed", "pid": self._pid, "command": command} - ) - except (OSError, RuntimeError): - return # bridge closed at teardown; old hosts error-reply — both fine - - async def _wait_reaped(self) -> None: - loop = asyncio.get_running_loop() - future: asyncio.Future[None] = loop.create_future() - - def wake() -> None: - try: - loop.call_soon_threadsafe(lambda: future.done() or future.set_result(None)) - except RuntimeError: - pass - - with self._callback_lock: - if self._reaped: + result = await asyncio.shield(self._task) + assert self._cell_finished is not None + await self._cell_finished.wait() + if self._awaited_by_creating_cell or self._consumed or not repl.is_active(): return - self._reap_callback = wake - try: - await future + await host_request("bash.completed", {**self._notice_payload(), "exitCode": result.exit_code}) + self._notice_accepted = True + if self._consumed: + await self._withdraw_notice() + except (OSError, RuntimeError) as exc: + import sys + sys.stderr.write( + f"Background bash failed for {self._handle_id!r}: {exc}. " + "Inspect the saved handle for details.\n" + ) finally: - with self._callback_lock: - if self._reap_callback is wake: - self._reap_callback = None + repl.emit({"application/vnd.bash-activity+json": { + **self._activity, "handle_id": self._handle_id, "active": False, + }}) async def _wait(self) -> BashResult: - # Asyncio-native wakeup: no executor thread is parked for the command's - # duration, so many concurrent awaits cannot exhaust the default pool. - loop = asyncio.get_running_loop() - fut: asyncio.Future[None] = loop.create_future() - - def _wake() -> None: - try: - loop.call_soon_threadsafe(lambda: fut.done() or fut.set_result(None)) - except RuntimeError: - pass # awaiting loop already closed - - self._add_done_callback(_wake) - await fut - assert self._result is not None - return self._result - - async def _wait_owned(self) -> BashResult: - # One-shot `await bash(cmd)` owns the process: a cancelled await (e.g. - # a kernel interrupt) must not leave the command running. TERM, bounded - # grace, group KILL, then a bounded confirmed-exit wait before the - # CancelledError propagates, so no side effect can land after it. - try: - return await self._wait() - except asyncio.CancelledError: - # Signal synchronously first: even if the cleanup awaits below are - # re-cancelled, TERM is already delivered and the escalation timer - # armed. The confirm wait runs as a shielded task so repeated - # cancels of this task cannot skip it (they re-raise into awaits - # inside this except block); the loop re-awaits until it finishes - # (the confirm coroutine itself is bounded). - self.kill(grace=_CANCEL_TERM_GRACE) - confirm = asyncio.ensure_future(self._confirm_group_exit()) - while not confirm.done(): - try: - await asyncio.shield(confirm) - except asyncio.CancelledError: - continue - raise - - async def _confirm_group_exit(self) -> None: - if not await self._await_group_death(_CANCEL_TERM_GRACE): - if _IS_POSIX: - _signal_group(self._pid, signal.SIGKILL) - else: - # kill() holds the escalation lock; to_thread keeps the loop free. - await asyncio.to_thread(self.kill) - await self._await_group_death(_CANCEL_KILL_WAIT) - - def _group_alive(self) -> bool: - if not _IS_POSIX: - job = self._job # snapshot: _watch may clear it concurrently - if job is not None: - # Job accounting sees detached descendants a dead leader hides. - empty = _winjob.is_empty(job) - if empty is not None: - return not empty - return self._proc.poll() is None - try: - os.killpg(self._pid, 0) - except ProcessLookupError: - return False - except PermissionError: - pass - return True - - async def _await_group_death(self, timeout: float) -> bool: - deadline = time.monotonic() + timeout - while self._group_alive(): - if time.monotonic() >= deadline: - return False - await asyncio.sleep(0.02) - return True - - def _abort_spawn(self) -> None: - # Enrollment or containment failed before the gate opened (POSIX) or - # while the child is still suspended, before resume (Windows): kill - # the child and unwind the handle before threads start. - if _IS_POSIX: - for fd in (self._status_read, self._wake_read, self._wake_write): - if fd >= 0: - try: - os.close(fd) - except OSError: - pass - self._status_read = self._wake_read = self._wake_write = -1 - delivered = _signal_group(self._pid, signal.SIGKILL) - else: - with self._kill_lock: - delivered = False - if self._job is not None: - delivered = _winjob.terminate(self._job) - job, self._job = self._job, None - _winjob.close(job) - if not delivered: - # Pre-resume abort: the never-run leader has no descendants, so a - # delivered kill retires the journal record. - try: - self._proc.kill() - delivered = True - except OSError: - pass - if self._proc.stdout is not None: - self._proc.stdout.close() - # The blocking wait stays outside the lock: hProcess is still open, so a - # concurrent raw-pid fallback stays pinned to the right process. - try: - self._proc.wait(timeout=5) - except (OSError, subprocess.SubprocessError): - pass - with self._kill_lock: - self._reaped = True - if not _IS_POSIX: - # Reaped commits before close: later lock holders skip raw-pid fallbacks. - cast("_winjob.JobProcess", self._proc).close() - with _live_lock: - _live_handles.discard(self) - if delivered: - _record_journal(self._pid, active=False) - - def __await__(self) -> Generator[Any, None, BashResult]: - # A handle awaited before any other API use is a one-shot command tied - # to the await (kill-on-cancel); touching the handle API first marks it - # as a deliberate background handle whose awaits only wait. - try: - current_task = asyncio.current_task() - except RuntimeError: - current_task = None - creating_cell_waited = _creating_cell_waits_for(self._creating_cell_task, current_task) owned = not self._released - wait = self._wait_owned() if owned else self._wait() self._released = True - completed = False + awaiter = asyncio.current_task() try: - result = yield from wait.__await__() - completed = True - return result - finally: - if (completed or owned) and ( - creating_cell_waited - or _creating_cell_waits_for(self._creating_cell_task, current_task) - ): + result = await asyncio.shield(self._task) + self._note_consumed(awaiter) + if _creating_cell_waits_for(self._cell_task, awaiter): self._awaited_by_creating_cell = True - if completed: - self._note_result_consumed(current_task) - - def __repr__(self) -> str: - state = f"exit_code={self._result.exit_code}" if self._result else "running" - return f"" - - -def bash(command: str) -> BashHandle: - """Start a shell command immediately; await the handle for the result. - - `await bash(cmd)` is a one-shot: cancelling the await (e.g. an interrupt) - kills the command's process group. `h = bash(cmd)` used as a background - handle (any .pid/.running/.output()/.tail()/.poll()/.kill() access before - the first await) survives cancellation; awaiting it only waits. Leak - containment is per-platform: process groups plus the orphan journal on - POSIX; a kill-on-close job object on Windows entered while the child is - still suspended, so no descendant can escape it and kill()/crash cleanup - are unconditional -- bash() raises if containment cannot be established. - Output written after the completion fence (e.g. by an EXIT trap or a - background job) is not in BashResult.output but stays visible via - handle.output()/tail(). - """ - if not isinstance(command, str) or not command: - raise TypeError("command must be a non-empty str") - _install_shutdown_hook() - return BashHandle(command) - - -def _shell(environment: Mapping[str, str] | None = None) -> str: - # Read per call so env changes made in the REPL apply to later commands. - environment = os.environ if environment is None else environment - override = environment.get("DEVO_BASH_SHELL") - if override: - if not os.path.isabs(override): - raise ValueError("DEVO_BASH_SHELL must be an absolute path") - return override - if not _IS_POSIX: - # Installer registrations and standard locations also cover custom - # drives. Never use a workspace-controlled PATH to choose the shell. - if shell := find_windows_bash(environment): - return shell - raise RuntimeError( - "Install Git for Windows to use bash(), or set DEVO_BASH_SHELL " - "to the absolute path of a POSIX shell" - ) - # PATH fallback only serves bare/standalone POSIX runtime use: the host - # always injects DEVO_BASH_SHELL (an absolute path) when a shell exists. - shell = shutil.which("bash") - return shell or "/bin/sh" - - -def _with_prefix(command: str) -> str: - prefix = os.environ.get("DEVO_BASH_COMMAND_PREFIX") - return f"{prefix}\n{command}" if prefix else command - - -def _fence_printf() -> str: - # `\command -p printf` defeats alias expansion but not a user-defined shell - # function named `command`, which would swallow both fence frames and leave - # the await hanging until the shell dies (wedged behind background jobs). A - # slash-qualified command name bypasses function and alias lookup for - # ordinary command names, so resolve printf on the system default utility PATH. - path = shutil.which("printf", path=os.confstr("CS_PATH") or os.defpath) - if path and "'" not in path: - return f"'{path}'" - return "\\command -p printf" - - -def _status_script(command: str, completion_a: str, completion_b: str) -> str: - # Closed control fds preserve background behavior; supported shells atomically write the frame. - emit = _fence_printf() - return ( - f"exec {_STATUS_FD}>&0 {_OUTPUT_FD}>&1 0&- {_STATUS_FD}>&-\n" - "__prime_status=$?\n" - "\\set +x\n" - f"{emit} '\\036prime-agent-complete:%s%s\\037' " - f"'{completion_a}' '{completion_b}' >&{_OUTPUT_FD} || exit \"$__prime_status\"\n" - f"{emit} '%s\\n' \"$__prime_status\" >&{_STATUS_FD}\n" - f"exec {_OUTPUT_FD}>&- {_STATUS_FD}>&-\n" - "wait\n" - 'exit "$__prime_status"\n' - ) - - -def _child_env() -> dict[str, str]: - return {**os.environ, "NO_COLOR": "1", "TERM": "dumb", "CLICOLOR": "0", "FORCE_COLOR": "0"} - - -def _signal_group(pid: int, sig: int) -> bool: - """True when the signal was delivered or the group is already gone.""" - try: - os.killpg(pid, sig) - except ProcessLookupError: - return True # already dead: safe to mark the journal record inactive - except OSError: - return False # not delivered: the record must stay active for the host reaper - return True - - -def _system32(*parts: str) -> str: - # Absolute paths for Windows helper binaries: PATH (and CWD on Windows - # CPython) lookup could resolve a planted taskkill.exe/powershell.exe. - root = os.environ.get("SystemRoot", r"C:\Windows") - return os.path.join(root, "System32", *parts) - - -def _helper_env() -> dict[str, str]: - return {**os.environ, "NoDefaultCurrentDirectoryInExePath": "1"} + return result + except asyncio.CancelledError: + if owned: + # Keep start alive so its eventual ID can be cancelled. Never lose + # an admitted process by cancelling the start RPC's reply future. + self.kill() + if _creating_cell_waits_for(self._cell_task, awaiter): + self._awaited_by_creating_cell = True + raise + def __await__(self): + return self._wait().__await__() -def _taskkill_tree(pid: int) -> bool: - # Windows has no process groups to signal; taskkill /T kills the whole tree. - try: - return ( - subprocess.run( - [_system32("taskkill.exe"), "/PID", str(pid), "/T", "/F"], - capture_output=True, - timeout=10, - env=_helper_env(), - ).returncode - == 0 + def __repr__(self) -> str: + state = "failed" if self._error else ( + f"exit_code={self._result.exit_code}" if self._result else + "pending" if self._handle_id is None else "running" ) - except (OSError, subprocess.SubprocessError): - return False - - -def _process_start_id(pid: int) -> str | None: - if os.name == "nt": - # Mirrors getWindowsProcessStartId in session-lease.ts byte-for-byte so - # the host's identity comparison matches the journaled string. - try: - out = subprocess.run( - [ - _system32("WindowsPowerShell", "v1.0", "powershell.exe"), - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - f"([System.Diagnostics.Process]::GetProcessById({pid})).StartTime.ToUniversalTime().Ticks", - ], - capture_output=True, - text=True, - timeout=5, - env=_helper_env(), - ).stdout.strip() - return f"win:{out}" if out.isdigit() else None - except (OSError, subprocess.SubprocessError): - return None - try: - with open(f"/proc/{pid}/stat", "r") as f: - stat = f.read() - fields = stat[stat.rindex(")") + 2 :].split(" ") - if len(fields) > 19 and fields[19]: - return f"proc:{fields[19]}" - except (OSError, ValueError): - pass - try: - # macOS has no /proc; /bin/ps is always present there, so use the - # absolute path (bare `ps` stays only as the exotic-POSIX last resort). - ps = "/bin/ps" if sys.platform == "darwin" else "ps" - out = subprocess.run( - [ps, "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, timeout=5 - ).stdout.strip() - return f"ps:{out}" if out else None - except (OSError, subprocess.SubprocessError): - return None - - -def _record_journal(pid: int, active: bool) -> bool: - # Returns False only when the journal is configured but enrollment failed; - # active-record callers must then fail closed. Active records always carry - # a processStartId so host reaping stays identity-verified. - path = os.environ.get("DEVO_INTERNAL_ORPHAN_PROCESS_JOURNAL") - owner = os.environ.get("DEVO_KERNEL_OWNER_PID") - if not path or not owner: - return True - try: - owner_pid = int(owner) - except ValueError: - return False - start_id = _process_start_id(pid) if active else None - if active and start_id is None: - return False - record: dict[str, Any] = { - "version": 1, - "pid": pid, - "ownerPid": owner_pid, - # The host reaps bash children per kernel pid when it kills or loses this kernel. - "kernelPid": os.getpid(), - **({"processStartId": start_id} if start_id else {}), - "active": active, - "recordedAt": datetime.now(timezone.utc).isoformat(), + return f"" + + +def bash( + command: str, + *, + sandbox_permissions: str = "use_default", + justification: str | None = None, + additional_permissions: dict[str, Any] | None = None, + prefix_rule: list[str] | None = None, + timeout_ms: int | None = None, + workdir: str | None = None, +) -> BashHandle: + """Schedule a host shell command; no host means failure, never local fallback. + + Requires a running asyncio loop. Workdir defaults to Python's current cwd. + Environment and shell selection are owned by the host, not ``os.environ``. + Cancelling a first, otherwise untouched await schedules host cancellation; + background handle awaits are wait-only. Cancellation during pending approval + is applied after admission; it cannot dismiss a host approval dialog. + """ + if not isinstance(command, str) or not command.strip(): + raise ValueError("command must be a non-empty string") + payload: dict[str, Any] = { + "command": command, + "workdir": os.getcwd() if workdir is None else workdir, + "sandbox_permissions": sandbox_permissions, } - data = (json.dumps(record) + "\n").encode() - try: - fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) - try: - # Complete-write loop: a short write would leave a truncated JSON - # line that the host discards, which must count as failure. - view = memoryview(data) - while view: - written = os.write(fd, view) - if written <= 0: - return False - view = view[written:] - os.fsync(fd) - finally: - os.close(fd) - except OSError: - return False - return True - - -def _kill_live_handles() -> None: - with _live_lock: - handles = list(_live_handles) - for handle in handles: - if _IS_POSIX: - delivered = _signal_group(handle._pid, signal.SIGKILL) - else: - with handle._kill_lock: - if handle._reaped: - continue - delivered = handle._job is not None and _winjob.terminate(handle._job) - if not delivered: - delivered = _taskkill_tree(handle._pid) - if not delivered: - # Leader-only fallback cannot prove the tree died: never - # justifies an inactive record. - try: - handle._proc.kill() - except OSError: - pass - if delivered: - _record_journal(handle._pid, active=False) - - -def _install_shutdown_hook() -> None: - global _hook_installed - with _hook_lock: - if _hook_installed: - return - _hook_installed = True - atexit.register(_kill_live_handles) + payload.update({key: value for key, value in { + "justification": justification, + "additional_permissions": additional_permissions, + "prefix_rule": prefix_rule, + "timeout_ms": timeout_ms, + }.items() if value is not None}) + return BashHandle(command, payload) diff --git a/crates/kernel/rlm-runtime/src/rlm/grants.py b/crates/kernel/rlm-runtime/src/rlm/grants.py deleted file mode 100644 index ec9d6648..00000000 --- a/crates/kernel/rlm-runtime/src/rlm/grants.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Kernel-side credential grants (design doc rlm-permissions.md §9, P2). - -The host delivers directory grants as file descriptors over the -``DEVO_GRANT_FD`` Unix socket: one newline-terminated JSON message per -grant, with the descriptor attached via SCM_RIGHTS and an fstat -``(dev, ino)`` anti-swap claim. Received descriptors are verified against -the claim, stored per granted root, and used by ``rlm.read``/``rlm.write`` -via ``os.open(path, dir_fd=...)`` — native kernel-side I/O with no -per-operation IPC and no kernel restart. - -A ``revoke`` message drops the descriptor (already-open handles keep -working — a documented residual). -""" - -from __future__ import annotations - -import json -import os -import socket -import struct -import threading -from typing import Any, Dict, Optional, Tuple - -_grants_lock = threading.Lock() -# granted root (str, host absolute) -> (fd, access) -_grants: Dict[str, Tuple[int, str]] = {} -_started = False - - -def _recvmsg_json_with_fd(sock: socket.socket) -> Tuple[Dict[str, Any], int]: - """Receive one JSON line + one SCM_RIGHTS descriptor.""" - data, ancdata, _flags, _addr = sock.recvmsg( - 65536, socket.CMSG_SPACE(struct.calcsize("i")) - ) - fd = -1 - for level, ctype, cdata in ancdata: - if level == socket.SOL_SOCKET and ctype == socket.SCM_RIGHTS: - (fd,) = struct.unpack("i", cdata[: struct.calcsize("i")]) - message = json.loads(data.decode("utf-8").strip()) - return message, fd - - -def _accept(message: Dict[str, Any], fd: int) -> None: - kind = message.get("type") - root = str(message.get("root", "")) - if kind == "revoke": - with _grants_lock: - entry = _grants.pop(root, None) - if entry is not None: - try: - os.close(entry[0]) - except OSError: - pass - return - if kind != "grant" or not root or fd < 0: - if fd >= 0: - try: - os.close(fd) - except OSError: - pass - return - # Anti-swap: verify the received descriptor matches the (dev, ino) claim. - try: - st = os.fstat(fd) - if st.st_dev != int(message.get("dev", -1)) or st.st_ino != int( - message.get("ino", -1) - ): - os.close(fd) # claim mismatch — drop, never trust silently - return - except OSError: - return - with _grants_lock: - old = _grants.get(root) - if old is not None: - try: - os.close(old[0]) - except OSError: - pass - _grants[root] = (fd, str(message.get("access", "read"))) - - -def _reader(sock_fd: int) -> None: - sock = socket.socket(fileno=sock_fd) - try: - while True: - try: - message, fd = _recvmsg_json_with_fd(sock) - except (OSError, ValueError): - return # channel closed (kernel shutdown) - _accept(message, fd) - finally: - try: - sock.detach() - except OSError: - pass - - -def start_from_env() -> None: - """Start the grant reader if DEVO_GRANT_FD is set (fenced kernels).""" - global _started - if _started: - return - raw = os.environ.get("DEVO_GRANT_FD") - if not raw: - return - try: - fd = int(raw) - except ValueError: - return - _started = True - thread = threading.Thread(target=_reader, args=(fd,), daemon=True) - thread.start() - - -def grant_fd_for(path: os.PathLike | str) -> Optional[Tuple[int, str, str]]: - """Longest-prefix granted root covering ``path`` -> (fd, access, rel).""" - p = os.fspath(path) - with _grants_lock: - best: Optional[Tuple[int, str, str]] = None - best_len = -1 - for root, (fd, access) in _grants.items(): - if p == root: - rel = "." - elif p.startswith(root.rstrip(os.sep) + os.sep): - rel = os.path.relpath(p, root) - else: - continue - if len(root) > best_len: - best = (fd, access, rel) - best_len = len(root) - return best diff --git a/crates/kernel/rlm-runtime/src/rlm/local_bash.py b/crates/kernel/rlm-runtime/src/rlm/local_bash.py new file mode 100644 index 00000000..161932fc --- /dev/null +++ b/crates/kernel/rlm-runtime/src/rlm/local_bash.py @@ -0,0 +1,1181 @@ +"""Async-by-default shell execution: bash() spawns immediately and returns a live handle.""" + +from __future__ import annotations + +import asyncio +import atexit +import functools +import json +import os +import secrets +import selectors +import shutil +import signal +import socket +import struct +import subprocess +import sys +import threading +import time +from collections import deque +from collections.abc import Callable, Generator, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, cast + +from . import _winjob +from .windows_shell import find_windows_bash + +_IS_POSIX = os.name == "posix" + +if _IS_POSIX: + import fcntl + import termios + +_HEAD_CAP = 512 * 1024 +_TAIL_CAP = 3 * 512 * 1024 +_READ_CHUNK = 65536 +# Fixed child-side fd for the status channel; POSIX shells (notably dash) only +# guarantee single-digit fds in redirection syntax. +_STATUS_FD = 9 +_OUTPUT_FD = 8 +_COMPLETION_PREFIX = b"\x1eprime-agent-complete:" +_COMPLETION_SUFFIX = b"\x1f" +# Cancelled one-shot awaits: TERM grace before the group KILL, then the bounded +# wait for a confirmed group exit before CancelledError propagates. +_CANCEL_TERM_GRACE = 0.5 +_CANCEL_KILL_WAIT = 2.0 +_COMPLETION_NOTICE_COMMAND_CAP = 1000 +_ASYNCIO_WRAPPER_CALLBACKS = { + ("asyncio.tasks", "gather.._done_callback"), + ("asyncio.tasks", "shield.._inner_done_callback"), + ("asyncio.tasks", "_wait.._on_completion"), + ("asyncio.tasks", "as_completed.._on_completion"), + ("asyncio.tasks", "_release_waiter"), +} + +_live_handles: set["BashHandle"] = set() +_live_lock = threading.Lock() +_hook_installed = False +_hook_lock = threading.Lock() + + +def _current_cell_completion_context() -> tuple[asyncio.Event, asyncio.Task[Any] | None] | None: + """Get the creating REPL cell's lifecycle without coupling standalone use to repl.""" + try: + from . import repl + + if repl.is_active(): + return repl.current_cell_completion_context() + except (ImportError, RuntimeError): + pass + return None + + +def _consume_notice_task(task: asyncio.Task[None]) -> None: + """Retrieve detached notifier failures so they never become loop warnings.""" + if not task.cancelled(): + task.exception() + + +def _completion_reaches( + start: asyncio.Future[Any], targets: tuple[asyncio.Future[Any], ...] +) -> bool: + """Follow asyncio's wrapper and TaskGroup ownership callbacks.""" + pending = [start] + seen_futures: set[int] = set() + seen_values: set[int] = set() + + def collect(value: Any, depth: int = 0) -> None: + if isinstance(value, asyncio.Future): + pending.append(value) + return + identity = id(value) + if depth >= 4 or identity in seen_values: + return + seen_values.add(identity) + + nested: list[Any] = [] + if isinstance(value, asyncio.Queue): + pending.extend(value._getters) + elif isinstance(value, functools.partial): + nested.extend((value.func, value.args, value.keywords)) + elif isinstance(value, dict): + nested.extend(value.keys()) + nested.extend(value.values()) + elif isinstance(value, (tuple, list, set, frozenset)): + nested.extend(value) + else: + closure = getattr(value, "__closure__", None) or () + for cell in closure: + try: + nested.append(cell.cell_contents) + except ValueError: + pass + bound_self = getattr(value, "__self__", None) + if bound_self is not None: + nested.append(bound_self) + for item in nested: + collect(item, depth + 1) + + while pending: + future = pending.pop() + if any(future is target for target in targets): + return True + if id(future) in seen_futures: + continue + seen_futures.add(id(future)) + for entry in getattr(future, "_callbacks", None) or (): + callback = entry[0] if isinstance(entry, tuple) else entry + base = callback.func if isinstance(callback, functools.partial) else callback + identity = (getattr(base, "__module__", None), getattr(base, "__qualname__", None)) + if identity in _ASYNCIO_WRAPPER_CALLBACKS: + collect(callback) + elif identity == ("asyncio.tasks", "_AsCompletedIterator._handle_completion"): + collect(base.__self__._done) + elif identity == (None, "Task.task_wakeup"): + task = getattr(callback, "__self__", None) + if isinstance(task, asyncio.Task): + pending.append(task) + elif identity == ("asyncio.taskgroups", "TaskGroup._on_task_done"): + parent = getattr(getattr(callback, "__self__", None), "_parent_task", None) + if isinstance(parent, asyncio.Future): + pending.append(parent) + return False + + +def _creating_cell_waits_for( + owner: asyncio.Task[Any] | None, awaiter: asyncio.Task[Any] | None +) -> bool: + """Return whether the cell owner directly or transitively waits for awaiter.""" + if owner is None or awaiter is None: + return False + if owner is awaiter: + return True + waiter = getattr(owner, "_fut_waiter", None) + targets: tuple[asyncio.Future[Any], ...] = (owner,) + if isinstance(waiter, asyncio.Future): + targets += (waiter,) + return _completion_reaches(awaiter, targets) + + +def _live_cell_owner() -> asyncio.Task[Any] | None: + """Body task of the cell executing right now, ignoring detached context copies.""" + try: + from . import repl + + if repl.is_active(): + return repl.active_cell_task() + except (ImportError, RuntimeError): + pass + return None + + +@dataclass(frozen=True) +class BashResult: + exit_code: int + output: str + duration: float + + +class _BoundedBuffer: + """First _HEAD_CAP bytes plus a rolling _TAIL_CAP-byte tail; the middle is dropped.""" + + def __init__(self) -> None: + self._head = bytearray() + self._tail: deque[bytes] = deque() + self._tail_size = 0 + self._dropped = 0 + self._lock = threading.Lock() + + def write(self, chunk: bytes) -> None: + with self._lock: + if len(self._head) < _HEAD_CAP: + take = _HEAD_CAP - len(self._head) + self._head.extend(chunk[:take]) + chunk = chunk[take:] + if not chunk: + return + self._tail.append(chunk) + self._tail_size += len(chunk) + # Trim the oldest chunk instead of dropping it whole so exactly _TAIL_CAP bytes stay. + while self._tail_size > _TAIL_CAP: + excess = self._tail_size - _TAIL_CAP + oldest = self._tail[0] + if len(oldest) <= excess: + self._tail.popleft() + self._tail_size -= len(oldest) + self._dropped += len(oldest) + else: + self._tail[0] = oldest[excess:] + self._tail_size -= excess + self._dropped += excess + + def size(self) -> int: + with self._lock: + return len(self._head) + self._tail_size + + def text(self) -> str: + with self._lock: + head = bytes(self._head) + tail = b"".join(self._tail) + dropped = self._dropped + if not dropped: + return (head + tail).decode("utf-8", errors="replace") + marker = f"\n... [{dropped} bytes dropped] ...\n" + return head.decode("utf-8", errors="replace") + marker + tail.decode("utf-8", errors="replace") + + +class BashHandle: + """Live handle to a shell command; await it for the BashResult. + + A handle awaited before any other API use (the `await bash(cmd)` one-shot + form, including `h = bash(cmd)` awaited immediately) owns the command: + cancelling that await kills the process group. Touching .pid/.running/ + .output()/.tail()/.poll()/.kill() first marks the handle as a background + handle; later awaits only wait and cancelling them leaves it running. + """ + + def __init__(self, command: str) -> None: + self.command = command + completion_context = _current_cell_completion_context() + self._creating_cell_finished = completion_context[0] if completion_context else None + self._creating_cell_task = completion_context[1] if completion_context else None + self._awaited_by_creating_cell = False + self._buffer = _BoundedBuffer() + self._done = threading.Event() + self._eof = threading.Event() + self._completion_terminal = threading.Event() + self._completion_output: str | None = None + self._completion_lock = threading.Lock() + self._completion_pending = b"" + self._status: int | None = None + self._status_known = threading.Event() + self._reaped = False + self._result: BashResult | None = None + self._callbacks: list[Callable[[], None]] = [] + self._reap_callback: Callable[[], None] | None = None + self._result_consumed = False + self._consumed_notice: Callable[[], None] | None = None + self._callback_lock = threading.Lock() + # Serializes kill/reap so a pid fallback can never outlive the process handle. + self._kill_lock = threading.Lock() + self._started = time.monotonic() + # POSIX: own process group so kill() signals the whole pipeline; Windows + # contains the tree in a kill-on-close job object. + self._status_read = -1 + self._wake_read = -1 + self._wake_write = -1 + # True only while the pump moves a chunk from the pipe into the buffer. + self._pump_transfer = False + self._job: int | None = None + self._completion_marker: bytes | None = None + status_write = -1 + if _IS_POSIX: + # Full-duplex status channel: the child end rides in as stdin (fd 0) + # and the script remaps it to _STATUS_FD before swapping in /dev/null + # (dash rejects multi-digit fds in redirections at parse time). The + # parent end doubles as the gate: the child blocks on it until the + # pid is journaled, so a kernel kill in that window cannot leak an + # unjournaled command (parent death closes the socket -> child exits). + parent_sock, child_sock = socket.socketpair() + self._status_read = parent_sock.detach() + status_write = child_sock.detach() + try: + self._wake_read, self._wake_write = os.pipe() + except BaseException: + os.close(self._status_read) + os.close(status_write) + raise + completion_token = secrets.token_hex(32) + # Halves stop passive echoes; a deliberate forgery freezes only this call while later bytes stay live. + token_midpoint = len(completion_token) // 2 + self._completion_marker = ( + _COMPLETION_PREFIX + completion_token.encode("ascii") + _COMPLETION_SUFFIX + ) + script = _status_script( + _with_prefix(command), + completion_token[:token_midpoint], + completion_token[token_midpoint:], + ) + else: + # Windows lacks a foreground-status channel, so its exit drain stays best-effort. + script = _with_prefix(command) + self._job = _winjob.create_job() + if self._job is None: + # Nothing spawned yet, so nothing can leak: refuse to start. + raise RuntimeError("bash(): Windows job containment could not be established") + try: + self._proc: subprocess.Popen[bytes] | _winjob.JobProcess + if _IS_POSIX: + self._proc = subprocess.Popen( + [_shell(), "-c", script], + cwd=os.getcwd(), + env=_child_env(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + stdin=status_write, + ) + else: + self._proc = _winjob.spawn_in_job( + self._job, [_shell(), "-c", script], cwd=os.getcwd(), env=_child_env() + ) + except BaseException: + for fd in (self._status_read, self._wake_read, self._wake_write): + if fd >= 0: + os.close(fd) + if self._job is not None: + job, self._job = self._job, None + _winjob.close(job) + raise + finally: + if status_write >= 0: + os.close(status_write) + self._pid: int = self._proc.pid + self._released = False + with _live_lock: + _live_handles.add(self) + enrolled = _record_journal(self._pid, active=True) + if not enrolled: + # Fail closed: a configured journal that cannot enroll the pid must + # not let the command run (the host reaper would never see it). + self._abort_spawn() + raise RuntimeError( + "bash(): orphan-journal enrollment failed (journal configured but the " + "pid could not be recorded); the spawned process was killed" + ) + if _IS_POSIX: + # Journal first, then open the gate: the child does not run the user + # command until this byte arrives. A failed write means the child + # already died; the status/EOF paths report that normally. + try: + os.write(self._status_read, b"\n") + except OSError: + pass + else: + # The child is already job-contained and journaled; resume is the + # last step. A failed resume would strand a permanently suspended + # child: fail closed via the assigned job. + if not cast("_winjob.JobProcess", self._proc).resume(): + self._abort_spawn() + raise RuntimeError("bash(): Windows job containment could not be established") + threading.Thread(target=self._pump, daemon=True).start() + threading.Thread(target=self._report, daemon=True).start() + threading.Thread(target=self._watch, daemon=True).start() + self._schedule_background_completion_notice() + + @property + def pid(self) -> int: + self._released = True + return self._pid + + @property + def running(self) -> bool: + # Group liveness, matching kill()'s guard and the journal; poll()/await + # keep foreground result semantics after `cmd &` returns early. + self._released = True + return not self._reaped + + def output(self) -> str: + self._released = True + self._note_result_consumed() + return self._buffer.text() + + def tail(self, n: int = 50) -> str: + self._released = True + self._note_result_consumed() + return "\n".join(self._buffer.text().splitlines()[-n:]) + + def poll(self) -> BashResult | None: + self._released = True + self._note_result_consumed() + return self._result if self._done.is_set() else None + + def kill(self, sig: int = signal.SIGTERM, grace: float = 5.0) -> None: + # Guard on group death, not _done: kill() must still reach a lingering + # background group after the foreground result was already delivered. + self._released = True + if self._reaped: + return + if not _IS_POSIX: + with self._kill_lock: + if self._reaped: # re-check: _watch may have reaped while we waited + return + if self._job is not None and _winjob.terminate(self._job): + return + # TerminateJobObject failed or reap raced: taskkill fallback. + if not _taskkill_tree(self._pid): + try: + self._proc.kill() + except OSError: + pass + return + _signal_group(self._pid, sig) + if sig == signal.SIGTERM: + timer = threading.Timer(grace, self._force_kill) + timer.daemon = True + timer.start() + + def _force_kill(self) -> None: + if not self._reaped: + _signal_group(self._pid, signal.SIGKILL) + + def _pump(self) -> None: + stdout = self._proc.stdout + assert stdout is not None + if not _IS_POSIX: + try: + while chunk := stdout.read1(_READ_CHUNK): + self._buffer.write(chunk) + except (OSError, ValueError): + pass + stdout.close() + self._eof.set() + return + fd = stdout.fileno() + try: + with selectors.DefaultSelector() as sel: + sel.register(fd, selectors.EVENT_READ) + while True: + sel.select() + self._pump_transfer = True + try: + chunk = os.read(fd, _READ_CHUNK) + if not chunk: + break + self._consume_output(chunk) + finally: + self._pump_transfer = False + except (OSError, ValueError): + pass + self._abandon_completion() + try: + stdout.close() + except OSError: + pass + self._eof.set() + + def _consume_output(self, chunk: bytes) -> None: + marker = self._completion_marker + assert marker is not None + with self._completion_lock: + if self._completion_terminal.is_set(): + self._buffer.write(chunk) + return + data = self._completion_pending + chunk + marker_at = data.find(marker) + if marker_at >= 0: + self._buffer.write(data[:marker_at]) + self._completion_pending = b"" + self._completion_output = self._buffer.text() + self._completion_terminal.set() + self._buffer.write(data[marker_at + len(marker) :]) + return + retained = 0 + for size in range(min(len(data), len(marker) - 1), 0, -1): + if data.endswith(marker[:size]): + retained = size + break + self._buffer.write(data[:-retained] if retained else data) + self._completion_pending = data[-retained:] if retained else b"" + + def _abandon_completion(self) -> None: + with self._completion_lock: + if self._completion_terminal.is_set(): + return + self._buffer.write(self._completion_pending) + self._completion_pending = b"" + self._completion_terminal.set() + + def _wait_for_completion(self) -> str | None: + self._completion_terminal.wait() + return self._completion_output + + def _report(self) -> None: + # Finalize at foreground completion (status channel), not EOF, so + # `cmd &` does not hang the await; the shell then `wait`s for its + # background jobs, keeping the journaled group identity alive. + status: int | None = None + try: + status = self._read_status() + # Reserve the delivered status before draining so a shell death during + # the drain window cannot override it with wait()'s signal exit code. + with self._callback_lock: + self._status = status + finally: + # _watch blocks on this event without a timeout, so every exit path + # (parsed status, EOF, garbage, exception) must set it. + self._status_known.set() + if status is not None: + output = self._wait_for_completion() + if output is None: + self._drain_grace() + self._finalize(status, output) + + def _watch(self) -> None: + # Observe shell death independently of the status socket: an early + # `exit`/`exec`/`set -e`/fatal signal skips `printf`, and background + # children can hold the socket open past the shell's lifetime. + exit_code = self._proc.wait() + if self._wake_write >= 0: + # Unblock _read_status: background children can hold the status socket + # open past the shell's lifetime via bash's saved-fd duplicate. + try: + os.write(self._wake_write, b"x") + except OSError: + pass + os.close(self._wake_write) + # _report always sets _status_known (try/finally), so wait indefinitely: + # a slow reporter can never lose a delivered status to wait()'s code. + self._status_known.wait() + with self._callback_lock: + delivered = self._status + if delivered is None and not self._done.is_set(): + self._abandon_completion() + self._drain_grace() + self._finalize(exit_code) + with self._kill_lock: + delivered = self._reap_group() + self._reaped = True + if not _IS_POSIX: + # Reaped: pid fallbacks are gone, so the handle may finally close. + cast("_winjob.JobProcess", self._proc).close() + with self._callback_lock: + callback, self._reap_callback = self._reap_callback, None + if callback is not None: + callback() + if delivered: + _record_journal(self._pid, active=False) + with _live_lock: + _live_handles.discard(self) + + def _reap_group(self) -> bool: + # Group liveness, not leader death, gates the inactive record: members + # that outlive the leader would leak behind a stale journal anchor. + if not _IS_POSIX: + # Terminate then close the last handle: kill-on-close reaps + # stragglers. An unproven terminate falls back to taskkill; if + # that also fails the record stays active for the host reaper. + delivered = False + if self._job is not None: + delivered = _winjob.terminate(self._job) + job, self._job = self._job, None + _winjob.close(job) + return delivered or _taskkill_tree(self._pid) + try: + os.killpg(self._pid, 0) + except ProcessLookupError: + return True # group already gone + except PermissionError: + pass + return _signal_group(self._pid, signal.SIGKILL) + + def _read_status(self) -> int | None: + if self._status_read < 0: + return None + try: + # DefaultSelector (kqueue/epoll) instead of select(): select() rejects + # fds >= FD_SETSIZE (1024) even when the process fd limit is higher. + with selectors.DefaultSelector() as sel: + sel.register(self._status_read, selectors.EVENT_READ) + sel.register(self._wake_read, selectors.EVENT_READ) + line = b"" + while b"\n" not in line: + ready = {key.fd for key, _ in sel.select()} + # Prefer status bytes: any status write happens before shell exit, + # so it is already readable whenever the wake fd fires. + if self._status_read not in ready: + break # shell died without writing a status + chunk = os.read(self._status_read, 64) + if not chunk: + break # EOF without a full status line + line += chunk + return int(line) + except (OSError, ValueError): + return None + finally: + os.close(self._status_read) + os.close(self._wake_read) + + def _drain_grace(self) -> None: + # Best-effort fallback when process exit/EOF arrives without a sentinel. + deadline = time.monotonic() + 0.5 + size = self._buffer.size() + while time.monotonic() < deadline: + if self._eof.wait(0.05): + return + # A chunk between pipe read and buffer commit (transfer flag) is + # invisible to both FIONREAD and the buffer size; wait it out. + if self._pipe_pending() or self._pump_transfer: + size = self._buffer.size() + continue + current = self._buffer.size() + if current == size: + return + size = current + + def _pipe_pending(self) -> bool: + # POSIX only: FIONREAD on the capture pipe; Windows keeps the + # quiescence heuristic (best-effort parity). + if not _IS_POSIX or self._eof.is_set(): + return False + stdout = self._proc.stdout + if stdout is None: + return False + try: + pending = struct.unpack("i", fcntl.ioctl(stdout.fileno(), termios.FIONREAD, struct.pack("i", 0)))[0] + except (OSError, ValueError): + return False + return pending > 0 + + def _finalize(self, exit_code: int, output: str | None = None) -> None: + with self._callback_lock: + if self._done.is_set(): + return + self._result = BashResult( + exit_code=exit_code, + output=self._buffer.text() if output is None else output, + duration=time.monotonic() - self._started, + ) + self._done.set() + callbacks = self._callbacks + self._callbacks = [] + for callback in callbacks: + callback() + + def _add_done_callback(self, callback: Callable[[], None]) -> None: + with self._callback_lock: + if not self._done.is_set(): + self._callbacks.append(callback) + return + callback() + + def _note_result_consumed(self, awaiter: asyncio.Task[Any] | None = None) -> None: + """Record a result read that reaches the model: only reads during a live + cell count (a detached reader between turns must keep the notice — it is + the idle session's only wake-up), and an awaiting reader must be one the + live cell waits for.""" + if not self._done.is_set(): + return + owner = _live_cell_owner() + if owner is None: + return + if awaiter is not None and not _creating_cell_waits_for(owner, awaiter): + return + with self._callback_lock: + if self._result_consumed: + return + self._result_consumed = True + notice, self._consumed_notice = self._consumed_notice, None + if notice is not None: + notice() + + def _schedule_background_completion_notice(self) -> None: + cell_finished = self._creating_cell_finished + if cell_finished is None: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + from . import repl + + activity = {"id": secrets.token_hex(16), "pid": self._pid, "active": True} + # Publish synchronously before bash() returns and the creating cell can end. + repl.emit({"application/vnd.bash-activity+json": activity}) + notice = self._notify_background_completion(cell_finished, activity) + try: + task = loop.create_task(notice) + except BaseException: + self.kill(signal.SIGKILL if _IS_POSIX else signal.SIGTERM) + notice.close() + repl.emit({"application/vnd.bash-activity+json": {**activity, "active": False}}) + raise + task.add_done_callback(_consume_notice_task) + + async def _notify_background_completion( + self, cell_finished: asyncio.Event, activity: dict[str, Any] + ) -> None: + from . import repl + + try: + result = await self._wait() + await self._wait_reaped() + # The cell may do other work before awaiting this handle. Do not classify + # it as detached until that whole cell has crossed its completion barrier. + await cell_finished.wait() + if self._awaited_by_creating_cell or self._result_consumed or not repl.is_active(): + return + command = self.command + if len(command) > _COMPLETION_NOTICE_COMMAND_CAP: + command = command[:_COMPLETION_NOTICE_COMMAND_CAP] + "\n... [command truncated]" + reply = await repl.host_request( + { + "type": "bash.completed", + "pid": self._pid, + "command": command, + "exitCode": result.exit_code, + } + ) + if isinstance(reply, dict) and reply.get("status") == "ok": + # Notice accepted by the host; later reads must ask it to withdraw. + self._arm_consumed_notice(command) + else: + sys.stderr.write( + f"Background bash completion follow-up for pid {self._pid} was not accepted. " + "Inspect the saved handle with poll(), output(), or tail().\n" + ) + except (OSError, RuntimeError): + # Standalone runtimes have no host handler, and teardown can close + # the bridge while a process is finishing. Shell results stay usable. + return + finally: + # Reap and deliver (or report rejection) before releasing kernel residency. + repl.emit({"application/vnd.bash-activity+json": {**activity, "active": False}}) + + def _arm_consumed_notice(self, command: str) -> None: + # Armed only post-acceptance: the withdrawal can never overtake its notice. + loop = asyncio.get_running_loop() + + def dispatch() -> None: + def start() -> None: + task = loop.create_task(self._notify_result_consumed(command)) + task.add_done_callback(_consume_notice_task) + + try: + loop.call_soon_threadsafe(start) + except RuntimeError: + pass # notifying loop already closed + + with self._callback_lock: + if not self._result_consumed: + self._consumed_notice = dispatch + return + dispatch() + + async def _notify_result_consumed(self, command: str) -> None: + from . import repl + + if not repl.is_active(): + return + try: + await repl.host_request( + {"type": "bash.consumed", "pid": self._pid, "command": command} + ) + except (OSError, RuntimeError): + return # bridge closed at teardown; old hosts error-reply — both fine + + async def _wait_reaped(self) -> None: + loop = asyncio.get_running_loop() + future: asyncio.Future[None] = loop.create_future() + + def wake() -> None: + try: + loop.call_soon_threadsafe(lambda: future.done() or future.set_result(None)) + except RuntimeError: + pass + + with self._callback_lock: + if self._reaped: + return + self._reap_callback = wake + try: + await future + finally: + with self._callback_lock: + if self._reap_callback is wake: + self._reap_callback = None + + async def _wait(self) -> BashResult: + # Asyncio-native wakeup: no executor thread is parked for the command's + # duration, so many concurrent awaits cannot exhaust the default pool. + loop = asyncio.get_running_loop() + fut: asyncio.Future[None] = loop.create_future() + + def _wake() -> None: + try: + loop.call_soon_threadsafe(lambda: fut.done() or fut.set_result(None)) + except RuntimeError: + pass # awaiting loop already closed + + self._add_done_callback(_wake) + await fut + assert self._result is not None + return self._result + + async def _wait_owned(self) -> BashResult: + # One-shot `await bash(cmd)` owns the process: a cancelled await (e.g. + # a kernel interrupt) must not leave the command running. TERM, bounded + # grace, group KILL, then a bounded confirmed-exit wait before the + # CancelledError propagates, so no side effect can land after it. + try: + return await self._wait() + except asyncio.CancelledError: + # Signal synchronously first: even if the cleanup awaits below are + # re-cancelled, TERM is already delivered and the escalation timer + # armed. The confirm wait runs as a shielded task so repeated + # cancels of this task cannot skip it (they re-raise into awaits + # inside this except block); the loop re-awaits until it finishes + # (the confirm coroutine itself is bounded). + self.kill(grace=_CANCEL_TERM_GRACE) + confirm = asyncio.ensure_future(self._confirm_group_exit()) + while not confirm.done(): + try: + await asyncio.shield(confirm) + except asyncio.CancelledError: + continue + raise + + async def _confirm_group_exit(self) -> None: + if not await self._await_group_death(_CANCEL_TERM_GRACE): + if _IS_POSIX: + _signal_group(self._pid, signal.SIGKILL) + else: + # kill() holds the escalation lock; to_thread keeps the loop free. + await asyncio.to_thread(self.kill) + await self._await_group_death(_CANCEL_KILL_WAIT) + + def _group_alive(self) -> bool: + if not _IS_POSIX: + job = self._job # snapshot: _watch may clear it concurrently + if job is not None: + # Job accounting sees detached descendants a dead leader hides. + empty = _winjob.is_empty(job) + if empty is not None: + return not empty + return self._proc.poll() is None + try: + os.killpg(self._pid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass + return True + + async def _await_group_death(self, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while self._group_alive(): + if time.monotonic() >= deadline: + return False + await asyncio.sleep(0.02) + return True + + def _abort_spawn(self) -> None: + # Enrollment or containment failed before the gate opened (POSIX) or + # while the child is still suspended, before resume (Windows): kill + # the child and unwind the handle before threads start. + if _IS_POSIX: + for fd in (self._status_read, self._wake_read, self._wake_write): + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + self._status_read = self._wake_read = self._wake_write = -1 + delivered = _signal_group(self._pid, signal.SIGKILL) + else: + with self._kill_lock: + delivered = False + if self._job is not None: + delivered = _winjob.terminate(self._job) + job, self._job = self._job, None + _winjob.close(job) + if not delivered: + # Pre-resume abort: the never-run leader has no descendants, so a + # delivered kill retires the journal record. + try: + self._proc.kill() + delivered = True + except OSError: + pass + if self._proc.stdout is not None: + self._proc.stdout.close() + # The blocking wait stays outside the lock: hProcess is still open, so a + # concurrent raw-pid fallback stays pinned to the right process. + try: + self._proc.wait(timeout=5) + except (OSError, subprocess.SubprocessError): + pass + with self._kill_lock: + self._reaped = True + if not _IS_POSIX: + # Reaped commits before close: later lock holders skip raw-pid fallbacks. + cast("_winjob.JobProcess", self._proc).close() + with _live_lock: + _live_handles.discard(self) + if delivered: + _record_journal(self._pid, active=False) + + def __await__(self) -> Generator[Any, None, BashResult]: + # A handle awaited before any other API use is a one-shot command tied + # to the await (kill-on-cancel); touching the handle API first marks it + # as a deliberate background handle whose awaits only wait. + try: + current_task = asyncio.current_task() + except RuntimeError: + current_task = None + creating_cell_waited = _creating_cell_waits_for(self._creating_cell_task, current_task) + owned = not self._released + wait = self._wait_owned() if owned else self._wait() + self._released = True + completed = False + try: + result = yield from wait.__await__() + completed = True + return result + finally: + if (completed or owned) and ( + creating_cell_waited + or _creating_cell_waits_for(self._creating_cell_task, current_task) + ): + self._awaited_by_creating_cell = True + if completed: + self._note_result_consumed(current_task) + + def __repr__(self) -> str: + state = f"exit_code={self._result.exit_code}" if self._result else "running" + return f"" + + +def bash(command: str) -> BashHandle: + """Start a shell command immediately; await the handle for the result. + + `await bash(cmd)` is a one-shot: cancelling the await (e.g. an interrupt) + kills the command's process group. `h = bash(cmd)` used as a background + handle (any .pid/.running/.output()/.tail()/.poll()/.kill() access before + the first await) survives cancellation; awaiting it only waits. Leak + containment is per-platform: process groups plus the orphan journal on + POSIX; a kill-on-close job object on Windows entered while the child is + still suspended, so no descendant can escape it and kill()/crash cleanup + are unconditional -- bash() raises if containment cannot be established. + Output written after the completion fence (e.g. by an EXIT trap or a + background job) is not in BashResult.output but stays visible via + handle.output()/tail(). + """ + if not isinstance(command, str) or not command: + raise TypeError("command must be a non-empty str") + _install_shutdown_hook() + return BashHandle(command) + + +def _shell(environment: Mapping[str, str] | None = None) -> str: + # Read per call so env changes made in the REPL apply to later commands. + environment = os.environ if environment is None else environment + override = environment.get("DEVO_BASH_SHELL") + if override: + if not os.path.isabs(override): + raise ValueError("DEVO_BASH_SHELL must be an absolute path") + return override + if not _IS_POSIX: + # Installer registrations and standard locations also cover custom + # drives. Never use a workspace-controlled PATH to choose the shell. + if shell := find_windows_bash(environment): + return shell + raise RuntimeError( + "Install Git for Windows to use bash(), or set DEVO_BASH_SHELL " + "to the absolute path of a POSIX shell" + ) + # PATH fallback only serves bare/standalone POSIX runtime use: the host + # always injects DEVO_BASH_SHELL (an absolute path) when a shell exists. + shell = shutil.which("bash") + return shell or "/bin/sh" + + +def _with_prefix(command: str) -> str: + prefix = os.environ.get("DEVO_BASH_COMMAND_PREFIX") + return f"{prefix}\n{command}" if prefix else command + + +def _fence_printf() -> str: + # `\command -p printf` defeats alias expansion but not a user-defined shell + # function named `command`, which would swallow both fence frames and leave + # the await hanging until the shell dies (wedged behind background jobs). A + # slash-qualified command name bypasses function and alias lookup for + # ordinary command names, so resolve printf on the system default utility PATH. + path = shutil.which("printf", path=os.confstr("CS_PATH") or os.defpath) + if path and "'" not in path: + return f"'{path}'" + return "\\command -p printf" + + +def _status_script(command: str, completion_a: str, completion_b: str) -> str: + # Closed control fds preserve background behavior; supported shells atomically write the frame. + emit = _fence_printf() + return ( + f"exec {_STATUS_FD}>&0 {_OUTPUT_FD}>&1 0&- {_STATUS_FD}>&-\n" + "__prime_status=$?\n" + "\\set +x\n" + f"{emit} '\\036prime-agent-complete:%s%s\\037' " + f"'{completion_a}' '{completion_b}' >&{_OUTPUT_FD} || exit \"$__prime_status\"\n" + f"{emit} '%s\\n' \"$__prime_status\" >&{_STATUS_FD}\n" + f"exec {_OUTPUT_FD}>&- {_STATUS_FD}>&-\n" + "wait\n" + 'exit "$__prime_status"\n' + ) + + +def _child_env() -> dict[str, str]: + return {**os.environ, "NO_COLOR": "1", "TERM": "dumb", "CLICOLOR": "0", "FORCE_COLOR": "0"} + + +def _signal_group(pid: int, sig: int) -> bool: + """True when the signal was delivered or the group is already gone.""" + try: + os.killpg(pid, sig) + except ProcessLookupError: + return True # already dead: safe to mark the journal record inactive + except OSError: + return False # not delivered: the record must stay active for the host reaper + return True + + +def _system32(*parts: str) -> str: + # Absolute paths for Windows helper binaries: PATH (and CWD on Windows + # CPython) lookup could resolve a planted taskkill.exe/powershell.exe. + root = os.environ.get("SystemRoot", r"C:\Windows") + return os.path.join(root, "System32", *parts) + + +def _helper_env() -> dict[str, str]: + return {**os.environ, "NoDefaultCurrentDirectoryInExePath": "1"} + + +def _taskkill_tree(pid: int) -> bool: + # Windows has no process groups to signal; taskkill /T kills the whole tree. + try: + return ( + subprocess.run( + [_system32("taskkill.exe"), "/PID", str(pid), "/T", "/F"], + capture_output=True, + timeout=10, + env=_helper_env(), + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +def _process_start_id(pid: int) -> str | None: + if os.name == "nt": + # Mirrors getWindowsProcessStartId in session-lease.ts byte-for-byte so + # the host's identity comparison matches the journaled string. + try: + out = subprocess.run( + [ + _system32("WindowsPowerShell", "v1.0", "powershell.exe"), + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + f"([System.Diagnostics.Process]::GetProcessById({pid})).StartTime.ToUniversalTime().Ticks", + ], + capture_output=True, + text=True, + timeout=5, + env=_helper_env(), + ).stdout.strip() + return f"win:{out}" if out.isdigit() else None + except (OSError, subprocess.SubprocessError): + return None + try: + with open(f"/proc/{pid}/stat", "r") as f: + stat = f.read() + fields = stat[stat.rindex(")") + 2 :].split(" ") + if len(fields) > 19 and fields[19]: + return f"proc:{fields[19]}" + except (OSError, ValueError): + pass + try: + # macOS has no /proc; /bin/ps is always present there, so use the + # absolute path (bare `ps` stays only as the exotic-POSIX last resort). + ps = "/bin/ps" if sys.platform == "darwin" else "ps" + out = subprocess.run( + [ps, "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, timeout=5 + ).stdout.strip() + return f"ps:{out}" if out else None + except (OSError, subprocess.SubprocessError): + return None + + +def _record_journal(pid: int, active: bool) -> bool: + # Returns False only when the journal is configured but enrollment failed; + # active-record callers must then fail closed. Active records always carry + # a processStartId so host reaping stays identity-verified. + path = os.environ.get("DEVO_INTERNAL_ORPHAN_PROCESS_JOURNAL") + owner = os.environ.get("DEVO_KERNEL_OWNER_PID") + if not path or not owner: + return True + try: + owner_pid = int(owner) + except ValueError: + return False + start_id = _process_start_id(pid) if active else None + if active and start_id is None: + return False + record: dict[str, Any] = { + "version": 1, + "pid": pid, + "ownerPid": owner_pid, + # The host reaps bash children per kernel pid when it kills or loses this kernel. + "kernelPid": os.getpid(), + **({"processStartId": start_id} if start_id else {}), + "active": active, + "recordedAt": datetime.now(timezone.utc).isoformat(), + } + data = (json.dumps(record) + "\n").encode() + try: + fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + try: + # Complete-write loop: a short write would leave a truncated JSON + # line that the host discards, which must count as failure. + view = memoryview(data) + while view: + written = os.write(fd, view) + if written <= 0: + return False + view = view[written:] + os.fsync(fd) + finally: + os.close(fd) + except OSError: + return False + return True + + +def _kill_live_handles() -> None: + with _live_lock: + handles = list(_live_handles) + for handle in handles: + if _IS_POSIX: + delivered = _signal_group(handle._pid, signal.SIGKILL) + else: + with handle._kill_lock: + if handle._reaped: + continue + delivered = handle._job is not None and _winjob.terminate(handle._job) + if not delivered: + delivered = _taskkill_tree(handle._pid) + if not delivered: + # Leader-only fallback cannot prove the tree died: never + # justifies an inactive record. + try: + handle._proc.kill() + except OSError: + pass + if delivered: + _record_journal(handle._pid, active=False) + + +def _install_shutdown_hook() -> None: + global _hook_installed + with _hook_lock: + if _hook_installed: + return + _hook_installed = True + atexit.register(_kill_live_handles) diff --git a/crates/kernel/rlm-runtime/src/rlm/mcp.py b/crates/kernel/rlm-runtime/src/rlm/mcp.py index 632bbcd3..9a34e050 100644 --- a/crates/kernel/rlm-runtime/src/rlm/mcp.py +++ b/crates/kernel/rlm-runtime/src/rlm/mcp.py @@ -549,8 +549,8 @@ async def call_tool(server: str, tool: str, arguments: dict[str, Any] | None = N First asks the host via ``mcp.call`` (plan-mode denial + permission cascade + grant cache). Only when the host has no handler for the action (older host / standalone kernel) does it fall back to the - kernel-local registry — the OS fence still bounds that fallback's - effects, and the fallback logs loudly rather than passing silently. + kernel-local registry with the trusted Python process's user privileges. + The fallback logs rather than passing silently. """ _validate_name(tool, "tool") if arguments is not None and not isinstance(arguments, dict): @@ -573,7 +573,7 @@ async def call_tool(server: str, tool: str, arguments: dict[str, Any] | None = N print( f"mcp.call_tool: host mediation unavailable ({no_host}); " - "falling back to kernel-local registry (fence still bounds effects)", + "falling back to trusted kernel-local registry", file=_sys.stderr, ) return await _dispatch(lambda: _registry.call(server, tool, args)) diff --git a/crates/kernel/rlm-runtime/src/rlm/repl.md b/crates/kernel/rlm-runtime/src/rlm/repl.md index 575b816f..c8c719a6 100644 --- a/crates/kernel/rlm-runtime/src/rlm/repl.md +++ b/crates/kernel/rlm-runtime/src/rlm/repl.md @@ -62,8 +62,11 @@ runtime keeps serving. Closing stdin is equivalent to `shutdown`. `host_reply` request carrying the same id. `cell_id` is present only while the request belongs to a live cell. The host matches it to that cell's capability; a late detached request cannot reuse a later turn's handler. - Idle requests are denied except the host's narrow `bash.completed` and - PID-scoped `bash.consumed` routes. Older frames without `cell_id` remain + Idle requests are denied except Bash completion/consumption notices and + session-scoped `bash.poll` / `bash.kill` / `bash.forget` controls. Host-owned + Bash uses opaque handle IDs; explicit legacy local Bash uses PID notices. + The cell owner stays live until its scheduled Bash starts are admitted. + Older frames without `cell_id` remain parseable but cannot use per-cell capabilities. - `{"event":"error","id":str|null,"ename":str,"evalue":str,"traceback":[str,...]}` - `{"event":"done","id":str,"status":"ok"|"error"}` — exactly one per id'd @@ -175,5 +178,7 @@ top-level names under the same filter the snapshot applies. ## Shutdown -`shutdown` (or stdin EOF) kills live `rlm.bash` child process groups, replies -`done` (when the request carried an id), stops the loop, and exits 0. +`shutdown` (or stdin EOF) fails pending host requests and kills any explicit +`rlm.local_bash` child process groups, replies `done` (when the request carried +an id), stops the loop, and exits 0. Product `rlm.bash` commands are owned and +cleaned up by the host, not by Python process-group signaling. diff --git a/crates/kernel/rlm-runtime/src/rlm/repl.py b/crates/kernel/rlm-runtime/src/rlm/repl.py index 8c917b97..9855b2a4 100644 --- a/crates/kernel/rlm-runtime/src/rlm/repl.py +++ b/crates/kernel/rlm-runtime/src/rlm/repl.py @@ -29,7 +29,7 @@ from typing import Any from ._snapshot_files import create_snapshot_temp -from .bash import _kill_live_handles +from .local_bash import _kill_live_handles PROTOCOL_VERSION = 3 @@ -51,6 +51,7 @@ class _CellExecution: def __init__(self) -> None: self.finished = asyncio.Event() self.owner: asyncio.Task[Any] | None = None + self.bash_admissions: list[asyncio.Future[None]] = [] # Asyncio tasks copy cell context at creation, so detached tasks retain their @@ -116,6 +117,13 @@ def current_cell_completion_context() -> tuple[asyncio.Event, asyncio.Task[Any] return execution.finished, execution.owner +def register_bash_admission(admission: asyncio.Future[None]) -> None: + """Keep the creating cell alive until its scheduled host start is admitted.""" + execution = _current_cell_execution.get() + if execution is not None and execution.owner is asyncio.current_task(): + execution.bash_admissions.append(admission) + + def active_cell_task() -> asyncio.Task[Any] | None: """The cell body task executing right now, or None between cells (global state, not the cell contextvar — detached tasks keep stale context copies).""" @@ -539,11 +547,24 @@ def _compile_cell(code: str, filename: str) -> tuple[list[types.CodeType], bool] async def _run_codes(codes: list[types.CodeType], ns: dict[str, Any]) -> Any: value: Any = None - for code_obj in codes: - value = eval(code_obj, ns) # noqa: S307 - executing the model's cell is the runtime's job - if code_obj.co_flags & inspect.CO_COROUTINE: - value = await value - return value + try: + for code_obj in codes: + value = eval(code_obj, ns) # noqa: S307 - executing the model's cell is the runtime's job + if code_obj.co_flags & inspect.CO_COROUTINE: + value = await value + return value + finally: + execution = _current_cell_execution.get() + if execution is not None and execution.bash_admissions: + # The owner must remain live, not merely the protocol request: that + # live owner is the capability used to tag a host start with cell_id. + outcomes = await asyncio.gather( + *(asyncio.shield(admission) for admission in execution.bash_admissions), + return_exceptions=True, + ) + for outcome in outcomes: + if isinstance(outcome, BaseException): + raise outcome async def _run_guarded(task: asyncio.Task[Any], rid: str) -> tuple[str, Any, dict[str, Any] | None]: diff --git a/crates/kernel/rlm-runtime/src/rlm/shell.py b/crates/kernel/rlm-runtime/src/rlm/shell.py index d463b2fb..35fcf37b 100644 --- a/crates/kernel/rlm-runtime/src/rlm/shell.py +++ b/crates/kernel/rlm-runtime/src/rlm/shell.py @@ -12,12 +12,15 @@ async def shell( additional_permissions: dict[str, Any] | None = None, prefix_rule: list[str] | None = None, timeout_ms: int | None = None, + workdir: str | None = None, ) -> dict[str, Any]: """Run a host shell command after authorization, returning its structured result. Use with_additional_permissions for narrow grants, or require_escalated for - an approved unsandboxed command. A denial raises RuntimeError; the persistent - Python kernel remains fenced. Ordinary bash() retains its local behavior. + an approved unsandboxed command. A denial raises RuntimeError. For a live + handle with background execution, use bash(), which also runs on the host. + workdir is resolved by the host relative to the session workspace; omission + uses that workspace, not the Python process's current directory. """ if not isinstance(command, str) or not command.strip(): raise ValueError("command must be a non-empty string") @@ -32,6 +35,7 @@ async def shell( "additional_permissions": additional_permissions, "prefix_rule": prefix_rule, "timeout_ms": timeout_ms, + "workdir": workdir, }.items(): if value is not None: payload[key] = value diff --git a/crates/kernel/rlm-runtime/tests/test_file_helpers.py b/crates/kernel/rlm-runtime/tests/test_file_helpers.py new file mode 100644 index 00000000..3d439695 --- /dev/null +++ b/crates/kernel/rlm-runtime/tests/test_file_helpers.py @@ -0,0 +1,55 @@ +import errno +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import AsyncMock, patch + +import rlm + + +class FileHelperTests(unittest.IsolatedAsyncioTestCase): + async def test_direct_io_uses_user_files_without_host_requests(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "file.txt" + with patch.object(rlm, "host_request", new_callable=AsyncMock) as host: + await rlm.write(path, "first") + self.assertEqual(await rlm.read(path), "first") + await rlm.write_once(path, "second") + self.assertEqual(await rlm.read_once(path), "second") + host.assert_not_awaited() + + async def test_read_failures_use_host_with_once_semantics(self): + for reader, extra in ((rlm.read, {}), (rlm.read_once, {"once": True})): + with self.subTest(reader=reader.__name__): + with patch("builtins.open", side_effect=OSError(errno.EACCES, "denied")), patch.object( + rlm, "host_request", new_callable=AsyncMock, return_value={"content": "host bytes"} + ) as host: + self.assertEqual(await reader("file.txt"), "host bytes") + host.assert_awaited_once_with( + "fs.read", {"path": "file.txt", "errno": errno.EACCES, **extra} + ) + + async def test_write_failures_use_host_with_once_semantics(self): + for writer, extra in ((rlm.write, {}), (rlm.write_once, {"once": True})): + with self.subTest(writer=writer.__name__): + with patch("builtins.open", side_effect=OSError(errno.EACCES, "denied")), patch.object( + rlm, "host_request", new_callable=AsyncMock, return_value={} + ) as host: + self.assertIsNone(await writer("file.txt", "content")) + host.assert_awaited_once_with( + "fs.write", {"filePath": "file.txt", "content": "content", **extra} + ) + + async def test_host_denial_surfaces_as_permission_error(self): + for helper, args in ( + (rlm.read, ("file.txt",)), + (rlm.read_once, ("file.txt",)), + (rlm.write, ("file.txt", "content")), + (rlm.write_once, ("file.txt", "content")), + ): + with self.subTest(helper=helper.__name__): + with patch("builtins.open", side_effect=OSError(errno.EACCES, "denied")), patch.object( + rlm, "host_request", new_callable=AsyncMock, side_effect=RuntimeError("host denied") + ): + with self.assertRaisesRegex(PermissionError, "host denied"): + await helper(*args) diff --git a/crates/kernel/rlm-runtime/tests/test_host_bash.py b/crates/kernel/rlm-runtime/tests/test_host_bash.py new file mode 100644 index 00000000..075478fa --- /dev/null +++ b/crates/kernel/rlm-runtime/tests/test_host_bash.py @@ -0,0 +1,232 @@ +import asyncio +import os +import unittest +from importlib import import_module +from unittest.mock import patch + +import rlm + +bash_module = import_module("rlm.bash") + + +def snapshot(running=True, output="", code=None): + return {"handle_id": "host-opaque-1", "running": running, + "exit_code": code, "output": output, "duration": 0.25} + + +class HostBashTests(unittest.IsolatedAsyncioTestCase): + async def test_pending_cached_state_and_completion(self): + admitted = asyncio.Event() + calls = [] + async def host(kind, payload): + calls.append((kind, payload)) + if kind == "bash.start": + await admitted.wait() + return snapshot(output="first") + return snapshot(False, "first\nlast\n", 0) + with patch.object(rlm, "host_request", host), patch.object(bash_module, "_POLL_INTERVAL", 0): + handle = rlm.bash("echo hello") + self.assertEqual((handle.handle_id, handle.pid, handle.running, handle.poll(), handle.output()), + (None, None, True, None, "")) + self.assertEqual(calls, []) + admitted.set() + result = await handle + self.assertEqual(result, rlm.BashResult(0, "first\nlast\n", 0.25)) + self.assertEqual((handle.handle_id, handle.pid, handle.running, handle.poll(), handle.tail(1)), + ("host-opaque-1", None, False, result, "last")) + self.assertEqual(await handle, result) + self.assertEqual(calls, [("bash.start", {"command": "echo hello", "workdir": os.getcwd(), + "sandbox_permissions": "use_default"}), + ("bash.poll", {"handle_id": "host-opaque-1"}), + ("bash.forget", {"handle_id": "host-opaque-1"})]) + + async def test_authorization_arguments(self): + calls = [] + async def host(kind, payload): + calls.append((kind, payload)) + return snapshot(False, "", 0) + with patch.object(rlm, "host_request", host): + await rlm.bash("probe", workdir="child", sandbox_permissions="with_additional_permissions", + additional_permissions={"network": {"enabled": True}}, + justification="network probe", prefix_rule=["probe"], timeout_ms=42) + self.assertEqual(calls, [("bash.start", {"command": "probe", "workdir": "child", + "sandbox_permissions": "with_additional_permissions", "additional_permissions": {"network": {"enabled": True}}, + "justification": "network probe", "prefix_rule": ["probe"], "timeout_ms": 42}), + ("bash.forget", {"handle_id": "host-opaque-1"})]) + + async def test_denial_has_no_local_fallback(self): + async def host(kind, payload): + raise RuntimeError("denied") + with patch.object(rlm, "host_request", host), patch("subprocess.Popen") as spawn: + handle = rlm.bash("echo forbidden") + with self.assertRaisesRegex(RuntimeError, "denied"): + await handle + with self.assertRaisesRegex(RuntimeError, "denied"): + handle.poll() + self.assertFalse(handle.running) + spawn.assert_not_called() + + async def test_missing_host_fails_closed(self): + with self.assertRaisesRegex(RuntimeError, "not serving"): + await rlm.bash("echo forbidden") + + async def test_cancel_pending_owned_await_cancels_after_admission(self): + started, admit, killed = asyncio.Event(), asyncio.Event(), asyncio.Event() + calls = [] + async def host(kind, payload): + calls.append(kind) + if kind == "bash.start": + started.set() + await admit.wait() + return snapshot() + if kind == "bash.kill": + killed.set() + return snapshot() + await killed.wait() + return snapshot(False, "stopped", 130) + with patch.object(rlm, "host_request", host), patch.object(bash_module, "_POLL_INTERVAL", 0): + handle = rlm.bash("long command") + task = asyncio.ensure_future(handle) + await started.wait() + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertNotIn("bash.kill", calls) + admit.set() + await killed.wait() + self.assertEqual(await handle, rlm.BashResult(130, "stopped", 0.25)) + + async def test_cancel_background_await_does_not_kill(self): + started, finish = asyncio.Event(), asyncio.Event() + calls = [] + async def host(kind, payload): + calls.append(kind) + if kind == "bash.start": + started.set() + return snapshot() + await finish.wait() + return snapshot(False, "done", 0) + with patch.object(rlm, "host_request", host), patch.object(bash_module, "_POLL_INTERVAL", 0): + handle = rlm.bash("background") + self.assertTrue(handle.running) + task = asyncio.ensure_future(handle) + await started.wait() + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + finish.set() + await handle + self.assertNotIn("bash.kill", calls) + + async def test_kill_pending_handle(self): + killed = asyncio.Event() + async def host(kind, payload): + if kind == "bash.kill": + killed.set() + if kind == "bash.poll": + await killed.wait() + return snapshot(False, "", 130) + return snapshot() + with patch.object(rlm, "host_request", host), patch.object(bash_module, "_POLL_INTERVAL", 0): + handle = rlm.bash("background") + handle.kill() + handle.kill() + self.assertEqual(await handle, rlm.BashResult(130, "", 0.25)) + + async def test_background_notice_and_consumption_use_opaque_id(self): + from rlm import repl + cell_finished, notice = asyncio.Event(), asyncio.Event() + calls, displays = [], [] + async def host(kind, payload): + calls.append((kind, payload)) + if kind == "bash.completed": + notice.set() + return {} + if kind == "bash.consumed": + return {} + return snapshot(False, "done", 0) + with patch.object(rlm, "host_request", host), patch.object(repl, "is_active", return_value=True), patch.object(repl, "emit", side_effect=displays.append), patch.object(bash_module, "_current_cell_completion_context", return_value=(cell_finished, None)), patch.object(bash_module, "_live_cell_owner", return_value=None): + handle = rlm.bash("background") + await handle._task + self.assertEqual(len(calls), 2) + cell_finished.set() + await handle._notice_task + self.assertTrue(notice.is_set()) + with patch.object(bash_module, "_live_cell_owner", return_value=asyncio.current_task()): + handle.output() + await asyncio.sleep(0) + self.assertEqual(calls[2:], [ + ("bash.completed", {"handle_id": "host-opaque-1", "command": "background", "exitCode": 0}), + ("bash.consumed", {"handle_id": "host-opaque-1", "command": "background"})]) + self.assertEqual([d["application/vnd.bash-activity+json"]["active"] for d in displays], [True, False]) + self.assertTrue(all("pid" not in d["application/vnd.bash-activity+json"] for d in displays)) + + async def test_foreground_await_suppresses_notice(self): + from rlm import repl + cell_finished = asyncio.Event() + calls = [] + async def host(kind, payload): + calls.append(kind) + return snapshot(False, "done", 0) + with patch.object(rlm, "host_request", host), patch.object(repl, "is_active", return_value=True), patch.object(repl, "emit"), patch.object(bash_module, "_current_cell_completion_context", return_value=(cell_finished, asyncio.current_task())): + handle = rlm.bash("foreground") + await handle + cell_finished.set() + await handle._notice_task + self.assertEqual(calls, ["bash.start", "bash.forget"]) + + async def test_sync_cell_keeps_start_capability_until_admission(self): + from rlm import repl + frames, ids = [], [] + admitted = asyncio.Event() + async def host(kind, payload): + ids.append((kind, repl.current_host_request_cell_id())) + if kind == "bash.start": + await asyncio.sleep(0) + self.assertFalse(any(frame["event"] == "done" for frame in frames)) + admitted.set() + return snapshot(False, "done", 0) + ns = {"bash": rlm.bash} + with patch.object(rlm, "host_request", host), patch.object(repl, "_loop", asyncio.get_running_loop()), patch.object(repl, "_send", side_effect=frames.append), patch.object(repl, "_drain_output"), patch.object(repl, "emit"), patch.object(repl, "is_active", return_value=True): + await repl._handle_execute({"id": "sync-cell", "code": "h = bash('background')"}, ns) + await ns["h"]._notice_task + self.assertTrue(admitted.is_set()) + self.assertEqual(ids, [("bash.start", "sync-cell"), ("bash.forget", "sync-cell"), ("bash.completed", None)]) + self.assertEqual(frames, [{"event": "done", "id": "sync-cell", "status": "ok"}]) + + async def test_background_start_denial_is_a_cell_error(self): + from rlm import repl + frames = [] + async def host(kind, payload): + raise RuntimeError("approval denied") + ns = {"bash": rlm.bash} + with patch.object(rlm, "host_request", host), patch.object(repl, "_loop", asyncio.get_running_loop()), patch.object(repl, "_send", side_effect=frames.append), patch.object(repl, "_drain_output"), patch.object(repl, "emit"), patch.object(repl, "is_active", return_value=True), patch("sys.stderr.write"): + await repl._handle_execute({"id": "denied-cell", "code": "h = bash('background')"}, ns) + await ns["h"]._notice_task + self.assertEqual(frames[-1], {"event": "done", "id": "denied-cell", "status": "error"}) + self.assertIn("approval denied", str(frames[0])) + + async def test_poll_error_schedules_host_cleanup(self): + killed = asyncio.Event() + async def host(kind, payload): + if kind == "bash.start": + return snapshot() + if kind == "bash.kill": + killed.set() + return snapshot() + raise RuntimeError("poll failed") + with patch.object(rlm, "host_request", host), patch.object(bash_module, "_POLL_INTERVAL", 0): + handle = rlm.bash("background") + with self.assertRaisesRegex(RuntimeError, "poll failed"): + await handle + await killed.wait() + await handle._kill_task + + async def test_terminal_host_error_is_not_a_fabricated_exit_code(self): + async def host(kind, payload): + return {**snapshot(False, "partial output"), "error": "launch failed"} + with patch.object(rlm, "host_request", host): + handle = rlm.bash("probe") + with self.assertRaisesRegex(RuntimeError, "launch failed\npartial output"): + await handle + self.assertEqual((handle.output(), handle.tail()), ("partial output", "partial output")) diff --git a/crates/kernel/rlm-runtime/tests/test_rlm_shell.py b/crates/kernel/rlm-runtime/tests/test_rlm_shell.py index fea5a1d8..41312ec9 100644 --- a/crates/kernel/rlm-runtime/tests/test_rlm_shell.py +++ b/crates/kernel/rlm-runtime/tests/test_rlm_shell.py @@ -13,11 +13,11 @@ async def host(kind, payload): for helper in [rlm.shell, rlm.rlm.shell]: self.assertEqual(await helper( "echo probe", sandbox_permissions="require_escalated", - justification="May I run the probe?", timeout_ms=1000, + justification="May I run the probe?", timeout_ms=1000, workdir="child", ), {"status": "ok", "stdout": "probe"}) self.assertEqual(calls, [("bash", { "command": "echo probe", "sandbox_permissions": "require_escalated", - "justification": "May I run the probe?", "timeout_ms": 1000, + "justification": "May I run the probe?", "timeout_ms": 1000, "workdir": "child", })] * 2) async def test_narrow_grant_and_prefix_are_preserved(self): diff --git a/crates/kernel/rlm-runtime/tests/test_windows_shell.py b/crates/kernel/rlm-runtime/tests/test_windows_shell.py index c7e1dd92..0430c31c 100644 --- a/crates/kernel/rlm-runtime/tests/test_windows_shell.py +++ b/crates/kernel/rlm-runtime/tests/test_windows_shell.py @@ -6,7 +6,7 @@ from rlm.windows_shell import find_windows_bash -bash_module = import_module("rlm.bash") +bash_module = import_module("rlm.local_bash") class WindowsShellTests(unittest.TestCase): diff --git a/crates/kernel/src/credential_unix.rs b/crates/kernel/src/credential_unix.rs deleted file mode 100644 index b517ce88..00000000 --- a/crates/kernel/src/credential_unix.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! POSIX credential delivery channel (design doc §9, P2). Devo-owned: codex's -//! per-exec model never delivers credentials to a long-lived process. -//! -//! A Unix stream socketpair is created at spawn; one end stays with the -//! host, the other is inherited by the kernel process. Grants travel as a -//! one-line JSON payload plus one passed descriptor: -//! -//! 1. host opens the granted root with `open_tree(OPEN_TREE_CLONE)` — a -//! detached mount clone whose root IS the granted directory, so a dirfd -//! handed out this way clamps `..` at the grant boundary (the naive -//! "open the dir and pass the fd" alternative lets `openat(fd, "../..")` -//! walk the host mount tree with the kernel's own uid); -//! 2. host fstats the fd and sends `{"type":"grant",...}` with the fd -//! attached via `SCM_RIGHTS` plus the claimed `(dev, ino)`; -//! 3. the kernel-side facade verifies `fstat(fd)` matches the claim before -//! accepting (anti-swap), stores it per granted root, and serves -//! `rlm.read`/`rlm.write` with `os.open(path, dir_fd=fd)`. -//! -//! Revocation is cooperative: a `{"type":"revoke"}` message makes the -//! facade drop the descriptor (already-open handles keep working — a -//! documented residual, §9). Plain libc: no high-level socket crate needed. - -use std::io; -use std::os::fd::AsRawFd; -use std::os::fd::FromRawFd; -use std::os::fd::OwnedFd; -use std::os::fd::RawFd; -use std::path::Path; - -#[derive(Debug)] -pub struct GrantChannel { - socket: OwnedFd, - peer: OwnedFd, -} - -/// Message shape sent with the descriptor (also carries the anti-swap claim). -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub(crate) struct GrantMessage { - #[serde(rename = "type")] - pub kind: String, // "grant" | "revoke" - pub root: String, - pub access: String, // "read" | "write" - pub dev: u64, - pub ino: u64, -} - -const SCM_MAX_FD: usize = 1; - -#[allow(dead_code)] // wired incrementally: session holds the channel; the -// approval callback and Python facade land in the follow-up commit. -impl GrantChannel { - /// Create the channel; the peer descriptor is kept internally so the - /// host can duplicate it into the child at spawn (peer_fd). - pub(crate) fn new() -> io::Result { - let mut fds = [0 as RawFd; 2]; - let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) }; - if rc != 0 { - return Err(io::Error::last_os_error()); - } - // No CLOEXEC: the peer duplicate survives the kernel exec. - let (host, peer) = unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }; - Ok(Self { socket: host, peer }) - } - - /// Test constructor: channel + a duplicate of the peer (so the receiving - /// side can recvmsg on its own copy). - #[cfg(test)] - pub(crate) fn with_peer_copy() -> io::Result<(Self, OwnedFd)> { - let me = Self::new()?; - let peer_dup = dup_no_cloexec(me.peer_fd())?; - Ok((me, unsafe { OwnedFd::from_raw_fd(peer_dup) })) - } - - /// Peer fd number (duplicate this into the child at spawn). - pub(crate) fn peer_fd(&self) -> RawFd { - self.peer.as_raw_fd() - } - - /// Deliver one grant: open `root` and send it with its (dev, ino) claim. - /// Linux prefers a detached mount clone; other Unix hosts use a directory fd. - pub fn grant(&self, root: &Path, access: &str) -> io::Result<()> { - let path = std::ffi::CString::new(root.as_os_str().as_encoded_bytes()) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; - // Prefer open_tree(OPEN_TREE_CLONE|AT_RECURSIVE): a detached mount - // whose root IS `root`, clamping `..` at the grant boundary. CLONE - // needs CAP_SYS_ADMIN over the mount ns, so unprivileged hosts get - // EPERM — degrade to a plain O_PATH dirfd (no mount clamp; the OS - // fence stays the actual wall, and `..`-traversal beyond the grant - // remains subject to the kernel's Landlock/bwrap ruleset). - #[cfg(target_os = "linux")] - let tree = { - let fd = unsafe { - libc::syscall( - libc::SYS_open_tree, - libc::AT_FDCWD, - path.as_ptr(), - 1 | 0x8000, - ) - }; - if fd >= 0 { - unsafe { OwnedFd::from_raw_fd(fd as RawFd) } - } else { - let fd = unsafe { libc::open(path.as_ptr(), libc::O_PATH | libc::O_DIRECTORY) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - unsafe { OwnedFd::from_raw_fd(fd) } - } - }; - // macOS has no open_tree or O_PATH. This directory descriptor remains - // subject to the sandbox policy; it does not clamp parent traversal. - #[cfg(not(target_os = "linux"))] - let tree = { - let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - unsafe { OwnedFd::from_raw_fd(fd) } - }; - let claim = stat_claim(tree.as_raw_fd())?; - let message = GrantMessage { - kind: "grant".to_string(), - root: root.to_string_lossy().into_owned(), - access: access.to_string(), - dev: claim.0, - ino: claim.1, - }; - self.send_with_fd(&message, &tree) - } - - /// Ask the kernel facade to drop the descriptor for `root`. - pub fn revoke(&self, root: &Path) -> io::Result<()> { - let message = GrantMessage { - kind: "revoke".to_string(), - root: root.to_string_lossy().into_owned(), - access: String::new(), - dev: 0, - ino: 0, - }; - self.send_with_fd(&message, &Self::dev_null()?) - } - - fn dev_null() -> io::Result { - let path = std::ffi::CString::new("/dev/null")?; - let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - Ok(unsafe { OwnedFd::from_raw_fd(fd) }) - } - - /// Receive one message + fd (kernel/test side). `buf` must be large - /// enough for the JSON payload. - #[allow(clippy::unnecessary_cast)] // libc length fields differ between GNU and ARM64 musl. - pub(crate) fn recv_with_fd(fd: RawFd, buf: &mut [u8]) -> io::Result<(usize, OwnedFd)> { - let mut cmsg_buf = vec![0u8; unsafe { libc::CMSG_SPACE((SCM_MAX_FD * 4) as u32) } as usize]; - let mut iov = libc::iovec { - iov_base: buf.as_mut_ptr() as *mut libc::c_void, - iov_len: buf.len(), - }; - let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; - msg.msg_iov = &mut iov; - msg.msg_iovlen = 1; - msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; - msg.msg_controllen = cmsg_buf.len() as _; - let n = unsafe { libc::recvmsg(fd, &mut msg, 0) }; - if n < 0 { - return Err(io::Error::last_os_error()); - } - let mut received: Option = None; - let cmsg_ptr = cmsg_buf.as_ptr() as *mut libc::cmsghdr; - let mut cmsg = unsafe { libc::CMSG_FIRSTHDR(&msg) }; - while !cmsg.is_null() { - let kind = unsafe { (*cmsg).cmsg_type }; - if kind == libc::SCM_RIGHTS { - let data = unsafe { libc::CMSG_DATA(cmsg) } as *const i32; - let count = ((unsafe { (*cmsg).cmsg_len } as usize - - unsafe { libc::CMSG_LEN(0) } as usize) - / std::mem::size_of::()) - .min(SCM_MAX_FD); - for i in 0..count { - let fd = unsafe { *data.add(i) }; - if received.is_none() { - received = Some(unsafe { OwnedFd::from_raw_fd(fd) }); - } else { - unsafe { libc::close(fd) }; - } - } - } - cmsg = unsafe { libc::CMSG_NXTHDR(&msg, cmsg_ptr) }; - } - let fd = received.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "grant message carried no fd") - })?; - Ok((n as usize, fd)) - } - - #[allow(clippy::unnecessary_cast)] // libc length fields differ between GNU and ARM64 musl. - fn send_with_fd(&self, message: &GrantMessage, fd: &OwnedFd) -> io::Result<()> { - let mut payload = serde_json::to_vec(message).map_err(io::Error::other)?; - payload.push(b'\n'); - let mut iov = libc::iovec { - iov_base: payload.as_ptr() as *mut libc::c_void, - iov_len: payload.len(), - }; - let mut cmsg_buf = vec![0u8; unsafe { libc::CMSG_SPACE((SCM_MAX_FD * 4) as u32) } as usize]; - let cmsg = cmsg_buf.as_mut_ptr() as *mut libc::cmsghdr; - unsafe { - (*cmsg).cmsg_level = libc::SOL_SOCKET; - (*cmsg).cmsg_type = libc::SCM_RIGHTS; - (*cmsg).cmsg_len = libc::CMSG_LEN((SCM_MAX_FD * 4) as u32) as _; - let data = libc::CMSG_DATA(cmsg) as *mut i32; - *data = fd.as_raw_fd(); - } - let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; - msg.msg_iov = &mut iov; - msg.msg_iovlen = 1; - msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; - msg.msg_controllen = cmsg_buf.len() as _; - let rc = unsafe { libc::sendmsg(self.socket.as_raw_fd(), &msg, 0) }; - if rc < 0 { - return Err(io::Error::last_os_error()); - } - Ok(()) - } -} - -pub(crate) fn stat_claim(fd: RawFd) -> io::Result<(u64, u64)> { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - let rc = unsafe { libc::fstat(fd, &mut st) }; - if rc != 0 { - return Err(io::Error::last_os_error()); - } - Ok((st.st_dev as u64, st.st_ino as u64)) -} - -/// Duplicate `fd` without CLOEXEC so it survives the child's exec. -pub(crate) fn dup_no_cloexec(fd: RawFd) -> io::Result { - let dup = unsafe { libc::dup(fd) }; - if dup < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(dup) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn channel_delivers_grant_fd_with_matching_claim() { - let (host, peer) = GrantChannel::with_peer_copy().expect("socketpair"); - let dir = tempfile::tempdir().expect("tempdir"); - - host.grant(dir.path(), "write").expect("grant"); - - let mut buf = [0u8; 1024]; - let (n, fd) = GrantChannel::recv_with_fd(peer.as_raw_fd(), &mut buf).expect("recv"); - let message: GrantMessage = serde_json::from_slice(&buf[..n]).expect("grant message json"); - assert_eq!(message.kind, "grant"); - assert_eq!(message.access, "write"); - assert_eq!(message.root, dir.path().to_string_lossy()); - - // Anti-swap: fstat must match the claim. - let (dev, ino) = stat_claim(fd.as_raw_fd()).expect("fstat received fd"); - assert_eq!(dev, message.dev); - assert_eq!(ino, message.ino); - } - - #[cfg(target_os = "linux")] - #[test] - fn grant_fd_parent_traversal_behavior_matches_capabilities() { - // When open_tree(CLONE) is available the grant fd is a detached mount - // clone: `..` through it clamps at the grant root. Unprivileged hosts - // (EPERM) degrade to a plain O_PATH dirfd: `..` walks to the host - // parent — documented, and still contained by the OS fence's ruleset. - let (host, peer) = GrantChannel::with_peer_copy().expect("socketpair"); - let dir = tempfile::tempdir().expect("tempdir"); - host.grant(dir.path(), "write").expect("grant"); - let mut buf = [0u8; 1024]; - let (_n, fd) = GrantChannel::recv_with_fd(peer.as_raw_fd(), &mut buf).expect("recv"); - - let path = std::ffi::CString::new(dir.path().as_os_str().as_encoded_bytes()).unwrap(); - const SYS_OPEN_TREE: libc::c_long = 428; - let cloned = unsafe { libc::syscall(SYS_OPEN_TREE, -100i64, path.as_ptr(), 1 | 0x8000) }; - - let parent = std::ffi::CString::new("..").expect("cstring"); - let opened = unsafe { - libc::openat( - fd.as_raw_fd(), - parent.as_ptr(), - libc::O_RDONLY | libc::O_DIRECTORY, - ) - }; - assert!(opened >= 0, "openat ..: {}", io::Error::last_os_error()); - let (opened_dev, opened_ino) = stat_claim(opened).expect("fstat parent"); - let (root_dev, root_ino) = stat_claim(fd.as_raw_fd()).expect("fstat grant root"); - unsafe { libc::close(opened) }; - if cloned >= 0 { - unsafe { libc::close(cloned as i32) }; - assert_eq!( - (opened_dev, opened_ino), - (root_dev, root_ino), - "with open_tree(CLONE), '..' must clamp at the grant root" - ); - } else { - // Degraded mode: no clamp (the fence stays the wall). - assert_ne!((opened_dev, opened_ino), (root_dev, root_ino)); - } - } -} diff --git a/crates/kernel/src/fence.rs b/crates/kernel/src/fence.rs deleted file mode 100644 index a5e0cbdc..00000000 --- a/crates/kernel/src/fence.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! OS fence wiring for the RLM kernel. -//! -//! Windows: the kernel argv is wrapped in the restricted-token sandbox launcher -//! (`devo.exe --run-as-windows-sandbox ... python -m rlm.repl`). Linux/macOS -//! enforcement uses a child sandbox plan when available. If enforcement is -//! unavailable, a fence request is an explicit downgrade, never a silent one. -//! -//! Windows provisioning is performed by the wrapper before Python starts; a -//! missing setup marker must never bypass that wrapper. If preparing the -//! wrapped launch fails, the downgrade is loud (`tracing::warn`) and the caller -//! is expected to surface it in the UI and -//! record a `fence-off` event. The one hard exception stays with the approval -//! pipeline: profiles with deny-read paths never drop their sandbox. - -#[cfg(windows)] -use std::process::Stdio; -use tokio::process::Command; - -/// Outcome of the fence request for a spawned kernel — recorded on the -/// session so callers (core/server) can emit rollout events and UI warnings. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FenceState { - /// No fence requested; spawn bare by design. - NotRequested, - /// Sandbox provisioned; kernel argv wrapped in the sandbox launcher. - Fenced, - /// Fence requested but unavailable; kernel runs unfenced with an explicit, - /// logged downgrade (never silent). - DowngradedUnfenced, -} - -/// Result of the fence application: the launch command, the decision, and — -/// on Windows when fenced — the per-session credential authority whose SID -/// traveled in the kernel's restricted token. Unix platforms carry -/// no authority yet (dirfd delivery channel is follow-up work). -pub(crate) struct FenceOutcome { - pub command: Command, - pub state: FenceState, - #[cfg(windows)] - pub credentials: Option, - /// Unix pipe-mode enforcement plan (Landlock + seccomp), applied in the - /// child via `pre_exec` — the same mechanism the product's shell sandbox - /// uses when no outer wrapper carries the policy. - #[cfg(unix)] - pub child_plan: Option, - /// Unix credential delivery channel host end (fenced kernels only): - /// grants travel as fds over SCM_RIGHTS. `None` when unfenced. - #[cfg(unix)] - pub grant_channel: Option, -} - -/// Read roots the interpreter itself needs (`python_ro` profile): the -/// Python runtime prefix (`/bin/python*` on Unix, `/python.exe` -/// on Windows) plus every PYTHONPATH entry (vendored rlm-runtime, skills). -/// Private Windows installs under AppData need these explicit read/execute -/// grants just as Unix's no-default-read fence needs interpreter access. -fn interpreter_read_roots(config: &crate::session::KernelSessionConfig) -> Vec { - let mut roots = Vec::new(); - let python = if config.python.is_absolute() { - Some(config.python.clone()) - } else { - // Bare interpreter name: resolve via PATH so the runtime prefix can be - // granted (the fence must be able to see the binary it execs). - std::env::var_os("PATH").and_then(|paths| { - std::env::split_paths(&paths) - .map(|dir| dir.join(&config.python)) - .find(|candidate| candidate.is_absolute() && candidate.is_file()) - }) - }; - if let Some(python) = python - && let Some(parent) = python.parent() - { - #[cfg(unix)] - let prefix = parent.parent(); - #[cfg(windows)] - let prefix = Some(parent); - if let Some(prefix) = prefix - && prefix.is_absolute() - { - roots.push(prefix.to_path_buf()); - } - } - roots.extend(config.python_path_entries.iter().cloned()); - roots -} - -/// Unix fence: enforcement is entirely the Landlock/seccomp -/// child plan applied in `pre_exec` on the kernel process itself — the same -/// mechanism the product's pipe-mode shell sandbox uses. No outer wrapper: -/// a PipeComposed bwrap only adds read-deny bind-overs (rlm-kernel has none) -/// and network plumbing, and applying the Landlock plan to the bwrap parent -/// breaks its user-namespace setup (uid_map writes are refused). A missing -/// plan is an explicit downgrade, never a silent one. -#[cfg(unix)] -pub(super) fn fence_or_bare( - config: &crate::session::KernelSessionConfig, - bare: Command, -) -> FenceOutcome { - let Some(spec) = config.fence.as_ref() else { - return FenceOutcome { - command: bare, - state: FenceState::NotRequested, - child_plan: None, - grant_channel: None, - }; - }; - let overlay = devo_sandbox::SandboxPermissionOverlay { - read_paths: { - let mut roots = interpreter_read_roots(config); - roots.extend(spec.readable_roots.iter().cloned()); - roots - }, - write_paths: spec.writable_roots.clone(), - // `Unchanged` keeps the rlm-kernel profile's network restriction; - // `Enabled` would opt the kernel OUT of it. - network: if spec.restrict_network { - devo_sandbox::SandboxNetworkPermission::Unchanged - } else { - devo_sandbox::SandboxNetworkPermission::Enabled - }, - }; - // `rlm-kernel` has no default read: the fence - // derives from the session's permission state, so reads outside the - // granted roots are OS-refused and `rlm.read` escalates to the host - // approval pipeline instead of silently succeeding. - match devo_util_process::sandbox::resolve_profile_for_spawn_with_overlay( - Some("rlm-kernel"), - &config.cwd, - Some(&overlay), - ) { - Ok(Some(plan)) => FenceOutcome { - command: bare, - state: FenceState::Fenced, - child_plan: Some(plan), - grant_channel: crate::credential_unix::GrantChannel::new().ok(), - }, - Ok(None) => { - tracing::warn!( - "RLM kernel fence requested but no enforcement plan resolved; kernel runs \ - UNFENCED with full user permissions (explicit downgrade)" - ); - FenceOutcome { - command: bare, - state: FenceState::DowngradedUnfenced, - child_plan: None, - grant_channel: None, - } - } - Err(err) => { - tracing::warn!( - error = %err, - "RLM kernel fence enforcement plan resolution failed; kernel runs UNFENCED \ - with full user permissions (explicit downgrade)" - ); - FenceOutcome { - command: bare, - state: FenceState::DowngradedUnfenced, - child_plan: None, - grant_channel: None, - } - } - } -} - -#[cfg(windows)] -pub(super) fn wrap_or_bare( - config: &crate::session::KernelSessionConfig, - bare: Command, - devo_home: std::io::Result, -) -> FenceOutcome { - let Some(spec) = config.fence.as_ref() else { - return FenceOutcome { - command: bare, - state: FenceState::NotRequested, - credentials: None, - }; - }; - let devo_home = match devo_home { - Ok(home) => home, - Err(err) => { - tracing::warn!( - error = %err, - "RLM kernel fence requested but devo home is unavailable; kernel runs UNFENCED \ - with full user permissions (explicit downgrade)" - ); - return FenceOutcome { - command: bare, - state: FenceState::DowngradedUnfenced, - credentials: None, - }; - } - }; - // The wrapper provisions missing/outdated sandbox accounts with UAC consent - // and refreshes this request's exact ACL roots before starting Python. Do - // not gate it on setup markers: that skips first-run setup and runs Python - // with full user permissions. A failed/canceled setup fails the handshake. - // P2: mint the per-session credential authority. Its SID travels in - // the kernel's restricted token (identity marker, no access by - // itself); the retained authority delivers later grants as ACEs. - let session_id = format!("kernel-{}", uuid::Uuid::new_v4()); - // The SID is only recoverable from the running token, so log it: - // crash recovery, manual grants, and audits all key off it. - tracing::info!(session_id = %session_id, "RLM kernel session credential pending mint"); - let credentials = - match devo_windows_sandbox::SessionCredentialAuthority::new(&session_id, &devo_home) { - Ok(authority) => authority, - Err(err) => { - tracing::warn!( - error = %err, - "RLM kernel session credential authority could not be minted; \ - kernel runs UNFENCED (explicit downgrade)" - ); - return FenceOutcome { - command: bare, - state: FenceState::DowngradedUnfenced, - credentials: None, - }; - } - }; - tracing::info!( - session_id = %session_id, - sid = %credentials.sid(), - "RLM kernel session credential minted" - ); - let request = devo_windows_sandbox::WindowsSandboxRequest { - // Shell-shaped fields are ignored by the direct-argv launcher. - command: String::new(), - shell_program: String::new(), - shell_args: Vec::new(), - cwd: config.cwd.clone(), - readable_roots: { - let mut roots = interpreter_read_roots(config); - roots.extend(spec.readable_roots.iter().cloned()); - roots - }, - writable_roots: spec.writable_roots.clone(), - deny_read: spec.deny_read.clone(), - restrict_network: spec.restrict_network, - session_credential_sid: Some(credentials.sid().to_string()), - env_extra: crate::session::kernel_env_overrides(config), - }; - let argv = vec![ - config.python.to_string_lossy().into_owned(), - "-m".to_string(), - "rlm.repl".to_string(), - ]; - // The sandbox wrapper serializes the child's environment into - // argv before `cmd.env_remove` can act. Filter the snapshot before - // it enters either the wrapper argv or the eventual Python kernel. - let installed_bundle = devo_util_paths::runtime::RuntimeBundle::current().is_some(); - let inherited_env = std::env::vars() - .filter(|(key, value)| !crate::session::is_credential_env_var(key, value)) - .filter(|(key, _)| { - !installed_bundle - || !devo_util_paths::runtime_environment::is_interpreter_env_var( - key.as_ref(), - devo_util_paths::runtime_environment::Interpreter::Python, - ) - }) - .collect(); - match devo_windows_sandbox::prepare_windows_sandbox_launch_for_argv_with_env( - &request, - argv, - inherited_env, - ) { - Ok(launch) => { - let mut cmd = Command::new(launch.program); - cmd.args(launch.args) - .current_dir(&config.cwd) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - for (key, value) in &launch.env { - cmd.env(key, value); - } - FenceOutcome { - command: cmd, - state: FenceState::Fenced, - credentials: Some(credentials), - } - } - Err(err) => { - tracing::warn!( - error = %err, - "RLM kernel fence launch failed; kernel runs UNFENCED with full user \ - permissions (explicit downgrade)" - ); - FenceOutcome { - command: bare, - state: FenceState::DowngradedUnfenced, - credentials: None, - } - } - } -} - -#[cfg(all(test, windows))] -mod tests { - use super::{FenceState, wrap_or_bare}; - use crate::session::{KernelFenceSpec, KernelSessionConfig}; - use pretty_assertions::assert_eq; - use tokio::process::Command; - - #[test] - fn no_request_spawns_bare_without_a_home() { - let outcome = wrap_or_bare( - &KernelSessionConfig::default(), - Command::new("python"), - Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no home")), - ); - assert_eq!(outcome.state, FenceState::NotRequested); - assert_eq!(outcome.command.as_std().get_program(), "python"); - assert_eq!(outcome.credentials.is_some(), false); - } - - #[test] - fn first_run_uses_the_setup_capable_wrapper_instead_of_bare_python() { - let home = tempfile::tempdir().expect("fresh unprovisioned home"); - assert_eq!( - devo_windows_sandbox::sandbox_setup_is_complete(home.path()), - false, - ); - let config = KernelSessionConfig { - cwd: home.path().to_path_buf(), - fence: Some(KernelFenceSpec { - readable_roots: vec![home.path().to_path_buf()], - writable_roots: vec![home.path().to_path_buf()], - deny_read: Vec::new(), - restrict_network: true, - }), - ..KernelSessionConfig::default() - }; - let outcome = wrap_or_bare( - &config, - Command::new(&config.python), - Ok(home.path().to_path_buf()), - ); - assert_eq!(outcome.state, FenceState::Fenced); - assert_eq!(outcome.credentials.is_some(), true); - let args: Vec<_> = outcome.command.as_std().get_args().collect(); - assert_eq!(args[0], "--run-as-windows-sandbox"); - assert_eq!(args.contains(&std::ffi::OsStr::new("elevated")), true); - // Preparing argv is side-effect free; provisioning happens in the - // wrapper child before it launches the interpreter. - assert_eq!( - devo_windows_sandbox::sandbox_setup_is_complete(home.path()), - false, - ); - } - - #[test] - fn unavailable_home_remains_an_explicit_downgrade() { - let config = KernelSessionConfig { - fence: Some(KernelFenceSpec { - readable_roots: Vec::new(), - writable_roots: Vec::new(), - deny_read: Vec::new(), - restrict_network: true, - }), - ..KernelSessionConfig::default() - }; - let outcome = wrap_or_bare( - &config, - Command::new(&config.python), - Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no home")), - ); - assert_eq!(outcome.state, FenceState::DowngradedUnfenced); - assert_eq!(outcome.command.as_std().get_program(), &config.python); - assert_eq!(outcome.credentials.is_some(), false); - } -} - -#[cfg(test)] -mod interpreter_tests { - use super::interpreter_read_roots; - use crate::session::KernelSessionConfig; - use pretty_assertions::assert_eq; - - #[test] - fn private_runtime_grants_are_limited_to_interpreter_and_pythonpath() { - let installation = tempfile::tempdir().expect("private install"); - let prefix = installation.path().join("runtime/python"); - #[cfg(windows)] - let python = prefix.join("python.exe"); - #[cfg(unix)] - let python = prefix.join("bin/python3"); - let python_site = installation.path().join("runtime/python-site"); - let config = KernelSessionConfig { - python, - python_path_entries: vec![python_site.clone()], - ..KernelSessionConfig::default() - }; - assert_eq!(interpreter_read_roots(&config), vec![prefix, python_site]); - } -} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index e7123291..5a596112 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -1,23 +1,16 @@ //! CPython RLM REPL kernel host (REPL protocol v3). //! //! Spawns `python -m rlm.repl`, speaks newline-delimited UTF-8 JSON, and keeps -//! a session-scoped namespace across turns. See `L2-DES-RLM-001`. +//! a session-scoped namespace across turns. -#[cfg(unix)] -mod credential_unix; - -#[cfg(unix)] -pub use credential_unix::GrantChannel; -mod fence; mod protocol; mod session; -pub use fence::FenceState; pub use protocol::{KernelEvent, KernelRequest, PROTOCOL_VERSION, ReadyEvent, ReplError}; pub use session::{ ATTACHMENT_DISPLAY_MIME, CellAttachment, CellDiffDisplay, CellOutput, CellWaitOutcome, - DIFF_DISPLAY_MIME, ExecutionSurface, HostRequestHandler, InFlightCell, KernelFenceSpec, - KernelSession, KernelSessionConfig, MAX_ATTACHMENT_DATA_CHARS, SpawnError, WorkspaceAccess, + DIFF_DISPLAY_MIME, ExecutionSurface, HostRequestHandler, InFlightCell, KernelSession, + KernelSessionConfig, MAX_ATTACHMENT_DATA_CHARS, SpawnError, WorkspaceAccess, default_runtime_pythonpath, deny_host_handler, parse_attachment_display, parse_diff_display, parse_host_request_data, }; diff --git a/crates/kernel/src/protocol.rs b/crates/kernel/src/protocol.rs index 617ac5df..25402c32 100644 --- a/crates/kernel/src/protocol.rs +++ b/crates/kernel/src/protocol.rs @@ -7,21 +7,63 @@ use thiserror::Error; /// Wire protocol version announced in the `ready` event. pub const PROTOCOL_VERSION: u32 = 3; +/// One request from the host to the kernel, serialized as a single JSON line +/// written to the kernel's stdin. +/// +/// Requests run strictly in order, one at a time, except `interrupt` and +/// `host_reply`, which are handled on the reader thread. `execute`, `snapshot`, +/// `restore`, `list_names`, and `shutdown` (when it carries an `id`) each +/// receive exactly one matching [`KernelEvent::Done`] with the same `id`. +/// `interrupt` and `host_reply` are fire-and-forget and produce no reply. +/// +/// # Serialized examples +/// +/// ```json +/// {"type":"execute","id":"c1","code":"x = 1"} +/// {"type":"interrupt","id":"c1"} +/// {"type":"interrupt"} +/// {"type":"host_reply","id":"h1","data":{"status":"ok","result":{}}} +/// {"type":"snapshot","id":"s1","path":"/tmp/kernel.dill","manifest_path":"/tmp/kernel.json","max_bytes":1000000} +/// {"type":"restore","id":"r1","path":"/tmp/kernel.dill"} +/// {"type":"list_names","id":"n1"} +/// {"type":"shutdown","id":"q1"} +/// {"type":"shutdown"} +/// ``` +/// +/// Optional fields (`id` on `interrupt` and `shutdown`, and the three +/// `snapshot` limits) are omitted from the wire when `None`. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum KernelRequest { - Execute { - id: String, - code: String, - }, + /// Run `code` as one cell in the persistent namespace. + /// + /// `id` scopes every event produced by the cell and is echoed by its final + /// `done`. + Execute { id: String, code: String }, + + /// Raise `KeyboardInterrupt` in a running or queued cell. + /// + /// With `id`, that request only is interrupted. Without it, the running + /// request is interrupted, or the next queued request if none has started + /// yet. Produces no reply. Interrupt { #[serde(skip_serializing_if = "Option::is_none")] id: Option, }, - HostReply { - id: String, - data: Value, - }, + + /// Answer a kernel [`KernelEvent::HostRequest`] carrying the same `id`. + /// + /// `data` is delivered verbatim to the awaiting `rlm.repl.host_request` + /// call. Produces no reply. + HostReply { id: String, data: Value }, + + /// Serialize the user namespace with dill and write a JSON manifest. + /// + /// `path` receives the payload. `manifest_path` receives metadata about the + /// snapshot. `max_bytes` caps the total payload, `max_variable_bytes` caps + /// one pickled binding, and `prune_oversized` also deletes bindings that + /// exceed the per-variable cap. The final `done` reports `saved`, + /// `skipped`, `pruned`, and `bytes`. Snapshot { id: String, path: String, @@ -33,48 +75,102 @@ pub enum KernelRequest { #[serde(skip_serializing_if = "Option::is_none")] prune_oversized: Option, }, - Restore { - id: String, - path: String, - }, - ListNames { - id: String, - }, + + /// Restore names from a snapshot previously written by `Snapshot`. + /// + /// A missing file is reported as an ok, empty restore with a `reason`; a + /// corrupt file is reported as an error. Per-name failures are listed in + /// `failed`. + Restore { id: String, path: String }, + + /// List the sorted user-defined top-level names in the namespace. + /// + /// The matching `done` carries them in `names`. + ListNames { id: String }, + + /// Stop the runtime, kill its background Bash process groups, and exit 0. + /// + /// With `id`, the kernel emits a final `done` before exiting. Closing stdin + /// has the same effect. Shutdown { #[serde(skip_serializing_if = "Option::is_none")] id: Option, }, } +/// One event from the kernel to the host, deserialized from a single JSON line +/// read from the kernel's stdout. +/// +/// Events are notifications, not replies: one request can produce many events. +/// Every request with an `id` ends with exactly one [`KernelEvent::Done`] +/// carrying that same `id`, after all of its other events. `ready` is sent +/// once at startup. Events whose `id` is `None` (raw fd writes, user threads, +/// untagged displays) belong to no request. +/// +/// # Serialized examples +/// +/// ```json +/// {"event":"ready","protocol":3,"python":"3.13.11"} +/// {"event":"stdout","id":"c1","text":"hello\n"} +/// {"event":"stderr","id":null,"text":"warning\n"} +/// {"event":"result","id":"c1","text":"1"} +/// {"event":"display","id":"c1","data":{"text/plain":"..."}} +/// {"event":"host_request","id":"h1","cell_id":"c1","data":{"type":"bash","params":{}}} +/// {"event":"error","id":"c1","ename":"ValueError","evalue":"bad","traceback":["..."]} +/// {"event":"done","id":"c1","status":"ok"} +/// {"event":"done","id":"s1","status":"ok","saved":["x"],"skipped":[],"pruned":[],"bytes":1234} +/// {"event":"done","id":"r1","status":"ok","restored":["x"],"failed":[]} +/// {"event":"done","id":"n1","status":"ok","names":["x"]} +/// ``` #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(tag = "event", rename_all = "snake_case")] pub enum KernelEvent { - Ready { - protocol: u32, - python: String, - }, - Stdout { - id: Option, - text: String, - }, - Stderr { - id: Option, - text: String, - }, - Result { - id: String, - text: String, - }, - Display { - id: Option, - data: Value, - }, + /// Handshake, sent once before the kernel accepts requests. + /// + /// `protocol` must equal [`PROTOCOL_VERSION`]; a mismatch fails the spawn. + /// `python` is the interpreter version string. + Ready { protocol: u32, python: String }, + + /// Captured standard output. + /// + /// `id` names the cell whose Python execution context performed the write. + /// It is `None` for raw fd writes, user threads, subprocesses, or output + /// that cannot be attributed to a cell. Text is incremental; a frame is not + /// a complete line. + Stdout { id: Option, text: String }, + + /// Captured standard error; same attribution rules as [`KernelEvent::Stdout`]. + Stderr { id: Option, text: String }, + + /// `repr` of the cell's trailing expression, when it is not `None`. + /// + /// The same value is also bound to `_` in the namespace. + Result { id: String, text: String }, + + /// One display payload from `rlm.repl.emit`: a map of MIME type to JSON. + /// + /// `id` rides the spawning cell's task context, so a background task keeps + /// its creator's id after the cell finishes. User threads emit `None`. + Display { id: Option, data: Value }, + + /// A request from kernel code to the host (bash, web search, MCP, ...). + /// + /// `id` is the request id the host must echo in + /// [`KernelRequest::HostReply`]. `cell_id` is present only while a live + /// cell owns the request; a late detached request cannot reuse a later + /// turn's capability. The host denies idle requests except the narrow + /// `bash.completed` and `bash.consumed` routes. HostRequest { id: String, #[serde(default)] cell_id: Option, data: Value, }, + + /// An exception raised by a cell or by the protocol itself. + /// + /// `id` is `None` for protocol-level errors. An error does not end the + /// request by itself; the matching `done` follows. Error { id: Option, ename: String, @@ -82,6 +178,14 @@ pub enum KernelEvent { #[serde(default)] traceback: Vec, }, + + /// Final event for one id'd request; always the last event with that `id`. + /// + /// `status` is `"ok"` or `"error"`. `names` carries the result of + /// `list_names`. `reason` explains a failed snapshot/restore or a missing + /// snapshot. `extra` preserves operation-specific fields (`saved`, + /// `skipped`, `pruned`, `bytes`, `restored`, `failed`) that are flattened + /// into the top level. Done { id: String, status: String, @@ -94,26 +198,62 @@ pub enum KernelEvent { }, } +/// Parsed form of [`KernelEvent::Ready`], returned by the spawn handshake. +/// +/// [`KernelSession::spawn`](crate::KernelSession::spawn) waits for this event +/// before returning, so every live session has one. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadyEvent { + /// Protocol version the kernel speaks. Must equal [`PROTOCOL_VERSION`]. pub protocol: u32, + /// Python interpreter version, for example `"3.13.11"`. pub python: String, } +/// Errors from spawning a kernel or talking to it over the protocol. +/// +/// I/O and JSON failures are converted transparently from the underlying +/// errors; the other variants describe protocol-level failure modes. #[derive(Debug, Error)] pub enum ReplError { + /// The first event was not `ready`, or the kernel closed stdout during the + /// handshake. The string keeps the unexpected event or the startup stderr + /// tail used to diagnose it. #[error("protocol handshake failed: {0}")] Handshake(String), + + /// The kernel announced a protocol version this host does not speak. + /// + /// The host expects [`PROTOCOL_VERSION`]. #[error("protocol version mismatch: got {got}, expected {PROTOCOL_VERSION}")] - ProtocolMismatch { got: u32 }, + ProtocolMismatch { + /// Version announced by the kernel's `ready` event. + got: u32, + }, + + /// Reading from or writing to the kernel's stdin/stdout failed. #[error("kernel I/O error: {0}")] Io(#[from] std::io::Error), + + /// A request could not be serialized, or an event could not be parsed. #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + + /// The kernel closed its stdout while a request was still in flight. #[error("kernel closed unexpectedly")] Closed, + + /// The kernel reported a failed cell with this exception. #[error("execute failed: {ename}: {evalue}")] - CellFailed { ename: String, evalue: String }, + CellFailed { + /// Python exception type, for example `"ValueError"`. + ename: String, + /// Exception message. + evalue: String, + }, + + /// Any other protocol-level failure, such as an empty line or a missing + /// pipe. #[error("{0}")] Other(String), } @@ -123,7 +263,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-RLM-001 /// Verifies: execute request serializes to REPL protocol v3 shape. #[test] fn execute_request_wire_shape() { @@ -138,7 +277,6 @@ mod tests { assert_eq!(v["code"], "x = 1"); } - /// Trace: L2-DES-RLM-001 /// Verifies: ready event deserializes and exposes protocol version. #[test] fn ready_event_parses() { diff --git a/crates/kernel/src/session.rs b/crates/kernel/src/session.rs index 4bd2a685..f7256c8a 100644 --- a/crates/kernel/src/session.rs +++ b/crates/kernel/src/session.rs @@ -2,6 +2,8 @@ #[cfg(test)] mod inflight_wait_tests; +#[cfg(test)] +mod lifetime_tests; mod session_event_pump; use std::path::{Path, PathBuf}; @@ -19,15 +21,66 @@ use uuid::Uuid; use crate::protocol::{KernelEvent, KernelRequest, PROTOCOL_VERSION, ReadyEvent, ReplError}; -/// Async host callback for kernel `host_request` events. +/// Async callback that answers a `host_request` sent by the Python kernel. +/// +/// `host_request` is the kernel-to-host direction of the protocol. The kernel +/// is an ordinary Python process; approval policy, credentials, provider +/// access, host-owned filesystem writes, and subagent session lifecycle and +/// message routing stay in Rust. Kernel helpers such as `rlm.spawn`, +/// `rlm.list_subagents`, `rlm.delete_subagent`, and `agent_message.send` are +/// thin wrappers around this RPC; the host creates and owns the child +/// sessions. When Python code needs one of those capabilities it sends a +/// `host_request` and awaits the matching `host_reply`, instead of holding +/// privileged state or reimplementing host policy inside the kernel. +/// +/// Common actions are `bash` (run through the approval pipeline), +/// `web.search` / `web.fetch`, `mcp.refresh` / `mcp.list_tools`, `fs.write`, +/// `plan.update`, `question`, and the `rlm.*` subagent calls. +/// +/// Flow: +/// 1. Python calls `rlm.repl.host_request(data)`. +/// 2. The kernel writes `{"event":"host_request","id":"h1","cell_id":"c1",...}`. +/// 3. The session pump routes it here. +/// 4. This handler returns a JSON value. +/// 5. The session writes `{"type":"host_reply","id":"h1","data":...}` to stdin, +/// and the awaiting Python call resumes. /// -/// Arguments are `(request_id, data)`. Implementations must not call -/// [`KernelSession::execute`] on the same session (execute gate is held). -/// Host replies use a separate stdin lock so they never wait on the execute -/// FIFO (deadlock rule: one request in flight, replies strictly ordered). +/// Arguments are `(request_id, data)`; `data` is the raw request object whose +/// `type` (or `action`) field names the operation. The handler is captured for +/// the live execute request and matched by the REPL `cell_id`, so a late +/// request from a finished cell cannot reuse a later turn's capabilities; idle +/// requests are denied except the narrow `bash.completed` / `bash.consumed` +/// route. Implementations must not call [`KernelSession::execute`] on the same +/// session (the execute gate is held). Replies use a separate stdin lock so +/// they never wait behind the execute FIFO that is itself waiting for this +/// reply (deadlock rule). pub type HostRequestHandler = Arc BoxFuture<'static, Value> + Send + Sync>; /// Default deny handler when no host table is installed. +/// +/// It is also the fallback for idle `host_request`s that are not the narrow +/// `bash.completed` / `bash.consumed` route, and for a late request whose +/// `cell_id` no longer matches the active execute request. The handler never +/// performs the requested action; it returns an error envelope naming the +/// action, which the session writes back as the matching `host_reply`. +/// +/// Request data: +/// +/// ```json +/// {"type":"bash","params":{"command":"ls"}} +/// ``` +/// +/// Reply data (the handler's return value): +/// +/// ```json +/// {"status":"error","error":"host_request action not implemented: bash"} +/// ``` +/// +/// Wire frame produced by [`KernelSession`]: +/// +/// ```json +/// {"type":"host_reply","id":"h1","data":{"status":"error","error":"host_request action not implemented: bash"}} +/// ``` pub fn deny_host_handler() -> HostRequestHandler { Arc::new(|_id, data| { Box::pin(async move { @@ -45,6 +98,23 @@ pub fn deny_host_handler() -> HostRequestHandler { } /// Extract `(action, params)` from a REPL `host_request` data object. +/// +/// `action` prefers the `action` key and falls back to `type`; a request with +/// neither yields an empty string. When `params` is present it is returned as +/// is; otherwise the remaining object (without `action` / `type`) becomes the +/// params. +/// +/// ```json +/// {"type":"bash","params":{"command":"ls"}} +/// ``` +/// +/// yields `("bash", {"command":"ls"})`, while +/// +/// ```json +/// {"action":"compact.status","x":1} +/// ``` +/// +/// yields `("compact.status", {"x":1})`. pub fn parse_host_request_data(data: &Value) -> (String, Value) { let action = data .get("action") @@ -71,31 +141,36 @@ pub enum ExecutionSurface { Rlm, } -/// Host-requested workspace access for mode-aware kernel reuse. The OS fence -/// still enforces this access; this tag does not grant or restrict permissions. +/// Host-requested workspace access for mode-aware kernel reuse. +/// This tag does not grant or restrict permissions in the trusted kernel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkspaceAccess { + /// Plan mode: the host requests read-only workspace access. ReadOnly, + /// Build mode: the host requests read-write workspace access. ReadWrite, } /// Configuration for spawning a kernel session. #[derive(Debug, Clone)] pub struct KernelSessionConfig { + /// Interpreter used to run `python -m rlm.repl`. pub python: PathBuf, + /// Working directory of the kernel process and its default workspace root. pub cwd: PathBuf, /// Directories prepended to `PYTHONPATH` (vendored kernel runtime `src`). pub python_path_entries: Vec, /// Extra environment variables for the kernel process (harness paths, depth, …). pub extra_env: Vec<(String, String)>, - /// OS fence request (design doc §5). `None` = unfenced by design; a fence - /// that cannot be raised downgrades explicitly, never silently (§5.3). - pub fence: Option, /// Records the access requested by the host for subsequent kernel reuse. pub workspace_access: WorkspaceAccess, } impl Default for KernelSessionConfig { + /// Use the packaged runtime when present, otherwise a PATH-resolved + /// interpreter. The working directory is the process cwd. Packaged + /// sessions get the bundled site dir on `PYTHONPATH` and + /// `PYTHONNOUSERSITE=1`. fn default() -> Self { let bundle = devo_util_paths::runtime::RuntimeBundle::current(); Self { @@ -114,21 +189,27 @@ impl Default for KernelSessionConfig { extra_env: bundle.map_or_else(Vec::new, |_| { vec![("PYTHONNOUSERSITE".to_string(), "1".to_string())] }), - fence: None, workspace_access: WorkspaceAccess::ReadWrite, } } } +/// Why [`KernelSession::spawn`] or [`KernelSession::spawn_command`] failed. #[derive(Debug, thiserror::Error)] pub enum SpawnError { + /// Protocol setup or the handshake failed after the child started. #[error(transparent)] Repl(#[from] ReplError), + /// The custom command passed to [`KernelSession::spawn_command`] failed to + /// start. #[error("failed to spawn kernel: {0}")] Spawn(#[source] std::io::Error), + /// The configured Python interpreter could not be executed. #[error("failed to spawn kernel interpreter `{python}`: {source}")] SpawnInterpreter { + /// Interpreter path from [`KernelSessionConfig::python`]. python: PathBuf, + /// OS error from the failed spawn. #[source] source: std::io::Error, }, @@ -146,9 +227,9 @@ pub struct CellOutput { pub traceback: Vec, /// Populated by `list_names` (and any `done` that carries `names`). pub names: Option>, - /// Diffs from `application/vnd.devo.diff+json` display events. + /// Diffs from `application/vnd.diff+json` display events. pub diffs: Vec, - /// Media attachments from `application/vnd.devo.attachment+json` + /// Media attachments from `application/vnd.attachment+json` /// (e.g. attach-image skill). pub attachments: Vec, } @@ -172,19 +253,24 @@ pub struct CellAttachment { } /// MIME used by the edit skill when displaying a file diff from Python. -pub const DIFF_DISPLAY_MIME: &str = "application/vnd.devo.diff+json"; +pub const DIFF_DISPLAY_MIME: &str = "application/vnd.diff+json"; /// MIME used by the attach-image skill when loading an image into context. -pub const ATTACHMENT_DISPLAY_MIME: &str = "application/vnd.devo.attachment+json"; +pub const ATTACHMENT_DISPLAY_MIME: &str = "application/vnd.attachment+json"; /// Hard ceiling on a single attachment's base64 payload (defensive; skill caps lower). pub const MAX_ATTACHMENT_DATA_CHARS: usize = 10_000_000; /// Parse a diff display payload from a kernel `display` event's `data`. +/// +/// Accepts the diff MIME payload with snake_case or camelCase field names. +/// `start_line` is optional; unrelated MIME payloads return `None`. +/// +/// ```json +/// {"application/vnd.diff+json":{"path":"src/a.py","old_str":"x = 1\n","new_str":"x = 2\n","start_line":3}} +/// ``` pub fn parse_diff_display(data: &Value) -> Option { - let payload = data - .get(DIFF_DISPLAY_MIME) - .or_else(|| data.get("application/vnd.devo.diff+json"))?; + let payload = data.get(DIFF_DISPLAY_MIME)?; let path = payload.get("path")?.as_str()?.to_string(); let old_str = payload .get("old_str") @@ -211,11 +297,15 @@ pub fn parse_diff_display(data: &Value) -> Option { /// Parse an attachment display payload from a kernel `display` event's `data`. /// -/// Oversized or malformed payloads are dropped (returns `None`). +/// Only `image/*` payloads with non-empty base64 data are accepted; oversized +/// or malformed payloads are dropped (returns `None`). Both `mime_type` and +/// `mimeType` are accepted. +/// +/// ```json +/// {"application/vnd.attachment+json":{"mime_type":"image/png","data":"","path":"a.png"}} +/// ``` pub fn parse_attachment_display(data: &Value) -> Option { - let payload = data - .get(ATTACHMENT_DISPLAY_MIME) - .or_else(|| data.get("application/vnd.devo.attachment+json"))?; + let payload = data.get(ATTACHMENT_DISPLAY_MIME)?; let mime_type = payload .get("mime_type") .or_else(|| payload.get("mimeType"))? @@ -250,8 +340,16 @@ struct EventPumpState { events: Mutex>>, active_request: Arc>>, idle_bash_handler: Arc>>, + lifetime: tokio_util::sync::CancellationToken, } +/// Start the single stdout reader for this session. +/// +/// The pump owns the only reader of the kernel's stdout, forwards every event +/// to the collection channel, and handles `host_request` frames itself (a +/// request must be answered, not collected as cell output). It lives for the +/// lifetime of the child, so it can still deliver `bash.completed` after a +/// cell's `done` through the idle handler. fn start_event_pump( stdout: BufReader, stdin: Arc>, @@ -259,17 +357,20 @@ fn start_event_pump( let (event_tx, event_rx) = mpsc::channel(session_event_pump::EVENT_CHANNEL_CAPACITY); let active_request = Arc::new(Mutex::new(None)); let idle_bash_handler = Arc::new(Mutex::new(None)); + let lifetime = tokio_util::sync::CancellationToken::new(); session_event_pump::spawn( stdout, stdin, Arc::clone(&active_request), Arc::clone(&idle_bash_handler), event_tx, + lifetime.clone(), ); EventPumpState { events: Mutex::new(event_rx), active_request, idle_bash_handler, + lifetime, } } /// Shared collector state for a cell started via [`KernelSession::begin_execute`]. @@ -301,6 +402,7 @@ pub struct InFlightCell { } impl InFlightCell { + /// Protocol id for this cell (`c1`, `s1`, ...), echoed by its events. pub fn cell_id(&self) -> &str { &self.cell_id } @@ -375,6 +477,10 @@ impl InFlightCell { }); } + /// Read the collector's terminal state without waiting. + /// + /// Returns `Ok(Some(output))` after `done`, `Ok(None)` while the collector + /// is still running, and an error if collection failed. async fn try_take_done(&self) -> Result, ReplError> { let state = self.state.lock().await; match &state.terminal { @@ -387,6 +493,7 @@ impl InFlightCell { /// One durable CPython REPL for a session. pub struct KernelSession { + lifetime: tokio_util::sync::CancellationToken, child: Mutex, io: KernelIo, shutdown_gate: Mutex<()>, @@ -399,21 +506,8 @@ pub struct KernelSession { active_request: Arc>>, /// Only `bash.completed` may use this handler after a cell's `done` event. idle_bash_handler: Arc>>, - /// Fence outcome for this kernel (P0): callers read it to emit rollout - /// fence events and UI downgrade warnings (design doc §5.3). - fence_state: crate::fence::FenceState, workspace_access: WorkspaceAccess, workspace_root: PathBuf, - /// Unix credential delivery channel (§9): grants over SCM_RIGHTS. The - /// peer descriptor is inherited by the kernel process; its number rides - /// the `DEVO_GRANT_FD` env var. - #[cfg(unix)] - grant_channel: Option, - /// Per-session credential authority (P2, Windows fenced kernels only): - /// grants deliver ACEs for the SID carried in the kernel's restricted - /// token — the kernel is never restarted (§9). - #[cfg(windows)] - fence_credentials: Option, } /// Environment variable name segments that mark a variable as a credential. @@ -434,6 +528,14 @@ const CREDENTIAL_ENV_SEGMENTS: &[&str] = &[ "AUTH", ]; +/// Whether an inherited environment entry looks like a credential and must be +/// removed from the kernel. +/// +/// Matches credential-shaped names (`*_KEY`, `*_TOKEN`, `*_SECRET`, +/// `*_PASSWORD`, ..., `PGPASSWORD`, `MYSQL_PWD`) and `*_URL` / `*_URI` / +/// `*_DSN` values whose userinfo or query parameters carry credentials. The +/// check deliberately errs toward removal: dropping an ordinary variable is +/// harmless, leaking a secret into model-controlled Python is not. pub(crate) fn is_credential_env_var(name: &str, value: &str) -> bool { let name = name.to_ascii_uppercase(); if matches!(name.as_str(), "PGPASSWORD" | "MYSQL_PWD") @@ -482,6 +584,8 @@ mod credential_env_tests { use super::{KernelSessionConfig, is_credential_env_var, kernel_env_overrides}; use pretty_assertions::assert_eq; + /// Verifies: credential-shaped variable names are detected + /// case-insensitively, including `PGPASSWORD` and `MYSQL_PWD`. #[test] fn matches_credential_shaped_names() { for name in [ @@ -503,6 +607,8 @@ mod credential_env_tests { } } + /// Verifies: URL/URI/DSN values carrying userinfo or credential-named + /// query parameters are detected. #[test] fn matches_credential_bearing_uris() { for (name, value) in [ @@ -520,6 +626,7 @@ mod credential_env_tests { } } + /// Verifies: ordinary names and non-secret URLs/URIs are not filtered. #[test] fn keeps_non_credential_names_and_uris() { for (name, value) in [ @@ -538,6 +645,8 @@ mod credential_env_tests { } } + /// Verifies: credential-shaped `extra_env` entries are dropped before they + /// reach the kernel. #[test] fn extra_env_cannot_reintroduce_credentials() { let config = KernelSessionConfig { @@ -559,25 +668,6 @@ mod credential_env_tests { } } -/// OS fence request for the kernel process (design doc §5.1/§5.2). `None` -/// spawns the kernel unfenced by design; a fence that cannot be raised is an -/// explicit downgrade, never a silent one (§5.3). -#[derive(Debug, Clone)] -pub struct KernelFenceSpec { - /// Read-only roots beyond the `rlm-kernel` base profile (system/interpreter - /// trees) — the fence has **no default read**, so reads outside - /// base ∪ these roots ∪ `writable_roots` are OS-refused and route through - /// the rlm front door (approval pipeline). The Windows legacy sandbox - /// backend refuses restricted-read profiles. - pub readable_roots: Vec, - /// Read-write roots (Build workspace first; Plan omits the workspace). - pub writable_roots: Vec, - /// Paths the kernel must not read (deny roots). - pub deny_read: Vec, - /// Whether the kernel's network must be restricted (default true for RLM). - pub restrict_network: bool, -} - impl KernelSession { /// Spawn `python -m rlm.repl` and complete the protocol handshake. pub async fn spawn(config: KernelSessionConfig) -> Result, SpawnError> { @@ -601,52 +691,6 @@ impl KernelSession { .env("PYTHONIOENCODING", "utf-8") .env("PYTHONUNBUFFERED", "1"); - // OS fence (P0): on Windows with a provisioned sandbox this replaces - // OS fence (design doc §5): Windows wraps with the restricted-token - // launcher; Unix reuses the platform sandbox wrapper (bwrap/Seatbelt) - // that shell sandboxing uses, carrying the fence roots as overlay. - // Every unimplementable fence is an explicit downgrade, never silent. - #[cfg(unix)] - let mut fence_outcome = crate::fence::fence_or_bare(&config, cmd); - #[cfg(windows)] - let fence_outcome = - crate::fence::wrap_or_bare(&config, cmd, devo_util_paths::find_devo_home()); - #[cfg(windows)] - tracing::warn!( - program = ?fence_outcome.command.as_std().get_program(), - state = ?fence_outcome.state, - "FENCE-SPAWN-PROGRAM" - ); - // Unix: duplicate the channel's peer fd (no CLOEXEC) for the child; - // its number rides DEVO_GRANT_FD and the descriptor survives exec. - #[cfg(unix)] - let mut config = config; - #[cfg(unix)] - if let Some(channel) = fence_outcome.grant_channel.as_ref() { - let peer = channel.peer_fd(); - if let Ok(dup) = crate::credential_unix::dup_no_cloexec(peer) { - config - .extra_env - .push(("DEVO_GRANT_FD".to_string(), dup.to_string())); - } - } - #[cfg(any(windows, unix))] - let mut cmd = fence_outcome.command; - #[cfg(any(windows, unix))] - let fence_state = fence_outcome.state; - #[cfg(windows)] - let fence_credentials = fence_outcome.credentials; - // Unix pipe mode: apply the parent-resolved Landlock/seccomp plan in - // the child after fork (same pattern as the shell sandbox path). - #[cfg(unix)] - if let Some(plan) = fence_outcome.child_plan { - unsafe { - cmd.pre_exec(move || { - devo_util_process::sandbox::apply_resolved_in_child(Some(&plan)) - }); - } - } - // Never inherit credentials into the model-controlled kernel, even // when they are hidden in connection URI values. Env names are // case-insensitive on Windows, so match that way on all platforms. @@ -707,8 +751,10 @@ impl KernelSession { events, active_request, idle_bash_handler, + lifetime, } = start_event_pump(stdout, Arc::clone(&stdin)); Ok(Arc::new(Self { + lifetime, child: Mutex::new(child), io: KernelIo { stdin, events }, shutdown_gate: Mutex::new(()), @@ -718,13 +764,8 @@ impl KernelSession { host_handler: Mutex::new(None), active_request, idle_bash_handler, - fence_state, workspace_access: config.workspace_access, workspace_root, - #[cfg(unix)] - grant_channel: fence_outcome.grant_channel.take(), - #[cfg(windows)] - fence_credentials, })) } @@ -762,8 +803,10 @@ impl KernelSession { events, active_request, idle_bash_handler, + lifetime, } = start_event_pump(stdout, Arc::clone(&stdin)); Ok(Arc::new(Self { + lifetime, child: Mutex::new(child), io: KernelIo { stdin, events }, shutdown_gate: Mutex::new(()), @@ -773,28 +816,26 @@ impl KernelSession { host_handler: Mutex::new(host_handler), active_request, idle_bash_handler, - fence_state: crate::fence::FenceState::NotRequested, workspace_access: WorkspaceAccess::ReadWrite, workspace_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - #[cfg(unix)] - grant_channel: None, - #[cfg(windows)] - fence_credentials: None, })) } - pub fn ready(&self) -> &ReadyEvent { - &self.ready + /// Cancellation for work owned by this kernel, independent of any turn. + /// + /// Canceled when shutdown/termination begins, the event stream closes, or + /// this session is dropped. Canceling the returned child token does not + /// cancel the kernel itself. + pub fn lifetime_token(&self) -> tokio_util::sync::CancellationToken { + self.lifetime.child_token() } - /// Fence outcome recorded at spawn (P0): `Fenced`, `DowngradedUnfenced` - /// (loud downgrade — callers must surface it), or `NotRequested`. - pub fn fence_state(&self) -> crate::fence::FenceState { - self.fence_state + /// Handshake details recorded at spawn: protocol version and Python version. + pub fn ready(&self) -> &ReadyEvent { + &self.ready } - /// Access mode requested at spawn. A mode change must spawn a new kernel: - /// OS process restrictions cannot be changed after the process starts. + /// Access mode requested by the host at spawn for reuse decisions. pub fn workspace_access(&self) -> WorkspaceAccess { self.workspace_access } @@ -804,22 +845,6 @@ impl KernelSession { &self.workspace_root } - /// Per-session credential authority for a fenced kernel (P2, Windows): - /// callers wire approval callbacks to `grant_write_root`/`revoke_*` so - /// permission changes are credential deliveries, never restarts (§9). - #[cfg(windows)] - pub fn fence_credentials(&self) -> Option<&devo_windows_sandbox::SessionCredentialAuthority> { - self.fence_credentials.as_ref() - } - - /// Unix credential delivery channel (§9): approvals call `grant`/`revoke` - /// on it; the kernel facade turns received descriptors into native - /// dirfd-backed rlm.read/rlm.write. `None` for unfenced kernels. - #[cfg(unix)] - pub fn grant_channel(&self) -> Option<&crate::credential_unix::GrantChannel> { - self.grant_channel.as_ref() - } - /// Install or clear the host_request callback (typically once per turn). pub async fn set_host_handler(&self, handler: Option) { *self.host_handler.lock().await = handler; @@ -831,6 +856,9 @@ impl KernelSession { *self.idle_bash_handler.lock().await = handler; } + /// Bind the live request id to the handler allowed to answer its + /// `host_request`s. The pump only uses this handler while the request's + /// `cell_id` matches; later or detached requests fall back to idle/deny. async fn set_active_request(&self, id: String, host_handler: Option) { *self.active_request.lock().await = Some(session_event_pump::ActiveRequestContext { id, host_handler }); @@ -971,6 +999,7 @@ impl KernelSession { /// teardown forever. If the REPL does not exit in time, the kernel child is /// terminated; normal shutdown lets Python kill its owned Bash process groups. pub async fn shutdown(&self) -> Result<(), ReplError> { + self.lifetime.cancel(); let _shutdown_guard = self.shutdown_gate.lock().await; { let mut child = self.child.lock().await; @@ -1033,6 +1062,7 @@ impl KernelSession { /// Immediately stop and reap the kernel child when its session is permanently deleted. /// This discards the REPL namespace and does not wait for running Python code. pub async fn terminate(&self) { + self.lifetime.cancel(); let mut child = self.child.lock().await; if child.try_wait().ok().flatten().is_none() { let _ = child.start_kill(); @@ -1041,6 +1071,17 @@ impl KernelSession { } } +impl Drop for KernelSession { + fn drop(&mut self) { + self.lifetime.cancel(); + } +} + +/// Serialize one request as a single JSON line and flush it to kernel stdin. +/// +/// The caller holds the stdin lock. Host replies take the same lock without +/// holding the execute gate, so they are never blocked behind the cell that is +/// waiting for them. async fn write_request(stdin: &mut ChildStdin, req: &KernelRequest) -> Result<(), ReplError> { let mut line = serde_json::to_string(req)?; line.push('\n'); @@ -1049,6 +1090,10 @@ async fn write_request(stdin: &mut ChildStdin, req: &KernelRequest) -> Result<() Ok(()) } +/// Read one newline-delimited JSON event from the kernel's stdout. +/// +/// EOF becomes [`ReplError::Closed`] and an empty line is a protocol error; +/// malformed JSON surfaces as [`ReplError::Json`]. async fn read_line_event(stdout: &mut BufReader) -> Result { let mut buf = String::new(); let n = stdout.read_line(&mut buf).await?; @@ -1093,6 +1138,11 @@ pub(crate) fn kernel_env_overrides(config: &KernelSessionConfig) -> Vec<(String, overrides } +/// Read events until the `ready` handshake and return the parsed handshake. +/// +/// Startup diagnostics may arrive as `stderr` events first; they are kept and +/// attached to [`ReplError::Handshake`] if the handshake fails. Any other event +/// before `ready` is a handshake error. async fn read_ready(stdout: &mut BufReader) -> Result { let mut startup_stderr = String::new(); loop { @@ -1120,6 +1170,10 @@ async fn read_ready(stdout: &mut BufReader) -> Result Result { let state = Arc::new(Mutex::new(InflightCollectorState { output: CellOutput::default(), @@ -1129,6 +1183,11 @@ async fn collect_until_done(session: &KernelSession, id: &str) -> Result Option { let runtime = std::env::var_os("DEVO_RLM_RUNTIME_SRC") .map(PathBuf::from) @@ -1261,12 +1322,10 @@ mod tests { cwd: std::env::temp_dir(), python_path_entries: vec![runtime], extra_env: Vec::new(), - fence: None, workspace_access: WorkspaceAccess::ReadWrite, }) } - /// Trace: L2-DES-RLM-001 /// Verifies: handshake accepts protocol 3 from a live CPython REPL. #[tokio::test] async fn live_handshake_protocol_v3() { @@ -1286,6 +1345,8 @@ mod tests { drop(session); } + /// Verifies: `terminate` stops and reaps a kernel blocked in a running + /// cell without waiting for that cell to finish. #[tokio::test] async fn terminating_a_deleted_kernel_stops_a_running_python_cell() { let Some(mut config) = live_config() else { @@ -1325,6 +1386,8 @@ mod tests { ); } + /// Verifies: credential-shaped extras and inherited variables never reach + /// the kernel environment, while ordinary URLs still do. #[tokio::test] async fn live_kernel_does_not_receive_explicit_credentials() { let Some(mut config) = live_config() else { @@ -1363,6 +1426,8 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' ); } + /// Verifies: graceful shutdown lets Python kill its background Bash + /// process group instead of leaving an orphan. #[cfg(unix)] #[tokio::test] async fn graceful_shutdown_kills_background_bash_process_group() { @@ -1378,7 +1443,9 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' } }; let output = session - .execute("from rlm.bash import bash\nhandle = bash('sleep 60')\nprint(handle.pid)") + .execute( + "from rlm.local_bash import bash\nhandle = bash('sleep 60')\nprint(handle.pid)", + ) .await .expect("start background Bash process"); let pid = output @@ -1399,6 +1466,8 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' .expect("kernel shutdown should kill the Bash process group"); } + /// Probe whether a process group still exists (`kill(-pid, 0)`); EPERM + /// counts as alive because the group exists under another identity. #[cfg(unix)] fn unix_process_group_exists(pid: i32) -> bool { // bash.py starts each handle in a fresh session, so the process PID is @@ -1409,140 +1478,6 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' result == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) } - /// Trace: design doc rlm-permissions.md §5-§9 (Windows provisioned path) - /// Verifies: with the sandbox provisioned, the kernel spawns inside the - /// restricted-token fence; writes inside the workspace succeed, writes - /// outside are refused by the OS, and state survives hitting the wall. - #[cfg(windows)] - #[tokio::test] - async fn live_fenced_kernel_enforces_os_boundary_windows() { - // The fence wrapper re-execs `current_exe()` with the sandbox sentinel - // argv; only the real `devo.exe` early-dispatch understands it, so the - // in-cargo-test binary cannot self-host the wrapper. Verify via the - // product binary (TUI e2e) instead. - let exe = std::env::current_exe().unwrap_or_default(); - let exe_name = exe - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - // The wrapper re-execs current_exe() with the sandbox sentinel argv; - // only the product binary's early dispatch understands it. The cargo - // test harness (`devo_kernel-.exe`) cannot self-host it — this - // path is verified on the product binary (TUI e2e: fenced spawn, - // OS-refused out-of-fence writes, PID-stable credential delivery). - if exe_name != "devo.exe" && exe_name != "devo" { - eprintln!("skip: fence wrapper needs the devo.exe host binary, not {exe_name}"); - return; - } - let Some(mut config) = live_config() else { - eprintln!("skip: kernel runtime not found"); - return; - }; - let workspace = tempfile::tempdir().expect("workspace"); - config.cwd = workspace.path().to_path_buf(); - config.fence = Some(KernelFenceSpec { - readable_roots: Vec::new(), // empty = full-disk read (workspace semantic) - writable_roots: vec![workspace.path().to_path_buf()], - deny_read: Vec::new(), - restrict_network: true, - }); - let session = match KernelSession::spawn(config).await { - Ok(s) => s, - Err(e) => { - panic!("fenced kernel spawn failed (provisioned?): {e}"); - } - }; - assert_eq!(session.fence_state(), crate::fence::FenceState::Fenced); - - let inside = workspace.path().join("inside.txt"); - let ok = session - .execute(format!("open({inside:?}, 'w').write('ok')")) - .await - .expect("inside write"); - assert_eq!(ok.status, "ok", "stderr={}", ok.stderr); - let marker = session.execute("fenced_state = 42").await.expect("state"); - assert_eq!(marker.status, "ok", "stderr={}", marker.stderr); - - // Outside the fence: the restricted token refuses the write. - let outside = session - .execute("open(r'C:/Windows/devo-fence-probe-x7', 'w')") - .await - .expect("outside write cell"); - assert_ne!( - outside.status, "ok", - "write outside the fence must fail; stdout={:?} stderr={:?}", - outside.stdout, outside.stderr - ); - - let state = session - .execute("print(fenced_state)") - .await - .expect("state after wall"); - assert_eq!(state.status, "ok", "stderr={}", state.stderr); - assert!(state.stdout.contains("42"), "stdout={:?}", state.stdout); - } - - /// Trace: L2-DES-RLM-001 / design doc rlm-permissions.md §5-§9 - /// Verifies: on Unix the fenced kernel enforces the OS boundary — writes - /// inside the granted workspace succeed, writes outside are refused by - /// the OS (not by a filter), and the kernel keeps its state afterwards. - #[cfg(unix)] - #[tokio::test] - async fn live_fenced_kernel_enforces_os_boundary() { - let Some(mut config) = live_config() else { - eprintln!("skip: kernel runtime not found"); - return; - }; - let workspace = tempfile::tempdir().expect("workspace"); - config.cwd = workspace.path().to_path_buf(); - config.fence = Some(KernelFenceSpec { - // No extra read roots: reads resolve to the `rlm-kernel` base - // (system/interpreter trees) + `writable_roots` only. - readable_roots: Vec::new(), - writable_roots: vec![workspace.path().to_path_buf()], - deny_read: Vec::new(), - restrict_network: true, - }); - let session = match KernelSession::spawn(config).await { - Ok(s) => s, - Err(e) => { - panic!("fenced kernel spawn failed (bwrap available?): {e}"); - } - }; - assert_eq!(session.fence_state(), crate::fence::FenceState::Fenced); - - // Inside the fence: write + read back works, and state persists. - let inside = workspace.path().join("inside.txt"); - let ok = session - .execute(format!("open({inside:?}, 'w').write('ok')")) - .await - .expect("inside write"); - assert_eq!(ok.status, "ok", "stderr={}", ok.stderr); - let marker = session.execute("fenced_state = 42").await.expect("state"); - assert_eq!(marker.status, "ok", "stderr={}", marker.stderr); - - // Outside the fence: the OS itself refuses the write. - let outside = session - .execute("open('/usr/local/devo-fence-probe-x7', 'w')") - .await - .expect("outside write cell"); - assert_ne!( - outside.status, "ok", - "write outside the fence must fail; stdout={:?} stderr={:?}", - outside.stdout, outside.stderr - ); - - // After hitting the wall the kernel is the same living process with - // its state intact (never restarted, §9). - let state = session - .execute("print(fenced_state)") - .await - .expect("state after wall"); - assert_eq!(state.status, "ok", "stderr={}", state.stderr); - assert!(state.stdout.contains("42"), "stdout={:?}", state.stdout); - } - - /// Trace: L2-DES-RLM-001 /// Verifies: namespace survives across two execute calls (cross-turn). #[tokio::test] async fn live_namespace_survives_two_executes() { @@ -1569,7 +1504,6 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' ); } - /// Trace: L2-DES-RLM-001 /// Verifies: interrupt request is accepted without tearing down the session. #[tokio::test] async fn live_interrupt_keeps_session_alive() { @@ -1597,7 +1531,6 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' ); } - /// Trace: L2-DES-RLM-001 /// Verifies: Windows spawn uses `python` on PATH (RestrictedToken wrap is later). #[cfg(windows)] #[tokio::test] @@ -1622,7 +1555,6 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' assert_eq!(session.ready().protocol, PROTOCOL_VERSION); } - /// Trace: L2-DES-RLM-001 /// Verifies: Unix spawn handshake succeeds when runtime is present. #[cfg(unix)] #[tokio::test] @@ -1641,7 +1573,6 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' assert_eq!(session.ready().protocol, PROTOCOL_VERSION); } - /// Trace: L2-DES-RLM-001 /// Verifies: snapshot then restore round-trips a binding when runtime is present. #[tokio::test] async fn live_snapshot_restore_roundtrip() { @@ -1688,7 +1619,6 @@ print([os.getenv('PGPASSWORD'), os.getenv('DEVO_TEST_DATABASE_URL'), os.getenv(' ); } - /// Trace: L2-DES-RLM-001 /// Verifies: host_request during execute gets a host_reply (fake REPL). #[tokio::test] async fn host_request_receives_reply_via_fake_repl() { @@ -1758,7 +1688,6 @@ for line in sys.stdin: ); } - /// Trace: L2-DES-RLM-001 /// Verifies: parse_host_request_data reads action + params. #[test] fn parse_host_request_action_and_params() { @@ -1770,6 +1699,7 @@ for line in sys.stdin: assert_eq!(params, serde_json::json!({"x": 1})); } + /// Verifies: the diff MIME payload is parsed into all fields. #[test] fn parse_diff_display_reads_mime_payload() { let data = serde_json::json!({ @@ -1787,12 +1717,14 @@ for line in sys.stdin: assert_eq!(diff.start_line, Some(3)); } + /// Verifies: unrelated and bash-activity display data produce no diff or + /// attachment. #[test] fn parse_diff_display_ignores_unrelated_display() { let data = serde_json::json!({ "text/plain": "hello" }); assert!(parse_diff_display(&data).is_none()); let bash_activity = serde_json::json!({ - "application/vnd.devo.bash-activity+json": { + "application/vnd.bash-activity+json": { "active": true, "pid": 123, } @@ -1801,10 +1733,11 @@ for line in sys.stdin: assert!(parse_attachment_display(&bash_activity).is_none()); } + /// Verifies: the attachment MIME payload is parsed, including its path. #[test] fn parse_attachment_display_reads_mime_payload() { let data = serde_json::json!({ - "application/vnd.devo.attachment+json": { + "application/vnd.attachment+json": { "mime_type": "image/png", "data": "YWJj", "path": "C:/tmp/a.png" @@ -1821,10 +1754,12 @@ for line in sys.stdin: ); } + /// Verifies: non-image MIME types and oversized base64 payloads are + /// dropped. #[test] fn parse_attachment_display_rejects_non_image_and_oversized() { let non_image = serde_json::json!({ - "application/vnd.devo.attachment+json": { + "application/vnd.attachment+json": { "mime_type": "application/pdf", "data": "YWJj" } @@ -1832,7 +1767,7 @@ for line in sys.stdin: assert!(parse_attachment_display(&non_image).is_none()); let oversized = serde_json::json!({ - "application/vnd.devo.attachment+json": { + "application/vnd.attachment+json": { "mime_type": "image/png", "data": "x".repeat(MAX_ATTACHMENT_DATA_CHARS + 1) } @@ -1840,7 +1775,6 @@ for line in sys.stdin: assert!(parse_attachment_display(&oversized).is_none()); } - /// Trace: L2-DES-RLM-001 /// Verifies: begin_execute can time out without interrupting; wait_until_done finishes later. #[tokio::test] async fn begin_execute_budgeted_wait_times_out_then_completes() { diff --git a/crates/kernel/src/session/lifetime_tests.rs b/crates/kernel/src/session/lifetime_tests.rs new file mode 100644 index 00000000..81e227e2 --- /dev/null +++ b/crates/kernel/src/session/lifetime_tests.rs @@ -0,0 +1,129 @@ +//! Kernel-owned host work must end with the kernel, not the active turn. + +use super::KernelSession; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::time::Duration; +use tokio::process::Command; + +async fn fake_kernel() -> Arc { + let mut command = Command::new("python3"); + command.arg("-u").arg("-c").arg( + r#" +import json, sys +print(json.dumps({"event":"ready","protocol":3,"python":"fake"}), flush=True) +for line in sys.stdin: + req = json.loads(line) + if req.get("code") == "exit": + break + print(json.dumps({"event":"done","id":req["id"],"status":"ok"}), flush=True) + if req.get("type") == "shutdown": + break +"#, + ); + KernelSession::spawn_command(command, None) + .await + .expect("fake kernel") +} + +#[tokio::test] +async fn lifetime_cancels_on_shutdown_and_terminate() { + let kernel = fake_kernel().await; + let token = kernel.lifetime_token(); + let independent = kernel.lifetime_token(); + independent.cancel(); + assert_eq!(token.is_cancelled(), false); + kernel.execute("cell").await.expect("cell"); + assert_eq!(token.is_cancelled(), false); + kernel.shutdown().await.expect("shutdown"); + assert_eq!(token.is_cancelled(), true); + + let kernel = fake_kernel().await; + let token = kernel.lifetime_token(); + kernel.terminate().await; + assert_eq!(token.is_cancelled(), true); +} + +#[tokio::test] +async fn lifetime_cancels_on_eof_and_drop() { + let kernel = fake_kernel().await; + let token = kernel.lifetime_token(); + assert!(kernel.execute("exit").await.is_err()); + tokio::time::timeout(Duration::from_secs(2), token.cancelled()) + .await + .expect("EOF cancellation"); + + let kernel = fake_kernel().await; + let token = kernel.lifetime_token(); + drop(kernel); + assert_eq!(token.is_cancelled(), true); +} + +#[tokio::test] +async fn eof_cancels_lifetime_while_host_handler_is_pending() { + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::sync::Notify; + + struct Dropped(Arc); + impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let started = Arc::new(Notify::new()); + let dropped = Arc::new(AtomicBool::new(false)); + let notified = Arc::clone(&started); + let observed = Arc::clone(&dropped); + let handler: super::HostRequestHandler = Arc::new(move |_, _| { + let notified = Arc::clone(¬ified); + let observed = Arc::clone(&observed); + Box::pin(async move { + let _drop = Dropped(observed); + notified.notify_one(); + std::future::pending().await + }) + }); + let mut command = Command::new("python3"); + command.arg("-u").arg("-c").arg( + r#" +import json, sys +print(json.dumps({"event":"ready","protocol":3,"python":"fake"}), flush=True) +req = json.loads(sys.stdin.readline()) +print(json.dumps({"event":"host_request","id":"pending","cell_id":req["id"],"data":{}}), flush=True) +# Exit only after the test has observed the blocked callback. +sys.stdin.readline() +"#, + ); + let kernel = KernelSession::spawn_command(command, Some(handler)) + .await + .expect("fake kernel"); + let token = kernel.lifetime_token(); + let execution_kernel = Arc::clone(&kernel); + let execution = tokio::spawn(async move { execution_kernel.execute("start").await }); + tokio::time::timeout(Duration::from_secs(2), started.notified()) + .await + .expect("handler started"); + { + use tokio::io::AsyncWriteExt; + kernel + .io + .stdin + .lock() + .await + .write_all(b"exit\n") + .await + .expect("release fake"); + } + tokio::time::timeout(Duration::from_secs(2), token.cancelled()) + .await + .expect("EOF cancellation"); + assert!(execution.await.expect("join").is_err()); + tokio::time::timeout(Duration::from_secs(2), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("handler canceled"); +} diff --git a/crates/kernel/src/session/session_event_pump.rs b/crates/kernel/src/session/session_event_pump.rs index bbe51682..c980fbff 100644 --- a/crates/kernel/src/session/session_event_pump.rs +++ b/crates/kernel/src/session/session_event_pump.rs @@ -1,9 +1,115 @@ //! Single-owner kernel stdout pump. //! -//! The pump remains alive between cells so late Bash completion requests can -//! reach a narrowly scoped idle handler. Cell events are demultiplexed to the -//! current request; host requests are handled here and never use a stale turn -//! callback after that request's `done` event. +//! A [`KernelSession`](super::KernelSession) spawns one Python child and gives +//! exactly one task the job of reading that child's stdout. This module is that +//! task. It is deliberately small, but it owns several invariants that are easy +//! to break elsewhere: +//! +//! - **One reader.** The kernel's stdout is a newline-delimited JSON protocol +//! channel. Two readers would split frames, so every event in the session +//! passes through this loop. +//! - **Demultiplexing.** A single cell can emit many `stdout` frames, `display` +//! frames, one optional `result`, and one terminal `done`. The pump forwards +//! only events that belong to the request currently allowed to own stdout. +//! - **Host RPC.** `host_request` frames are not cell output. They are answered +//! here, on a separate task, while the pump keeps reading stdout. +//! - **Lifecycle.** The pump outlives individual cells. A Bash completion or +//! `bash.poll` can arrive after the cell that started it has already emitted +//! `done`; the pump routes those frames to the session-scoped idle handler. +//! +//! # Two layers of "stdout" +//! +//! The child's OS stdin/stdout are the *transport*. Requests are JSON lines on +//! fd 0; events are JSON lines on stdout (a private dup of the original fd 1). +//! [`KernelEvent::Stdout`] and [`KernelEvent::Stderr`] are *logical streams +//! carried inside that transport*, not a second pipe: +//! +//! ```text +//! Python user code +//! print("hi") ──► tagged sys.stdout ──► {"event":"stdout","id":"c1","text":"hi\n"} +//! os.write(1, b"hi") ──► captured fd 1 pipe ──► {"event":"stdout","id":null,...} +//! │ +//! ▼ +//! protocol fd (dup of fd 1): one JSON object per line +//! │ +//! ▼ +//! Rust pump ──► event channel +//! ``` +//! +//! The runtime replaces `sys.stdout` and `sys.stderr` before serving requests. +//! A Python-level write is tagged with the writing cell's id at write time and +//! sent straight to the protocol. Raw fd writes (`os.write`, C extensions, +//! subprocesses, `sys.stdout.buffer.write`) are redirected into private pipes +//! and pumped as `id: null` events: the runtime cannot prove which cell wrote +//! those bytes, so it does not attribute them. Neither path can corrupt the +//! JSON framing: a raw `print()` that wrote plain text to fd 1 would interleave +//! with JSON event lines, so user output is always wrapped in an event first. +//! +//! This is why the event enum has `stdout` and `stderr` variants even though +//! the transport itself is stdin/stdout: the variants preserve *which logical +//! stream* produced the text, let the host keep separate `CellOutput.stdout` / +//! `CellOutput.stderr` buffers, and let the UI style diagnostics differently. +//! +//! # Event routing +//! +//! The pump forwards a decoded event only while a request owns stdout: +//! +//! | Event | Forwarded when | Notes | +//! |---|---|---| +//! | `stdout`, `stderr`, `display`, `error` | `id` is `None` or equals the active request id | `None` is session-global output from a raw fd or a user thread | +//! | `result` | `id` equals the active request id | Optional; at most one per cell | +//! | `done` | `id` equals the active request id | Clears the active capability, then is forwarded | +//! | `host_request` | never forwarded | Answered out of band | +//! | `ready` | never forwarded | Consumed by the spawn handshake before the pump starts | +//! +//! Frames for another id, or frames that arrive after `done`, are dropped. A +//! late `done` from an old cell must not clear a newer request's capability. +//! +//! # `result` versus `done` +//! +//! `result` is *data*: the `repr` of a cell's trailing expression, equivalent +//! to Jupyter's `Out[]`. It is optional and appears at most once. `x = 1` and +//! `print("hi")` produce no `result`; `1 + 1` produces `{"event":"result", +//! "id":"c1","text":"2"}` and binds `_ = 2`. +//! +//! `done` is *lifecycle*: exactly one per id'd request, always the last event +//! with that id. It carries the final `status` (`"ok"` or `"error"`) and, for +//! snapshot/restore/list_names, operation-specific fields such as `saved`, +//! `restored`, or `names`. The collector stops only on `done`. An `error` +//! event is not terminal on its own; `done` follows it. +//! +//! A normal cell therefore looks like: +//! +//! ```text +//! stdout("hello\n") stdout("world\n") result("42") done(status="ok") +//! ``` +//! +//! The streams are incremental: a frame is not a line. `done` is the fence +//! after the runtime has drained both captured streams. +//! +//! # Host requests and capabilities +//! +//! A `host_request` carries the id of the live cell that owns the request. +//! The pump clones that cell's `HostRequestHandler` only when `cell_id` matches +//! the active request. This is a capability: a detached task that outlives its +//! cell cannot reuse a later turn's handler. The handler runs on its own task +//! so an approval wait never blocks stdout reading; if the future is dropped +//! during teardown, it must release request-local resources. +//! +//! When no cell is active, only the narrow Bash routes (`bash.completed`, +//! `bash.consumed`, `bash.poll`, `bash.kill`, `bash.forget`) may use the idle +//! handler. `bash.start` and every other action are denied: starting a command +//! requires a live cell capability. +//! +//! # Backpressure and teardown +//! +//! The decoded-event channel is bounded by [`EVENT_CHANNEL_CAPACITY`]. If the +//! collector stops draining, the pump stops reading stdout and the kernel +//! eventually blocks on its next protocol write. That is intentional +//! backpressure; memory does not grow without bound. A read error cancels the +//! session lifetime and sends one final `Err` to the collector. The drop guard +//! cancels the same token when this task exits, which drops any in-flight host +//! handler future. use std::sync::Arc; @@ -15,26 +121,81 @@ use crate::protocol::{KernelEvent, KernelRequest}; use super::{HostRequestHandler, deny_host_handler}; +/// Number of decoded events the pump may buffer ahead of the collector. +/// +/// The channel is bounded on purpose. If the collector is slow, the pump stops +/// reading stdout, the kernel's next protocol write blocks on the pipe, and +/// host memory stays bounded. The capacity is large enough to absorb a burst +/// of `print()` frames without adding a round trip per line. pub(super) const EVENT_CHANNEL_CAPACITY: usize = 128; +/// The request currently allowed to own the kernel's stdout. +/// +/// The session installs this value before writing an `execute` request and the +/// pump clears it when the matching `done` arrives. Keeping the id and the +/// handler together is what makes the host callback a *capability*: a request +/// cannot answer with a handler that belongs to a different turn. #[derive(Clone)] pub(super) struct ActiveRequestContext { + /// Request id (`c1`, `s1`, ...) echoed by every event for this request. pub id: String, + /// Host callback captured when the request started. + /// + /// Cloning the `Arc` keeps the handler alive even if the session installs a + /// new handler for a later turn before this request's `done`. `None` is + /// meaningful: the request may own stdout but is not allowed to call back + /// into the host. pub host_handler: Option, } +/// Start the session's only stdout reader. +/// +/// The caller must have completed the `ready` handshake. `stdout` is moved into +/// the task and is never read by anyone else. The task runs until the child +/// closes stdout, a protocol error occurs, or the event receiver is dropped. +/// +/// # Arguments +/// +/// - `stdout`: buffered child stdout; the sole owner of the protocol channel. +/// - `stdin`: shared child stdin. The pump uses it only to write `host_reply` +/// frames, under the same mutex as `execute` / `interrupt` / `shutdown` +/// writes. +/// - `active_request`: the current request capability. The session updates it +/// before writing a request; the pump clears it on the matching `done`. +/// - `idle_bash_handler`: fallback for Bash completion and control while no +/// cell is active. +/// - `event_tx`: decoded events for the session's collector. Bounded; see +/// [`EVENT_CHANNEL_CAPACITY`]. +/// - `lifetime`: cancellation token shared with the session. The drop guard +/// cancels it when this task exits; cancelling it externally drops in-flight +/// host handler futures. +/// +/// # Routing +/// +/// See the module docs. `host_request` frames are answered here and never +/// reach `event_tx`; `done` clears the active request before it is forwarded. +/// +/// # Cancellation +/// +/// The task has no public handle. It ends when stdout closes, when the +/// collector is dropped, or when the process is shutting down. In-flight host +/// handlers observe the shared `lifetime` token and must release request-local +/// resources when their future is dropped. pub(super) fn spawn( mut stdout: BufReader, stdin: Arc>, active_request: Arc>>, idle_bash_handler: Arc>>, event_tx: mpsc::Sender>, + lifetime: tokio_util::sync::CancellationToken, ) { tokio::spawn(async move { + let _lifetime_guard = lifetime.clone().drop_guard(); loop { let event = match super::read_line_event(&mut stdout).await { Ok(event) => event, Err(error) => { + lifetime.cancel(); let _ = event_tx.send(Err(error)).await; break; } @@ -53,7 +214,16 @@ pub(super) fn spawn( .and_then(serde_json::Value::as_str); let handler = if let Some(handler) = active_handler { handler - } else if matches!(action, Some("bash.completed" | "bash.consumed")) { + } else if matches!( + action, + Some( + "bash.completed" + | "bash.consumed" + | "bash.poll" + | "bash.kill" + | "bash.forget" + ) + ) { idle_bash_handler .lock() .await @@ -62,16 +232,35 @@ pub(super) fn spawn( } else { deny_host_handler() }; - let reply_data = handler(id.clone(), data).await; - let reply = KernelRequest::HostReply { - id, - data: reply_data, - }; - let mut stdin = stdin.lock().await; - if let Err(error) = super::write_request(&mut stdin, &reply).await { - let _ = event_tx.send(Err(error)).await; - break; - } + // Capture authorization before spawning: a later Done must not + // attach this request to a different turn. Keep reading stdout + // while the host awaits approval so EOF cancels owned work. + let stdin = Arc::clone(&stdin); + let event_tx = event_tx.clone(); + let lifetime = lifetime.clone(); + tokio::spawn(async move { + let reply = async { + let reply_data = handler(id.clone(), data).await; + let reply = KernelRequest::HostReply { + id, + data: reply_data, + }; + let mut stdin = stdin.lock().await; + super::write_request(&mut stdin, &reply).await + }; + // Host handlers must release request-local resources when + // their future is dropped during kernel teardown. + tokio::select! { + biased; + _ = lifetime.cancelled() => {}, + result = reply => { + if let Err(error) = result { + lifetime.cancel(); + let _ = event_tx.send(Err(error)).await; + } + } + } + }); } KernelEvent::Done { id, @@ -86,9 +275,15 @@ pub(super) fn spawn( .as_ref() .is_some_and(|request| request.id == id) { + // Clear the capability before forwarding. After + // `done`, a late host request must fall back to + // idle/deny even if the collector is slow to + // consume this event. active_request.take(); true } else { + // A stale or foreign `done` must never clear the + // newer request that currently owns stdout. false } }; @@ -122,6 +317,17 @@ pub(super) fn spawn( }); } +/// Whether an ordinary event belongs to `request_id`. +/// +/// This is the forwarding filter for the `other` arm of the pump loop. +/// `stdout`, `stderr`, `display`, and `error` with `id: None` are +/// session-global and accepted while any request is active; events with a +/// concrete id must match exactly. `result` and `done` always carry an id and +/// must match exactly. `ready` and `host_request` never match: `ready` belongs +/// to the handshake and `host_request` is handled before this filter runs. +/// +/// `done` is normally handled by its own arm because it also clears the active +/// request; this function documents the same id rule for completeness. fn event_matches_request(event: &KernelEvent, request_id: &str) -> bool { match event { KernelEvent::Stdout { id, .. } @@ -148,6 +354,9 @@ mod tests { use super::super::{HostRequestHandler, KernelSession}; + /// Verifies: a Bash completion that arrives after its cell's `done` reaches + /// the idle handler, stale cell output is dropped, and a foreign `done` + /// does not clear the next cell's capability. #[tokio::test] async fn idle_host_request_wakes_bash_completion_without_stealing_next_cell() { let script = r#" @@ -174,6 +383,13 @@ for line in sys.stdin: print(json.dumps({"event":"host_request","id":"h-consumed","cell_id":cell_id,"data":{"type":"bash.consumed","params":{"pid":77}}}), flush=True) consumed_reply = json.loads(sys.stdin.readline()) assert consumed_reply["data"]["result"]["accepted"] is True, consumed_reply + for action in ["bash.poll", "bash.kill", "bash.forget"]: + print(json.dumps({"event":"host_request","id":action,"cell_id":cell_id,"data":{"type":action,"handle_id":"opaque-handle"}}), flush=True) + control_reply = json.loads(sys.stdin.readline()) + assert control_reply["data"]["result"]["accepted"] is True, control_reply + print(json.dumps({"event":"host_request","id":"h-start","cell_id":cell_id,"data":{"type":"bash.start","command":"echo denied"}}), flush=True) + start_reply = json.loads(sys.stdin.readline()) + assert start_reply["data"]["status"] == "error", start_reply else: print(json.dumps({"event":"stdout","id":"foreign-id","text":"foreign output\n"}), flush=True) print(json.dumps({"event":"done","id":"foreign-id","status":"error"}), flush=True) @@ -218,7 +434,13 @@ for line in sys.stdin: Box::pin(async move { if matches!( data.get("type").and_then(Value::as_str), - Some("bash.completed" | "bash.consumed") + Some( + "bash.completed" + | "bash.consumed" + | "bash.poll" + | "bash.kill" + | "bash.forget" + ) ) { idle_calls.fetch_add(1, Ordering::SeqCst); idle_notify.notify_one(); @@ -238,19 +460,22 @@ for line in sys.stdin: .await .expect("late Bash completion must wake while idle"); tokio::time::timeout(Duration::from_secs(1), async { - while idle_calls.load(Ordering::SeqCst) < 2 { + while idle_calls.load(Ordering::SeqCst) < 5 { tokio::time::sleep(Duration::from_millis(1)).await; } }) .await .expect("late consumed notice must reach the restricted idle handler"); assert_eq!(active_calls.load(Ordering::SeqCst), 1); - assert_eq!(idle_calls.load(Ordering::SeqCst), 2); + assert_eq!(idle_calls.load(Ordering::SeqCst), 5); let second = session.execute("second").await.expect("second cell"); assert_eq!(second.stdout, "next cell intact\n"); assert_eq!(second.status, "ok"); } + /// Verifies: a live host request uses the cell-scoped handler, while a + /// detached Bash completion after `done` falls back to the idle handler and + /// leaves the next cell readable. #[tokio::test] async fn live_python_host_request_keeps_cell_scope_and_late_bash_uses_idle_handler() { use std::path::PathBuf; @@ -267,7 +492,6 @@ for line in sys.stdin: cwd: std::env::temp_dir(), python_path_entries: vec![runtime_path], extra_env: Vec::new(), - fence: None, workspace_access: crate::WorkspaceAccess::ReadWrite, }; let session = match KernelSession::spawn(config).await { @@ -343,6 +567,9 @@ for line in sys.stdin: assert_eq!(after.status, "ok"); assert!(after.stdout.contains("reader still active")); } + /// Verifies: a real background Bash completion wakes the idle handler after + /// the launching cell has finished, and the stdout reader still serves the + /// next cell. #[cfg(unix)] #[tokio::test] async fn real_background_bash_completion_reaches_idle_handler() { @@ -360,7 +587,6 @@ for line in sys.stdin: cwd: std::env::temp_dir(), python_path_entries: vec![runtime_path], extra_env: Vec::new(), - fence: None, workspace_access: crate::WorkspaceAccess::ReadWrite, }) .await @@ -371,6 +597,17 @@ for line in sys.stdin: return; } }; + session + .set_host_handler(Some(Arc::new(|_id, data| { + Box::pin(async move { + assert_eq!(data["type"], "bash.start"); + json!({"status":"ok","result":{ + "handle_id":"test-host-handle", "running":true, + "exit_code":null, "output":"", "duration":0.0 + }}) + }) + }))) + .await; let idle_calls = Arc::new(AtomicUsize::new(0)); let idle_calls_handler = Arc::clone(&idle_calls); let idle_notify = Arc::new(Notify::new()); @@ -380,20 +617,29 @@ for line in sys.stdin: let idle_calls = Arc::clone(&idle_calls_handler); let idle_notify = Arc::clone(&idle_notify_handler); Box::pin(async move { - assert_eq!( - data.get("type").and_then(Value::as_str), - Some("bash.completed") - ); - idle_calls.fetch_add(1, Ordering::SeqCst); - idle_notify.notify_one(); - json!({"status":"ok","result":{"accepted":true}}) + assert_eq!(data["handle_id"], "test-host-handle"); + match data["type"].as_str() { + Some("bash.poll") => json!({"status":"ok","result":{ + "handle_id":"test-host-handle", "running":false, + "exit_code":0, "output":"host output", "duration":0.1 + }}), + Some("bash.completed") => { + idle_calls.fetch_add(1, Ordering::SeqCst); + idle_notify.notify_one(); + json!({"status":"ok","result":{"accepted":true}}) + } + Some("bash.forget" | "bash.consumed") => { + json!({"status":"ok","result":{"accepted":true}}) + } + other => panic!("unexpected idle Bash action: {other:?}"), + } }) }))) .await; let output = session .execute( - "from rlm.bash import bash\nhandle = bash(\"python -c 'import time; time.sleep(0.1)'\")\nprint(handle.pid)", + "from rlm.bash import bash\nhandle = bash('echo host output')\nprint('scheduled')", ) .await .expect("launch detached Bash command"); diff --git a/crates/protocol/src/acp/session.rs b/crates/protocol/src/acp/session.rs index 46ad6741..cf5894ef 100644 --- a/crates/protocol/src/acp/session.rs +++ b/crates/protocol/src/acp/session.rs @@ -201,7 +201,6 @@ mod tests { use crate::native::usage::SessionUsage; use crate::native::usage::UsageTotals; - /// Trace: L2-DES-APP-008 /// Verifies: ACP session info is projected from canonical Native Session. #[test] fn session_info_uses_acp_field_names_and_preserves_native_session() { diff --git a/crates/protocol/src/command_exec.rs b/crates/protocol/src/command_exec.rs index ca1ca2ac..55e54727 100644 --- a/crates/protocol/src/command_exec.rs +++ b/crates/protocol/src/command_exec.rs @@ -133,7 +133,6 @@ mod tests { assert_eq!(restored, payload); } - /// Trace: L2-DES-APP-003 /// Verifies: missing session_id on command/exec params deserializes to None. #[test] fn command_exec_params_missing_session_defaults_to_none() { @@ -163,7 +162,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-003 /// Verifies: sessionless command/exec output and exit notifications round-trip. #[test] fn command_exec_sessionless_notifications_roundtrip() { diff --git a/crates/protocol/src/native/rpc_admin.rs b/crates/protocol/src/native/rpc_admin.rs index 209ee975..463ece4c 100644 --- a/crates/protocol/src/native/rpc_admin.rs +++ b/crates/protocol/src/native/rpc_admin.rs @@ -734,7 +734,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: model/list dual-writes legacy capability and derived thinking fields. #[test] fn model_info_dual_writes_legacy_and_derived_thinking_fields() { @@ -761,7 +760,6 @@ mod tests { ); } - /// Trace: L2-DES-AUTH-001 /// Verifies: credential/set oauth params are write-only camelCase without echoing secrets in CredentialInfo. #[test] fn credential_set_oauth_params_and_masked_info_wire() { diff --git a/crates/protocol/src/native/rpc_search.rs b/crates/protocol/src/native/rpc_search.rs index 142b1d60..d99521b6 100644 --- a/crates/protocol/src/native/rpc_search.rs +++ b/crates/protocol/src/native/rpc_search.rs @@ -112,7 +112,6 @@ mod tests { use super::*; - /// Trace: L2-DES-CLIENT-002 /// Verifies: canonical search snapshots round-trip with camelCase fields. #[test] fn search_snapshot_roundtrips() { diff --git a/crates/protocol/src/native/session.rs b/crates/protocol/src/native/session.rs index bd8a571d..b583bddf 100644 --- a/crates/protocol/src/native/session.rs +++ b/crates/protocol/src/native/session.rs @@ -85,9 +85,11 @@ pub struct Session { // ── Derived caches (server-maintained, client read-only) ── pub preview: String, pub last_activity_at: DateTime, - /// Current on-disk size of the durable JSONL transcript. This is a - /// list-view cache: servers may omit it from non-list responses or when - /// the session has no readable rollout file. + /// Current on-disk size of the owning physical journal, when known. + /// Root sessions report the shared journal size; a child sharing that + /// journal reports `None` so the tree size is never presented as the + /// child's transcript size. This is a list-view cache: servers may omit + /// it from non-list responses or when no readable rollout exists. #[serde(default, skip_serializing_if = "Option::is_none")] pub transcript_size_bytes: Option, /// Approximate user+assistant item count for list / Agents View trays. diff --git a/crates/protocol/src/native/wire_projector.rs b/crates/protocol/src/native/wire_projector.rs index 4f7c3cd0..784e2686 100644 --- a/crates/protocol/src/native/wire_projector.rs +++ b/crates/protocol/src/native/wire_projector.rs @@ -145,7 +145,6 @@ mod tests { assert_eq!(projected.item, Item::AssistantMessage { text: "hi".into() }); } - /// Trace: L2-DES-APP-009 /// Verifies: emit-site item-delta helper wires Native delta notifications /// carrying `chunk_index` / `base_revision`. #[test] @@ -184,7 +183,6 @@ mod tests { assert_eq!(method, "item/plan/delta"); } - /// Trace: L2-DES-APP-009 /// Verifies: turn lifecycle notifications wire as native turn /// notifications; terminal states flow through `turn/completed`. #[test] @@ -280,7 +278,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-009 /// Verifies: context usage notifications wire as `context/usageUpdated` /// carrying the occupancy unchanged. #[test] @@ -302,7 +299,6 @@ mod tests { assert_eq!(projected, occupancy); } - /// Trace: L2-DES-APP-009 /// Verifies: the mid-turn usage meter projects to native /// `turn/usage/updated` with per-query totals, the last-query meter, and /// the context window (ratified vocabulary). @@ -367,7 +363,6 @@ mod tests { assert_eq!(value["contextWindow"].as_u64(), Some(200_000)); } - /// Trace: L2-DES-APP-009 /// Verifies: provider retry wires as native `model/queryRetrying` with /// provider/model/phase carried through. #[test] @@ -407,7 +402,6 @@ mod tests { assert_eq!(value["error"]["retryable"].as_bool(), Some(true)); } - /// Trace: L2-DES-APP-009 /// Verifies: plan updates project as a full native `Plan` item on /// `item/updated` (replace-by-revision, no plan-delta granularity). #[test] diff --git a/crates/protocol/src/reasoning_effort.rs b/crates/protocol/src/reasoning_effort.rs index 7ce071aa..1f99f880 100644 --- a/crates/protocol/src/reasoning_effort.rs +++ b/crates/protocol/src/reasoning_effort.rs @@ -594,7 +594,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: on/enabled select first non-off available thinking level. #[test] fn normalize_model_thinking_level_selection_maps_on_to_first_non_off() { diff --git a/crates/protocol/src/thinking_level.rs b/crates/protocol/src/thinking_level.rs index b8c155cf..1f34b011 100644 --- a/crates/protocol/src/thinking_level.rs +++ b/crates/protocol/src/thinking_level.rs @@ -278,7 +278,6 @@ mod tests { use crate::ReasoningLevelChoice; use crate::levels_with_leading_off; - /// Trace: L2-DES-MODEL-003 /// Verifies: unsupported reasoning yields only Off. #[test] fn get_supported_thinking_levels_unsupported() { @@ -292,7 +291,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: toggle-derived map exposes off + high (wire on). #[test] fn get_supported_thinking_levels_toggle_derived() { @@ -306,7 +304,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: levels with max include max; none effort maps to UI off. #[test] fn get_supported_thinking_levels_with_max_and_none_to_off() { @@ -336,7 +333,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: Off choice uses off wire value; xhigh requires explicit key. #[test] fn derive_levels_with_off_and_xhigh() { @@ -357,7 +353,6 @@ mod tests { ); } - /// Trace: L2-DES-MODEL-003 /// Verifies: clamp walks toward nearest supported neighbor. #[test] fn clamp_thinking_level_snaps_to_supported() { diff --git a/crates/provider/src/google/mod.rs b/crates/provider/src/google/mod.rs index dad46782..608c25b6 100644 --- a/crates/provider/src/google/mod.rs +++ b/crates/provider/src/google/mod.rs @@ -337,7 +337,6 @@ mod tests { use super::*; - /// Trace: L2-DES-MODEL-002 /// Verifies: Google requests use the v1beta model endpoint and API-key header. #[test] fn google_request_uses_model_url_and_api_key_header() { diff --git a/crates/provider/src/http.rs b/crates/provider/src/http.rs index ed70f1c8..1be77b7b 100644 --- a/crates/provider/src/http.rs +++ b/crates/provider/src/http.rs @@ -457,7 +457,6 @@ mod tests { assert_eq!(build_count.load(Ordering::SeqCst), 1); } - /// Trace: L2-DES-APP-005 /// Verifies: provider custom headers parse from a JSON object string. #[test] fn custom_headers_parse_json_object_string() { @@ -491,7 +490,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-005 /// Verifies: invalid provider custom header value errors do not print the value. #[test] fn custom_header_value_errors_do_not_print_value() { diff --git a/crates/provider/tests/provider_http.rs b/crates/provider/tests/provider_http.rs index 063e33f1..10426876 100644 --- a/crates/provider/tests/provider_http.rs +++ b/crates/provider/tests/provider_http.rs @@ -23,7 +23,6 @@ use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; -/// Trace: L2-DES-APP-005, L2-DES-MODEL-001 /// Verifies: OpenAI Chat Completions requests include custom provider headers and custom values override built-in headers. #[tokio::test] async fn openai_chat_completions_applies_custom_headers_after_builtin_headers() { @@ -56,7 +55,6 @@ async fn openai_chat_completions_applies_custom_headers_after_builtin_headers() assert_eq!(header_value(&request, "x-devo"), Some("yes")); } -/// Trace: L2-DES-APP-005, L2-DES-MODEL-001 /// Verifies: OpenAI Responses requests include custom provider headers and custom values override built-in headers. #[tokio::test] async fn openai_responses_applies_custom_headers_after_builtin_headers() { @@ -89,7 +87,6 @@ async fn openai_responses_applies_custom_headers_after_builtin_headers() { assert_eq!(header_value(&request, "x-devo"), Some("yes")); } -/// Trace: L2-DES-APP-005, L2-DES-MODEL-001 /// Verifies: Anthropic Messages requests include custom provider headers and custom values override built-in headers. #[tokio::test] async fn anthropic_messages_applies_custom_headers_after_builtin_headers() { @@ -160,7 +157,6 @@ async fn anthropic_messages_groups_split_tool_results_before_send() { assert_eq!(body["messages"][2]["role"], "assistant"); } -/// Trace: L2-DES-APP-005, L2-DES-MODEL-001 /// Verifies: provider proxy configuration routes OpenAI Chat Completions requests through the configured proxy. #[tokio::test] async fn provider_proxy_routes_openai_chat_completions_request() { diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs index 782d1fbd..a9bf4356 100644 --- a/crates/sandbox/src/lib.rs +++ b/crates/sandbox/src/lib.rs @@ -39,14 +39,6 @@ mod seatbelt; mod types; mod wrap; -/// Master switch for OS-sandbox enforcement, read at every process-spawn -/// decision point (tool router, server terminals, RLM kernel fence). -/// -/// The OS-sandbox mechanisms are immature and are TEMPORARILY DISABLED: -/// every process runs with full user permissions. Flip to `false` to -/// re-enable enforcement everywhere (see docs/runtime-isolation.md). -pub const SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED: bool = true; - #[cfg(target_os = "linux")] pub use bwrap::bwrap_reexec_for_profile; pub use bwrap::{ diff --git a/crates/sandbox/src/profiles.rs b/crates/sandbox/src/profiles.rs index 6397c436..c0663353 100644 --- a/crates/sandbox/src/profiles.rs +++ b/crates/sandbox/src/profiles.rs @@ -18,9 +18,7 @@ use crate::deny::{ use crate::paths::devo_home; #[cfg(all(feature = "enforce", unix))] use crate::paths::{DEVICE_DIRS, DEVICE_FILES}; -use crate::paths::{ - essential_writable_paths, essential_writable_paths_minimal, temp_writable_paths, -}; +use crate::paths::{essential_writable_paths, essential_writable_paths_minimal}; #[cfg(all(feature = "enforce", unix))] use crate::permissions::SandboxPermissionOverlay; @@ -68,11 +66,6 @@ pub enum ProfileName { Devbox, ReadOnly, Strict, - /// RLM kernel fence (design doc `rlm-permissions.md` §5.1, P4): unlike the - /// shell `workspace` profile it has **no default read** — the kernel reads - /// only the explicit system/interpreter roots, so any read outside them is - /// OS-refused and routes through the rlm front door (approval pipeline). - RlmKernel, Off, Custom(String), } @@ -84,7 +77,6 @@ impl std::fmt::Display for ProfileName { Self::Devbox => write!(f, "devbox"), Self::ReadOnly => write!(f, "read-only"), Self::Strict => write!(f, "strict"), - Self::RlmKernel => write!(f, "rlm-kernel"), Self::Off => write!(f, "off"), Self::Custom(name) => write!(f, "{name}"), } @@ -99,7 +91,6 @@ impl std::str::FromStr for ProfileName { "devbox" => Ok(Self::Devbox), "read-only" | "readonly" => Ok(Self::ReadOnly), "strict" => Ok(Self::Strict), - "rlm-kernel" => Ok(Self::RlmKernel), "off" | "none" => Ok(Self::Off), // Anything else is treated as a custom profile name. // Validation happens when we try to load it from config. @@ -305,13 +296,7 @@ impl ProfileName { tracing::warn!(path = ?path, "Skipping non-UTF8 read_only path"); continue; }; - if profile.name == "rlm-kernel" && path.parent() == Some(Path::new("/proc")) { - // These are exact, safe procfs metadata files, not directory - // subtrees. Do not broaden the rule to their parent /proc. - caps.allow_file_mut(path, AccessMode::Read)?; - } else { - caps = caps.allow_path(path_str, AccessMode::Read)?; - } + caps = caps.allow_path(path_str, AccessMode::Read)?; } // Read-write paths. nono/Landlock need the directory to exist at @@ -347,12 +332,6 @@ impl ProfileName { { continue; } - if profile.name == "rlm-kernel" && *dev == "/dev/fd" && cfg!(target_os = "linux") { - // /dev/fd points to /proc/self/fd. CapabilitySet canonicalizes - // it in the server *before* spawning the kernel, which would - // grant access to the server's open file descriptors instead. - continue; - } if p.is_dir() { // Some systems (e.g. Fedora) expose `/dev/fd` as a directory // symlink to `/proc/self/fd`. Treat it like other device @@ -539,57 +518,6 @@ impl ProfileName { }) } - // RLM kernel fence (design doc §5.1): no default read. The kernel - // reads only what its interpreter needs — system trees carrying - // libc/libpython/stdlib plus the runtime pseudo-filesystems — and - // everything else (workspace, session artifacts, granted roots, - // the Python runtime prefix) arrives via the spawn overlay. Reads - // outside the fence are OS-refused so `rlm.read` escalates to the - // host instead of silently succeeding. - Self::RlmKernel => { - let system_read: Vec = [ - "/usr", "/lib", "/lib64", "/bin", "/sbin", - // Standalone runtime installs (uv/python-build standalones) - "/opt", "/sys", "/dev", - // macOS variants of the Linux trees above - "/System", "/Library", "/private", - ] - .iter() - .map(PathBuf::from) - .filter(|p| p.exists()) - .chain( - // /proc is a shared process view outside bwrap. Giving - // Landlock read permission on its root lets a same-UID - // kernel read /proc//environ (and fd, mem, …). - // Grant only stable system metadata files. In particular, - // never grant /proc/self: nono canonicalizes it in the - // server before fork, turning it into /proc/. - [ - "/proc/cpuinfo", - "/proc/meminfo", - "/proc/version", - "/proc/stat", - "/proc/uptime", - "/proc/loadavg", - "/proc/filesystems", - ] - .into_iter() - .map(PathBuf::from) - .filter(|p| p.is_file()), - ) - .collect(); - Ok(SandboxProfile { - name: "rlm-kernel".to_string(), - read_only: system_read, - // Temp dirs stay writable for scratch work; the workspace - // and session artifact dir ride the spawn overlay. - read_write: temp_writable_paths(), - deny: vec![], - default_read: false, - restrict_network: true, - }) - } - Self::Custom(name) => { let profile_config = config.profiles.get(name).ok_or_else(|| { anyhow::anyhow!( @@ -932,7 +860,6 @@ mod tests { } } - /// Trace: L2-DES-SAFETY-002 /// Verifies: additional permissions expand a profile without disabling its sandbox policy. #[test] #[cfg(all(feature = "enforce", unix))] diff --git a/crates/sandbox/tests/deny_paths_e2e.rs b/crates/sandbox/tests/deny_paths_e2e.rs index 9ab7207e..e02c7007 100644 --- a/crates/sandbox/tests/deny_paths_e2e.rs +++ b/crates/sandbox/tests/deny_paths_e2e.rs @@ -158,6 +158,7 @@ fn bwrap_available() -> bool { #[cfg(target_os = "linux")] fn bwrap_user_namespace_unavailable(stderr: &str) -> bool { [ + "bwrap: No permissions to create a new namespace", "bwrap: unshare user ns: Permission denied", "bwrap: unshare user ns: Operation not permitted", "bwrap: unshare user ns: No space left on device", @@ -168,6 +169,23 @@ fn bwrap_user_namespace_unavailable(stderr: &str) -> bool { .any(|message| stderr.contains(message)) } +#[test] +#[cfg(target_os = "linux")] +fn namespace_unavailability_does_not_hide_other_bwrap_failures() { + for (stderr, expected) in [ + ( + "bwrap: No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces.", + true, + ), + ("bwrap: unshare user ns: Operation not permitted", true), + ("bwrap: setting up uid map: Permission denied", true), + ("bwrap: Can't bind mount /denied: Permission denied", false), + ("No permissions to create a new namespace", false), + ] { + pretty_assertions::assert_eq!(bwrap_user_namespace_unavailable(stderr), expected); + } +} + /// The custom profile under test, read from the env the parent set. fn profile_from_env() -> devo_sandbox::ProfileName { devo_sandbox::ProfileName::Custom(std::env::var(PROFILE_ENV).expect(PROFILE_ENV)) diff --git a/crates/sandbox/tests/headless_tty.rs b/crates/sandbox/tests/headless_tty.rs index f40e7c3a..369760bf 100644 --- a/crates/sandbox/tests/headless_tty.rs +++ b/crates/sandbox/tests/headless_tty.rs @@ -7,7 +7,7 @@ use std::process::Command; use pretty_assertions::assert_eq; #[test] -fn kernel_sandbox_starts_without_controlling_terminal() { +fn shell_sandbox_starts_without_controlling_terminal() { let support = nono::Sandbox::support_info(); if !support.is_supported { eprintln!("skipping: Landlock unavailable: {}", support.details); @@ -19,7 +19,7 @@ fn kernel_sandbox_starts_without_controlling_terminal() { "--ignored", "--exact", "--nocapture", - "headless_kernel_child", + "headless_shell_child", ]) .pre_exec(|| { if libc::setsid() == -1 { @@ -39,26 +39,20 @@ fn kernel_sandbox_starts_without_controlling_terminal() { } #[test] -#[ignore = "invoked in a detached process by kernel_sandbox_starts_without_controlling_terminal"] -fn headless_kernel_child() { +#[ignore = "invoked in a detached process by shell_sandbox_starts_without_controlling_terminal"] +fn headless_shell_child() { assert_eq!( std::fs::File::open("/dev/tty") .expect_err("probe must have no controlling terminal") .raw_os_error(), Some(libc::ENXIO), ); - let plan = devo_sandbox::resolve_enforcement_plan(Some("rlm-kernel"), &std::env::temp_dir()) - .expect("resolve kernel fence") + let plan = devo_sandbox::resolve_enforcement_plan(Some("workspace"), &std::env::temp_dir()) + .expect("resolve shell sandbox") .expect("active fence"); devo_sandbox::apply_resolved_enforcement_in_child(Some(&plan)) .expect("apply fence without a terminal"); assert!(std::fs::read("/proc/meminfo").is_ok()); - assert_eq!( - std::fs::read("/proc/self/environ") - .expect_err("process credentials must stay fenced") - .kind(), - std::io::ErrorKind::PermissionDenied, - ); let scratch = std::env::temp_dir().join(format!("devo-headless-{}", std::process::id())); std::fs::write(&scratch, b"fenced scratch").expect("scratch write remains allowed"); assert_eq!( diff --git a/crates/sandbox/tests/rlm_kernel_proc_e2e.rs b/crates/sandbox/tests/rlm_kernel_proc_e2e.rs deleted file mode 100644 index 6373aff5..00000000 --- a/crates/sandbox/tests/rlm_kernel_proc_e2e.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! The kernel Landlock profile must not expose the spawning server's procfs. -#![cfg(all(target_os = "linux", feature = "enforce"))] - -use std::fs; -use std::os::unix::process::CommandExt; -use std::path::Path; -use std::process::Command; - -const PROBE_ENV: &str = "DEVO_SANDBOX_PROC_PROBE"; - -#[test] -fn kernel_cannot_read_parent_proc_environ() { - let support = nono::Sandbox::support_info(); - if !support.is_supported { - eprintln!("skipping: Landlock unavailable: {}", support.details); - return; - } - - // Isolate the test parent's environment from the test harness so the - // child has a known secret to look for in /proc//environ. - let output = Command::new(std::env::current_exe().unwrap()) - .env(PROBE_ENV, "parent") - .env("DEVO_SANDBOX_PROC_SECRET", "server-credential-marker") - .args(["--ignored", "--exact", "--nocapture", "proc_parent_entry"]) - .output() - .expect("spawn parent process"); - assert!( - output.status.success(), - "proc fence regression: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -#[ignore = "invoked by kernel_cannot_read_parent_proc_environ"] -fn proc_parent_entry() { - if std::env::var(PROBE_ENV).as_deref() != Ok("parent") { - return; - } - let self_environ = fs::read("/proc/self/environ").expect("parent procfs precondition"); - assert!( - self_environ - .windows(b"server-credential-marker".len()) - .any(|w| w == b"server-credential-marker") - ); - - let baseline = Command::new(std::env::current_exe().unwrap()) - .env(PROBE_ENV, "baseline") - .args(["--ignored", "--exact", "--nocapture", "proc_kernel_entry"]) - .output() - .expect("spawn unsandboxed baseline"); - assert!( - baseline.status.success(), - "unsandboxed same-UID child could not read parent environ (invalid test): {}", - String::from_utf8_lossy(&baseline.stderr) - ); - - let workspace = std::env::temp_dir(); - let exe = std::env::current_exe().unwrap(); - // The test binary lives in target/debug/deps, outside the real kernel's - // Python runtime grant. Add its directory for this test invocation only. - let overlay = devo_sandbox::SandboxPermissionOverlay { - read_paths: vec![exe.parent().unwrap().to_path_buf()], - ..Default::default() - }; - let plan = devo_sandbox::resolve_enforcement_plan_with_overlay( - Some("rlm-kernel"), - Path::new(&workspace), - Some(&overlay), - ) - .expect("resolve kernel profile") - .expect("active kernel profile"); - let output = unsafe { - Command::new(std::env::current_exe().unwrap()) - .env(PROBE_ENV, "kernel") - .args(["--ignored", "--exact", "--nocapture", "proc_kernel_entry"]) - .pre_exec(move || { - devo_sandbox::apply_resolved_enforcement_in_child(Some(&plan)) - .map_err(|e| std::io::Error::other(format!("sandbox apply: {e:#}"))) - }) - .output() - } - .expect("apply kernel sandbox and run child"); - assert!( - output.status.success(), - "sandboxed kernel could access parent procfs or lost required proc metadata: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -#[ignore = "invoked by proc_parent_entry"] -fn proc_kernel_entry() { - let mode = std::env::var(PROBE_ENV); - if !matches!(mode.as_deref(), Ok("kernel" | "baseline")) { - return; - } - let parent = unsafe { libc::getppid() }; - let environ = format!("/proc/{parent}/environ"); - if mode.as_deref() == Ok("baseline") { - let exposed = fs::read(&environ).expect("same-UID baseline must read parent environ"); - assert!( - exposed - .windows(b"server-credential-marker".len()) - .any(|w| w == b"server-credential-marker"), - "parent secret absent from unsandboxed baseline" - ); - return; - } - assert!( - fs::read(&environ).is_err(), - "sandboxed kernel read parent's /proc environ" - ); - let fds = format!("/proc/{parent}/fd"); - assert!( - fs::read_dir(&fds).is_err(), - "sandboxed kernel listed parent FDs" - ); - for path in ["/proc/cpuinfo", "/proc/meminfo"] { - if Path::new(path).is_file() { - assert!( - fs::read(path).is_ok(), - "safe system metadata blocked: {path}" - ); - } - } -} diff --git a/crates/sandbox/tests/wrap_e2e.rs b/crates/sandbox/tests/wrap_e2e.rs index 4cb5d92e..ebd40ca3 100644 --- a/crates/sandbox/tests/wrap_e2e.rs +++ b/crates/sandbox/tests/wrap_e2e.rs @@ -34,6 +34,7 @@ fn bwrap_usable() -> bool { #[cfg(target_os = "linux")] fn bwrap_user_namespace_unavailable(stderr: &str) -> bool { [ + "bwrap: No permissions to create a new namespace", "bwrap: unshare user ns: Permission denied", "bwrap: unshare user ns: Operation not permitted", "bwrap: unshare user ns: No space left on device", @@ -45,6 +46,23 @@ fn bwrap_user_namespace_unavailable(stderr: &str) -> bool { .any(|message| stderr.contains(message)) } +#[test] +#[cfg(target_os = "linux")] +fn namespace_unavailability_does_not_hide_other_bwrap_failures() { + for (stderr, expected) in [ + ( + "bwrap: No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces.", + true, + ), + ("bwrap: unshare user ns: Operation not permitted", true), + ("bwrap: setting up uid map: Permission denied", true), + ("bwrap: Can't bind mount /denied: Permission denied", false), + ("No permissions to create a new namespace", false), + ] { + pretty_assertions::assert_eq!(bwrap_user_namespace_unavailable(stderr), expected); + } +} + fn temp_workspace(tag: &str, profile_toml: &str) -> PathBuf { let workspace = std::env::temp_dir().join(format!( "devo-wrap-e2e-{tag}-{}-{}", diff --git a/crates/server/AGENTS.md b/crates/server/AGENTS.md index 00f13b03..8b764776 100644 --- a/crates/server/AGENTS.md +++ b/crates/server/AGENTS.md @@ -44,7 +44,10 @@ The server runtime uses **one session actor per session**. Durable session state - **Rollout JSONL** is the canonical conversation history (agent layout): - Roots/forks: `~/.devo/sessions/.jsonl` - - Subagents: `~/.devo/session-artifacts//sub-xxxxxxxx/.jsonl` (nested `sub-…` dirs for deeper children) + - New descendants share the true root journal `~/.devo/sessions/.jsonl`; they do not create child `.jsonl` files. + - Legacy independent child journals `~/.devo/session-artifacts//sub-xxxxxxxx/.jsonl` remain readable and keep their own existing writes; no migration or automatic deletion. + - New child artifact/kernel/harness directories are deterministic: `~/.devo/session-artifacts//agents//`. + - `session/list` `transcriptSizeBytes` is the whole journal size for a root and `None` for a child sharing the root journal; it never reports the tree size as a child transcript size. - **SQLite** (`devo.db` `sessions` table) stores a lightweight index (`rollout_path`, `parent_session_id`, title, cwd, timestamps) used by `session/list` and resume decisions. - **In-memory session actors** are loaded on demand via `get_or_load_parent_session`; root sessions are LRU-evicted (capacity 16) when unpinned. - **`session/list`** returns durable user-visible sessions only (non-ephemeral, no `agent_path`; includes forks with `parent_session_id`); subagent rows are indexed but hidden from list. diff --git a/crates/server/src/event_reconcile.rs b/crates/server/src/event_reconcile.rs index 70c7b18f..1c83f134 100644 --- a/crates/server/src/event_reconcile.rs +++ b/crates/server/src/event_reconcile.rs @@ -73,6 +73,9 @@ struct FileOutcome { } fn reconcile_file(rollout_path: &Path, db: &Database) -> Result { + let canonical_journal = crate::journal_path::canonical_journal_key(rollout_path) + .with_context(|| format!("resolve canonical journal {}", rollout_path.display()))?; + let rollout_path = canonical_journal.as_path(); let watermark = db.projection_watermark(rollout_path)?; let file = std::fs::File::open(rollout_path) .with_context(|| format!("open rollout file {}", rollout_path.display()))?; @@ -247,14 +250,19 @@ mod tests { let (store, path) = write_session_file(dir.path()); let db = Database::open(dir.path().join("devo.db")).expect("open db"); + let canonical = + crate::journal_path::canonical_journal_key(&path).expect("canonical journal"); // Crash after projecting only the first row (session meta at index 0). - db.set_projection_watermark(&path, 0) + db.set_projection_watermark(&canonical, 0) .expect("set watermark"); let stats = reconcile_event_log(&store, &db).expect("reconcile"); // Lines 1 (turn) and 2 (item) backfill; session/created does not. assert_eq!(stats.rows_inserted, 2); assert_eq!(db.event_log_len().expect("count"), 2); - assert_eq!(db.projection_watermark(&path).expect("watermark"), Some(2)); + assert_eq!( + db.projection_watermark(&canonical).expect("watermark"), + Some(2) + ); } #[test] @@ -275,7 +283,12 @@ mod tests { assert_eq!(stats.files_damaged, 1); // All intact rows before the damage were still backfilled. assert_eq!(stats.rows_inserted, 4); - assert_eq!(db.projection_watermark(&path).expect("watermark"), Some(2)); + let canonical = + crate::journal_path::canonical_journal_key(&path).expect("canonical journal"); + assert_eq!( + db.projection_watermark(&canonical).expect("watermark"), + Some(2) + ); } #[test] diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index 85dabcdb..50525f2e 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -685,12 +685,6 @@ invocation_method = "openai_chat_completions" let _ = std::fs::remove_dir_all(workspace); } - /// Trace: provider connections can change while the server is running - /// (provider/upsert, OAuth login writing auth.json). Sessions capture - /// their router at creation, so a connection added after session start - /// failed every turn with "provider route not configured" until a server - /// restart. `reload_provider_router` must materialize the new connection - /// on an already-live context. #[tokio::test] async fn reload_provider_router_picks_up_new_connection_without_restart() { let deps = test_deps(""); diff --git a/crates/server/src/goal.rs b/crates/server/src/goal.rs index 18398b45..c5442434 100644 --- a/crates/server/src/goal.rs +++ b/crates/server/src/goal.rs @@ -307,7 +307,6 @@ mod tests { #[test] fn token_budget_exhausted_allows_budget_wrap_up_continuation() { - // Trace: L2-DES-GOAL-001 let mut goal = make_active_goal(); goal.budget.token_budget = Some(1000); goal.usage.tokens_used = 1000; @@ -320,7 +319,6 @@ mod tests { #[test] fn continuation_prompt_escapes_untrusted_objective_xml() { - // Trace: L2-DES-GOAL-001 let mut goal = make_active_goal(); goal.objective = "finish & report \"done\"".into(); goal.budget.token_budget = Some(100); @@ -335,7 +333,6 @@ mod tests { #[test] fn continuation_prompt_does_not_fabricate_default_budget() { - // Trace: L2-DES-GOAL-001 let goal = make_active_goal(); let prompt = goal.continuation_prompt().expect("active goal prompt"); diff --git a/crates/server/src/goal_durable.rs b/crates/server/src/goal_durable.rs index 7da0b050..d791d2c9 100644 --- a/crates/server/src/goal_durable.rs +++ b/crates/server/src/goal_durable.rs @@ -328,6 +328,9 @@ impl GoalDurableStore { Err(error) => return Err(anyhow::anyhow!("parse goal rollout: {error}")), }; let line = parsed; + if line.session_id() != Some(session_id) { + continue; + } if let devo_core::RolloutLine::Internal { entry: devo_core::InternalRecord::GoalState { @@ -361,6 +364,8 @@ impl GoalDurableStore { let rollout_path = index .rollout_path .context("session rollout path missing while persisting goal")?; + let _lease = crate::runtime::session_file_lock::SessionFileLock::acquire(&rollout_path) + .map_err(|error| anyhow::anyhow!("acquire goal journal lock: {error}"))?; let goal = goal.map(serde_json::to_value).transpose()?; rollout_store.append_goal_state(&rollout_path, session_id, goal)?; Ok(true) @@ -479,7 +484,6 @@ mod tests { #[tokio::test] async fn goal_durable_store_replays_current_goal_projection() { - // Trace: L2-DES-GOAL-001 let temp = TempDir::new().expect("temp dir"); let store = GoalDurableStore::new(temp.path().to_path_buf()); let session_id = SessionId::new(); @@ -525,7 +529,6 @@ mod tests { #[tokio::test] async fn goal_durable_store_replays_clear_as_no_goal() { - // Trace: L2-DES-GOAL-001 let temp = TempDir::new().expect("temp dir"); let store = GoalDurableStore::new(temp.path().to_path_buf()); let session_id = SessionId::new(); @@ -547,7 +550,6 @@ mod tests { #[tokio::test] async fn goal_durable_store_replays_paused_reason_as_blocker() { - // Trace: L2-DES-GOAL-001 let temp = TempDir::new().expect("temp dir"); let store = GoalDurableStore::new(temp.path().to_path_buf()); let session_id = SessionId::new(); diff --git a/crates/server/src/journal_path.rs b/crates/server/src/journal_path.rs new file mode 100644 index 00000000..03594a23 --- /dev/null +++ b/crates/server/src/journal_path.rs @@ -0,0 +1,33 @@ +//! Canonical physical identity for session journals. +//! +//! Locks, in-process append state, event watermarks, and unlink guards must all +//! agree on one physical file. Canonicalizing the existing file resolves +//! symlink aliases; for a not-yet-created file we canonicalize the parent and +//! join the file name. There is deliberately no lexical fallback: a failure to +//! resolve ownership is a write error, not a second lock identity. + +use std::io; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) fn canonical_journal_key(rollout_path: &Path) -> io::Result { + match std::fs::canonicalize(rollout_path) { + Ok(path) => return Ok(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let parent = rollout_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("journal path has no parent: {}", rollout_path.display()), + ) + })?; + let file_name = rollout_path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("journal path has no file name: {}", rollout_path.display()), + ) + })?; + let parent = std::fs::canonicalize(parent)?; + Ok(parent.join(file_name)) +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 468d0286..67db6201 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -11,6 +11,7 @@ mod execution; pub mod goal; mod goal_durable; mod heartbeat_command; +mod journal_path; mod json_extract; mod oauth_refresh; mod persisted_native_item; diff --git a/crates/server/src/oauth_refresh.rs b/crates/server/src/oauth_refresh.rs index 5ad378f6..dfdbecdc 100644 --- a/crates/server/src/oauth_refresh.rs +++ b/crates/server/src/oauth_refresh.rs @@ -277,7 +277,6 @@ mod tests { } } - /// Trace: L2-DES-AUTH-001 /// Verifies: an OAuth access token near expiry is refreshed when possible. #[test] fn near_expiry_with_refresh_token_refreshes() { @@ -287,7 +286,6 @@ mod tests { ); } - /// Trace: L2-DES-AUTH-001 /// Verifies: an OAuth access token outside the refresh window is reused. #[test] fn token_outside_refresh_window_is_reused() { @@ -297,7 +295,6 @@ mod tests { ); } - /// Trace: L2-DES-AUTH-001 /// Verifies: an expired OAuth token without refresh material is stale. #[test] fn expired_token_without_refresh_is_stale() { diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 84cfbda9..45ba781e 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::BufRead; use std::io::BufReader; +use std::io::Read; use std::io::Write; use std::path::Path; use std::path::PathBuf; @@ -46,7 +47,6 @@ use devo_core::legacy_lines_from_internal; use devo_core::legacy_rollback_line_from_native; use devo_core::legacy_title_line_from_native; use devo_core::parse_rollout_line; -use devo_core::read_canonical_history; use devo_core::rollout::InternalRecord; use devo_core::rollout::SessionPersistenceExtras; use devo_core::rollout::TurnPersistenceExtras; @@ -100,13 +100,9 @@ pub(crate) fn invent_session_persistence_extras() -> SessionPersistenceExtras { pub(crate) struct RolloutStore { /// Root data directory that contains `sessions/` and `session-artifacts/`. data_root: PathBuf, - /// Per-file locks that serialise concurrent writes to the same rollout file, - /// preventing interleaved JSON lines. - file_locks: Arc>>>>, - /// Per-file write-path state (v2 single-write, 05 §2.2). One instance per - /// rollout path, hydrated from the on-disk history on first append so - /// item seqs and approval folds never collide with it. - write_states: Arc>>, + /// Strong per-store cache of the process-global journal engines. Clones + /// share it; distinct stores converge through the global weak registry. + journals: Arc>>>, /// Delivery-log sink (08 §5/§7): after each fsynced append, derived /// events are projected into the SQLite `event_log` (best effort; the /// startup reconciler backfills anything missed). `None` in tests that @@ -114,12 +110,191 @@ pub(crate) struct RolloutStore { event_log: Option>, } -/// Per-file write-path state: seq/approval/settings tracking plus the index -/// of the next JSONL row to be written (used as the `source_fact_id` line -/// index). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JournalVisibility { + /// Only sessions whose latest lifecycle marker is not a tombstone. + Live, + /// Include sessions whose latest marker is a tombstone (delete cleanup). + IncludeDeleted, +} + +/// One process-wide engine per canonical physical journal. The state mutex is +/// both the in-process append serialization and the per-session state owner. +struct JournalShared { + state: StdMutex, +} + +#[derive(Default)] +struct JournalState { + write: Option, + /// Set before a journal is unlinked. Any later append/import to this + /// canonical path fails instead of recreating the deleted root. + closed: bool, +} + +static JOURNAL_REGISTRY: std::sync::OnceLock< + StdMutex>>, +> = std::sync::OnceLock::new(); + +fn journal_registry() -> &'static StdMutex>> { + JOURNAL_REGISTRY.get_or_init(|| StdMutex::new(HashMap::new())) +} + +impl RolloutStore { + /// True when this canonical journal has been durably closed/deleted. + pub(crate) fn journal_is_closed(&self, rollout_path: &Path) -> bool { + let Ok(key) = crate::journal_path::canonical_journal_key(rollout_path) else { + return false; + }; + let shared = { + let local = self + .journals + .lock() + .expect("rollout journal cache poisoned"); + local.get(&key).cloned() + } + .or_else(|| { + journal_registry() + .lock() + .expect("rollout journal registry poisoned") + .get(&key) + .and_then(std::sync::Weak::upgrade) + }); + shared.is_some_and(|shared| { + shared + .state + .lock() + .expect("rollout journal state mutex poisoned") + .closed + }) + } + + fn shared_journal(&self, rollout_path: &Path) -> Result> { + let key = crate::journal_path::canonical_journal_key(rollout_path)?; + { + let local = self + .journals + .lock() + .expect("rollout journal cache poisoned"); + if let Some(shared) = local.get(&key) { + return Ok(Arc::clone(shared)); + } + } + let shared = { + let mut registry = journal_registry() + .lock() + .expect("rollout journal registry poisoned"); + registry.retain(|_, weak| weak.strong_count() > 0); + match registry.get(&key).and_then(std::sync::Weak::upgrade) { + Some(shared) => shared, + None => { + let shared = Arc::new(JournalShared { + state: StdMutex::new(JournalState::default()), + }); + registry.insert(key.clone(), Arc::downgrade(&shared)); + shared + } + } + }; + let mut local = self + .journals + .lock() + .expect("rollout journal cache poisoned"); + Ok(Arc::clone(local.entry(key).or_insert(shared))) + } +} + +/// Per-file write-path state: the physical line index plus one +/// [`RolloutWriteState`] per logical session in the journal. Item `seq`, +/// settings epochs, approval folds, and cwd are session-scoped; the line index +/// and the event-log watermark remain file-scoped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum JournalWriteMode { + /// Explicit creation of a new root/fork journal. + CreateIfMissing, + /// Ordinary append/settings/tombstone: never recreate a missing journal. + ExistingOnly, +} + pub(crate) struct WritePathState { - write_state: RolloutWriteState, + sessions: HashMap, + /// Last merged persistence extras per session. A later `SessionMeta` that + /// passes `None` (cwd/ACP updates) must not erase agent path/nickname/mode. + session_extras: HashMap, next_line_index: u64, + /// Physical identity/length last observed/committed. A mismatch means an + /// external append/truncate/recreate happened; the state rehydrates. + observed: JournalStamp, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct JournalStamp { + len: u64, + modified: Option, + #[cfg(unix)] + dev: u64, + #[cfg(unix)] + ino: u64, + #[cfg(windows)] + creation_time: u64, + #[cfg(windows)] + last_write_time: u64, +} + +impl JournalStamp { + fn missing() -> Self { + Self { + len: 0, + modified: None, + #[cfg(unix)] + dev: 0, + #[cfg(unix)] + ino: 0, + #[cfg(windows)] + creation_time: 0, + #[cfg(windows)] + last_write_time: 0, + } + } +} + +fn journal_stamp(path: &Path) -> JournalStamp { + let Ok(metadata) = std::fs::metadata(path) else { + return JournalStamp::missing(); + }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + JournalStamp { + len: metadata.len(), + modified: metadata.modified().ok(), + dev: metadata.dev(), + ino: metadata.ino(), + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + JournalStamp { + len: metadata.len(), + modified: metadata.modified().ok(), + creation_time: metadata.creation_time(), + last_write_time: metadata.last_write_time(), + } + } + #[cfg(not(any(unix, windows)))] + { + JournalStamp { + len: metadata.len(), + modified: metadata.modified().ok(), + } + } +} + +impl WritePathState { + fn session_state_mut(&mut self, session_id: SessionId) -> &mut RolloutWriteState { + self.sessions.entry(session_id).or_default() + } } impl std::fmt::Debug for RolloutStore { @@ -134,8 +309,7 @@ impl Clone for RolloutStore { fn clone(&self) -> Self { Self { data_root: self.data_root.clone(), - file_locks: Arc::clone(&self.file_locks), - write_states: Arc::clone(&self.write_states), + journals: Arc::clone(&self.journals), event_log: self.event_log.as_ref().map(Arc::clone), } } @@ -146,8 +320,7 @@ impl RolloutStore { pub(crate) fn new(data_root: PathBuf, event_log: Option>) -> Self { Self { data_root, - file_locks: Arc::new(StdMutex::new(HashMap::new())), - write_states: Arc::new(StdMutex::new(HashMap::new())), + journals: Arc::new(StdMutex::new(HashMap::new())), event_log, } } @@ -162,23 +335,6 @@ impl RolloutStore { self.root_rollout_path(session_id) } - /// Allocates a nested subagent rollout under the parent's artifact tree. - /// - /// Roots nest under `session-artifacts//sub-xxxxxxxx/.jsonl`. - /// Children nest further: `…/sub-xxxxxxxx/sub-yyyyyyyy/.jsonl`. - pub(crate) fn allocate_child_rollout_path( - &self, - parent_rollout_path: Option<&Path>, - parent_session_id: &devo_protocol::native::ids::SessionId, - child_session_id: &devo_protocol::native::ids::SessionId, - ) -> Result { - let nest_dir = self.nesting_dir_for_parent(parent_rollout_path, parent_session_id)?; - std::fs::create_dir_all(&nest_dir) - .with_context(|| format!("create session nest dir {}", nest_dir.display()))?; - let child_dir = create_unique_sub_dir(&nest_dir)?; - Ok(child_dir.join(format!("{child_session_id}.jsonl"))) - } - /// Invents rollout path + [`SessionPersistenceExtras`] at origin for a root/fork. /// /// Live create / fork invent Native [`Session`] (and @@ -195,41 +351,62 @@ impl RolloutStore { } } - /// Invents a nested subagent rollout path + extras (session-artifacts tree). - pub(crate) fn invent_child_session_persistence( + /// Appends a session header to an already-established journal. Ordinary + /// metadata/archive updates must never recreate a missing root. + pub(crate) fn append_session_meta_at( &self, - parent_rollout_path: Option<&Path>, - parent_session_id: &devo_protocol::native::ids::SessionId, - child_session_id: &devo_protocol::native::ids::SessionId, - ) -> Result { - Ok(InventedSessionPersistence { - rollout_path: self.allocate_child_rollout_path( - parent_rollout_path, - parent_session_id, - child_session_id, - )?, - extras: invent_session_persistence_extras(), - }) + rollout_path: &Path, + session: &devo_protocol::native::session::Session, + extras: Option, + ) -> Result<()> { + self.append_session_meta_with_mode( + rollout_path, + session, + extras, + JournalWriteMode::ExistingOnly, + ) } - /// Appends the mandatory session header line (path-first Native). - pub(crate) fn append_session_meta_at( + /// Explicit root/fork/journal creation: allowed to create the physical file. + pub(crate) fn append_session_meta_create_at( &self, rollout_path: &Path, session: &devo_protocol::native::session::Session, extras: Option, ) -> Result<()> { - self.append_rollout_lines( + self.append_session_meta_with_mode( rollout_path, - vec![devo_core::session_line(session.clone(), extras, Utc::now())], + session, + extras, + JournalWriteMode::CreateIfMissing, ) } + fn append_session_meta_with_mode( + &self, + rollout_path: &Path, + session: &devo_protocol::native::session::Session, + extras: Option, + mode: JournalWriteMode, + ) -> Result<()> { + let session_id = session.id; + self.append_rollout_lines_with(rollout_path, session_id, mode, |state| { + let merged = + merge_session_extras(state.session_extras.get(&session_id), extras.as_ref()); + state.session_extras.insert(session_id, merged.clone()); + Ok(vec![devo_core::session_line( + session.clone(), + Some(merged), + Utc::now(), + )]) + }) + } + /// Test / migrate boundary: SessionRecord → Native SessionMeta append. /// Live create paths must use [`Self::append_session_meta_at`]. #[cfg(test)] pub(crate) fn append_session_meta(&self, record: &SessionRecord) -> Result<()> { - self.append_session_meta_at( + self.append_session_meta_create_at( &record.rollout_path, &devo_core::native_session_from_record(record) .context("build native session from record")?, @@ -346,22 +523,28 @@ impl RolloutStore { _settings_epoch: u64, settings: &[(SessionSettingsField, serde_json::Value)], ) -> Result<()> { - self.append_rollout_lines_with(rollout_path, |state| { - let native_session_id = devo_core::native_session_id(session_id) - .context("native session id for settings")?; - let mut lines = Vec::with_capacity(settings.len()); - for (field, value) in settings { - let epoch = state.write_state.allocate_settings_epoch(); - lines.push(devo_core::settings_line( - Utc::now(), - native_session_id, - *field, - value.clone(), - epoch, - )); - } - Ok(lines) - }) + let native_session_id = + devo_core::native_session_id(session_id).context("native session id for settings")?; + self.append_rollout_lines_with( + rollout_path, + native_session_id, + JournalWriteMode::ExistingOnly, + |state| { + let session_state = state.session_state_mut(native_session_id); + let mut lines = Vec::with_capacity(settings.len()); + for (field, value) in settings { + let epoch = session_state.allocate_settings_epoch(); + lines.push(devo_core::settings_line( + Utc::now(), + native_session_id, + *field, + value.clone(), + epoch, + )); + } + Ok(lines) + }, + ) } /// Appends one turn line (path-first Native). @@ -396,12 +579,18 @@ impl RolloutStore { /// server tests/fixtures that still feed packed records. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn append_item_at(&self, rollout_path: &Path, item: ItemRecord) -> Result<()> { - self.append_rollout_lines_with(rollout_path, |state| { - state - .write_state - .item_lines_from_record(&item, Utc::now()) - .context("expand item record to v2 lines") - }) + let session_id = item.session_id; + self.append_rollout_lines_with( + rollout_path, + session_id, + JournalWriteMode::ExistingOnly, + |state| { + state + .session_state_mut(session_id) + .item_lines_from_record(&item, Utc::now()) + .context("expand item record to v2 lines") + }, + ) } /// Narrow packed-ItemRecord helper for migrate / tests that still hold a SessionRecord. @@ -410,6 +599,61 @@ impl RolloutStore { self.append_item_at(&record.rollout_path, item) } + /// Drops cached append state after a caller rewrites a rollout file + /// outside [`Self::append_rollout_lines`] (for example session/import). + /// Runs a raw file mutation under the canonical journal's state mutex. + /// Used by import and other out-of-band rewrites so no cached append state + /// can survive the mutation. The caller must hold the SessionFileLock lease. + pub(crate) fn with_journal_state( + &self, + rollout_path: &Path, + mutate: impl FnOnce() -> Result, + ) -> Result { + let _lease = crate::runtime::session_file_lock::SessionFileLock::acquire(rollout_path) + .map_err(|error| anyhow::anyhow!("acquire journal lease: {error}"))?; + let shared = self.shared_journal(rollout_path)?; + let mut guard = shared + .state + .lock() + .expect("rollout journal state mutex poisoned"); + if guard.closed { + anyhow::bail!("journal {} was deleted", rollout_path.display()); + } + guard.write = None; + let original_len = journal_stamp(rollout_path).len; + let result = mutate(); + if result.is_err() && journal_stamp(rollout_path).len > original_len { + let _ = truncate_rollout(rollout_path, original_len); + } + guard.write = None; + result + } + + /// Appends a durable session-deletion marker. The enclosing Internal line + /// carries the native session id; scoped readers reject the session and + /// index rebuilds omit it. + pub(crate) fn append_session_deleted_at( + &self, + rollout_path: &Path, + session_id: SessionId, + deleted_at: chrono::DateTime, + ) -> Result<()> { + self.append_rollout_lines( + rollout_path, + vec![RolloutLine::Internal { + v: 2, + timestamp: deleted_at, + session_id, + turn_id: None, + seq: 0, + entry: InternalRecord::SessionDeleted { + schema_version: 1, + deleted_at, + }, + }], + ) + } + /// Appends one session-title update line to the durable rollout journal. pub(crate) fn append_title_update_at( &self, @@ -663,51 +907,90 @@ impl RolloutStore { ) -> Result> { let mut sessions = HashMap::new(); for rollout_path in self.rollout_paths()? { - match self.load_session_from_rollout(&rollout_path, deps).await { - Ok(recovered) => { - sessions.insert(recovered.summary.session_id(), recovered); - } + let indexed = match read_rollout_index_sessions(&rollout_path) { + Ok(indexed) => indexed, Err(error) => { tracing::warn!( rollout_path = %rollout_path.display(), error = %error, - "failed to replay rollout; skipping persisted session" + "failed to index rollout for bulk restore; skipping journal" ); + continue; + } + }; + for entry in indexed { + let session_id = entry.record.id; + match self + .load_session_from_rollout_for_session( + &rollout_path, + session_id, + deps, + /*history*/ None, + ) + .await + { + Ok(recovered) => { + sessions.insert(recovered.summary.session_id(), recovered); + } + Err(error) => { + tracing::warn!( + rollout_path = %rollout_path.display(), + session_id = %session_id, + error = %error, + "failed to replay session from journal; skipping" + ); + } } } } Ok(sessions) } - /// Indexes rollout SessionMeta headers into SQLite without replaying turns. + /// Indexes every logical session in every rollout journal into SQLite + /// without replaying turns. A shared root journal contributes one index row + /// per `SessionMeta`; a legacy independent child journal contributes one. pub(crate) fn index_rollout_metadata(&self, db: &crate::db::Database) -> Result<()> { let mut canonical = HashMap::, PathBuf, SessionRecord)>::new(); + let mut deleted_session_ids = std::collections::HashSet::new(); for rollout_path in self.rollout_paths()? { - match read_rollout_index_fields(&rollout_path) { - Ok((record, last_activity_at)) => match canonical.get(&record.id) { - Some((existing_activity, existing_path, _)) => { - if last_activity_at > *existing_activity { - tracing::warn!( - session_id = %record.id, - kept_rollout_path = %rollout_path.display(), - replaced_rollout_path = %existing_path.display(), - "duplicate rollout for session id; keeping newest last_activity_at" - ); - canonical.insert(record.id, (last_activity_at, rollout_path, record)); - } else { - tracing::warn!( - session_id = %record.id, - kept_rollout_path = %existing_path.display(), - ignored_rollout_path = %rollout_path.display(), - "duplicate rollout for session id; keeping newest last_activity_at" - ); + match read_rollout_index_scan(&rollout_path) { + Ok(scan) => { + deleted_session_ids.extend(scan.deleted); + for indexed in scan.sessions { + let record = indexed.record; + let last_activity_at = indexed.last_activity_at; + match canonical.get(&record.id) { + Some((existing_activity, existing_path, _)) => { + if last_activity_at > *existing_activity { + tracing::warn!( + session_id = %record.id, + kept_rollout_path = %rollout_path.display(), + replaced_rollout_path = %existing_path.display(), + "duplicate rollout for session id; keeping newest last_activity_at" + ); + canonical.insert( + record.id, + (last_activity_at, rollout_path.clone(), record), + ); + } else { + tracing::warn!( + session_id = %record.id, + kept_rollout_path = %existing_path.display(), + ignored_rollout_path = %rollout_path.display(), + "duplicate rollout for session id; keeping newest last_activity_at" + ); + } + } + None => { + canonical.insert( + record.id, + (last_activity_at, rollout_path.clone(), record), + ); + } } } - None => { - canonical.insert(record.id, (last_activity_at, rollout_path, record)); - } - }, + } Err(error) => { tracing::warn!( rollout_path = %rollout_path.display(), @@ -719,6 +1002,9 @@ impl RolloutStore { } for (session_id, (last_activity_at, rollout_path, record)) in canonical { + if deleted_session_ids.contains(&session_id) { + continue; + } let index_row = session_index_row_from_record(&record, last_activity_at); if let Err(error) = db.upsert_rollout_index_session(index_row, Some(rollout_path.as_path())) @@ -730,6 +1016,18 @@ impl RolloutStore { ); } } + // A durable tombstone wins over any live row from another journal. + for session_id in deleted_session_ids { + if let Err(error) = db.clear_pending(&session_id, crate::db::QueueType::Turn) { + tracing::warn!(%session_id, %error, "failed to clear tombstoned turn queue"); + } + if let Err(error) = db.clear_pending(&session_id, crate::db::QueueType::Steer) { + tracing::warn!(%session_id, %error, "failed to clear tombstoned steer queue"); + } + if let Err(error) = db.delete_session(&session_id) { + tracing::warn!(%session_id, %error, "failed to delete tombstoned session row"); + } + } Ok(()) } @@ -750,6 +1048,124 @@ impl RolloutStore { Ok(None) } + /// Finds a journal containing the session even when its latest lifecycle + /// marker is a tombstone. Delete retries use this to remove files left by a + /// crash between durable marking and unlink; resume/read paths must use the + /// tombstone-excluding lookup instead. + pub(crate) fn find_journal_containing_session_including_deleted( + &self, + session_id: &devo_protocol::native::ids::SessionId, + ) -> Result> { + self.find_journal_containing_session_strict(session_id, JournalVisibility::IncludeDeleted) + } + + /// Strict journal lookup. Unlike the lenient index/read fallback, a damaged + /// or unreadable journal is an error, not a missing session. + pub(crate) fn find_journal_containing_session_strict( + &self, + session_id: &devo_protocol::native::ids::SessionId, + visibility: JournalVisibility, + ) -> Result> { + for rollout_path in self.rollout_paths()? { + let (live, tombstoned) = self.journal_session_membership(&rollout_path, session_id)?; + let deleted = visibility == JournalVisibility::IncludeDeleted && tombstoned; + if live || deleted { + return Ok(Some(rollout_path)); + } + } + Ok(None) + } + + /// Strict membership scan for one journal/session using the same fail-closed + /// rollout visitor as resume. Returns `(live, tombstoned)`. + pub(crate) fn journal_session_membership( + &self, + rollout_path: &Path, + session_id: &devo_protocol::native::ids::SessionId, + ) -> Result<(bool, bool)> { + let mut live = false; + let mut deleted = false; + visit_rollout_lines(rollout_path, "journal membership", |line| { + match line { + RolloutLine::SessionMeta { session, .. } if session.id == *session_id => { + live = true; + } + RolloutLine::Internal { + session_id: owner, + entry: InternalRecord::SessionDeleted { .. }, + .. + } if owner == *session_id => { + deleted = true; + } + _ => {} + } + Ok(()) + })?; + Ok((live, deleted)) + } + + /// Single shared resolver for consumers that hold an indexed path: trust it + /// only when it exists and proves membership; otherwise scan strictly. + pub(crate) fn resolve_session_journal( + &self, + session_id: &devo_protocol::native::ids::SessionId, + indexed_path: Option<&Path>, + visibility: JournalVisibility, + ) -> Result> { + if let Some(path) = indexed_path.filter(|path| path.exists()) { + let (live, tombstoned) = self.journal_session_membership(path, session_id)?; + let deleted = visibility == JournalVisibility::IncludeDeleted && tombstoned; + if live || deleted { + return Ok(Some(path.to_path_buf())); + } + } + self.find_journal_containing_session_strict(session_id, visibility) + } + + /// Raw persisted agent lineage, ignoring tombstone state and index rows. + /// Delete retries need the parent edge even after the live scoped readers + /// intentionally reject a tombstoned session. + pub(crate) fn raw_session_parent( + &self, + rollout_path: &Path, + session_id: &SessionId, + ) -> Result>> { + let mut found = None; + visit_rollout_lines(rollout_path, "lineage read", |line| { + if let RolloutLine::SessionMeta { session, .. } = line + && session.id == *session_id + { + found = Some(session.parent.as_ref().map(|parent| { + let devo_protocol::native::session::SessionParent::Agent { session_id, .. } = + parent; + *session_id + })); + } + Ok(()) + })?; + Ok(found) + } + + /// Every persisted agent edge, including sessions whose latest marker is a + /// tombstone and sessions absent from the rebuildable SQLite index. + pub(crate) fn persisted_agent_edges(&self) -> Result> { + let mut edges = Vec::new(); + for rollout_path in self.rollout_paths()? { + visit_rollout_lines(&rollout_path, "lineage read", |line| { + if let RolloutLine::SessionMeta { session, .. } = line + && let Some(devo_protocol::native::session::SessionParent::Agent { + session_id: parent, + .. + }) = session.parent.as_ref() + { + edges.push((session.id, *parent)); + } + Ok(()) + })?; + } + Ok(edges) + } + /// Resolves a session cwd from the SQLite index, falling back to rollout SessionMeta. pub(crate) fn resolve_indexed_session_cwd( &self, @@ -759,96 +1175,216 @@ impl RolloutStore { if let Some(index) = db.get_session_index(session_id)? { return Ok(Some(index.session.cwd)); } - if let Some(rollout_path) = self.find_rollout_by_session_id(session_id)? { - let (record, _) = read_rollout_index_fields(&rollout_path)?; - return Ok(Some(record.cwd)); + if let Some(rollout_path) = + self.resolve_session_journal(session_id, None, JournalVisibility::Live)? + && let Some(indexed) = read_rollout_index_sessions(&rollout_path)? + .into_iter() + .find(|entry| entry.record.id == *session_id) + { + return Ok(Some(indexed.record.cwd)); } Ok(None) } - /// Deletes canonical rollout files for a session. + /// Deletes the durable storage owned by one session. /// - /// Root sessions also remove `session-artifacts//` (nested children). - pub(crate) fn delete_session_rollouts(&self, session_id: &SessionId) -> Result { - let exact = format!("{session_id}.jsonl"); + /// The journal is unlinked only when it is the target session's own file + /// (root journal or legacy independent child journal). A child sharing the + /// root journal never unlinks it; the caller must persist a tombstone first. + /// `delete_root_tree` removes `session-artifacts//` entirely. + pub(crate) fn delete_session_journal( + &self, + session_id: &SessionId, + journal_path: Option<&Path>, + session_dir: Option<&Path>, + delete_root_tree: bool, + ) -> Result { let mut deleted = false; let mut output_candidates = Vec::new(); - for rollout_path in self.rollout_paths()? { - let Some(file_name) = rollout_path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if file_name != exact { - continue; - } - output_candidates.extend(devo_core::output_replay::read_output_references( - &rollout_path, - )?); - let file_lock = { - let mut locks = self - .file_locks - .lock() - .expect("rollout file-locks table poisoned"); - locks - .entry(rollout_path.clone()) - .or_insert_with(|| Arc::new(StdMutex::new(()))) - .clone() - }; - let _guard = file_lock.lock().expect("rollout per-file lock poisoned"); - match std::fs::remove_file(&rollout_path) { - Ok(()) => deleted = true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("delete rollout {}", rollout_path.display())); + let mut removed_journal = false; + + let own_journal_path = journal_path.filter(|path| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem == session_id.as_str()) + }); + let should_unlink_journal = + own_journal_path.is_some() || (delete_root_tree && journal_path.is_none()); + if should_unlink_journal { + if let Some(rollout_path) = journal_path { + if self.unlink_journal_file(rollout_path, &mut output_candidates)? { + deleted = true; + removed_journal = true; + } + } else { + // Compatibility fallback for callers without an indexed path. + let exact = format!("{session_id}.jsonl"); + for rollout_path in self.rollout_paths()? { + if rollout_path.file_name().and_then(|name| name.to_str()) + != Some(exact.as_str()) + { + continue; + } + if self.unlink_journal_file(&rollout_path, &mut output_candidates)? { + deleted = true; + removed_journal = true; + } } } } - let artifacts = self - .data_root - .join("session-artifacts") - .join(session_id.to_string()); - if artifacts.is_dir() { - match std::fs::remove_dir_all(&artifacts) { + + if delete_root_tree { + let artifacts = self.root_session_dir(session_id); + if artifacts.is_dir() { + match std::fs::remove_dir_all(&artifacts) { + Ok(()) => deleted = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("delete session artifacts {}", artifacts.display()) + }); + } + } + } + } else if let Some(session_dir) = session_dir + && self.is_child_artifact_dir(session_dir) + && session_dir.is_dir() + { + match std::fs::remove_dir_all(session_dir) { Ok(()) => deleted = true, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(error).with_context(|| { - format!("delete session artifacts {}", artifacts.display()) + format!("delete child session dir {}", session_dir.display()) }); } } } - if let Err(error) = self.collect_unreferenced_outputs(&output_candidates) { + + if removed_journal && let Err(error) = self.collect_unreferenced_outputs(&output_candidates) + { tracing::warn!(%error, "output cleanup deferred to preserve session references"); } Ok(deleted) } + /// Unlinks one physical journal while its JournalShared state stays locked + /// against same-process appends. The caller holds / will acquire the + /// cross-process SessionFileLock lease. + fn unlink_journal_file( + &self, + rollout_path: &Path, + output_candidates: &mut Vec, + ) -> Result { + let _lease = crate::runtime::session_file_lock::SessionFileLock::acquire(rollout_path) + .map_err(|error| anyhow::anyhow!("acquire journal lease before unlink: {error}"))?; + let key = crate::journal_path::canonical_journal_key(rollout_path)?; + let shared = self.shared_journal(rollout_path)?; + let mut guard = shared + .state + .lock() + .expect("rollout journal state mutex poisoned"); + if !key.exists() { + guard.closed = true; + guard.write = None; + return Ok(false); + } + output_candidates.extend(devo_core::output_replay::read_output_references(&key)?); + let removed = match std::fs::remove_file(&key) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(error).with_context(|| format!("delete rollout {}", key.display())); + } + }; + // Keep the JournalShared (and its local strong Arc) alive with + // closed=true. Straggler/new appends through this store hit the same + // mutex and fail instead of recreating the root. No second global set. + guard.closed = true; + guard.write = None; + drop(guard); + Ok(removed) + } + + /// True when `dir` is a strict descendant of `/session-artifacts`. + /// A root artifact dir (`session-artifacts/`) is not a child dir and + /// must only be removed by a root-tree delete. + pub(crate) fn is_child_artifact_dir(&self, dir: &Path) -> bool { + let artifacts_root = self.data_root.join("session-artifacts"); + let artifacts_root = std::fs::canonicalize(&artifacts_root).unwrap_or(artifacts_root); + let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let Ok(relative) = dir.strip_prefix(&artifacts_root) else { + return false; + }; + relative.components().count() >= 2 + } + + #[cfg(test)] pub(crate) async fn load_session_from_rollout( &self, rollout_path: &Path, deps: &ServerRuntimeDependencies, ) -> Result { - self.load_session_from_rollout_with_history(rollout_path, deps, /*history*/ None) - .await + let mut session_id = None; + visit_rollout_lines(rollout_path, "resume", |line| { + if session_id.is_none() + && let RolloutLine::SessionMeta { session, .. } = line + { + session_id = Some(session.id); + } + Ok(()) + })?; + let session_id = session_id.with_context(|| { + format!( + "missing SessionMeta line in rollout {}", + rollout_path.display() + ) + })?; + self.load_session_from_rollout_for_session( + rollout_path, + session_id, + deps, + /*history*/ None, + ) + .await } - /// [`Self::load_session_from_rollout`] with a caller-provided canonical - /// history: hydrate reads the journal once and feeds both the - /// active-path scoping here and the downstream restores. `None` performs - /// the history read internally (callers that hold no history yet). - pub(crate) async fn load_session_from_rollout_with_history( + /// Loads one logical session from a journal that may contain many. + /// + /// `history` must already be scoped to `session_id`; `None` performs the + /// scoped read internally. + pub(crate) async fn load_session_from_rollout_for_session( &self, rollout_path: &Path, + session_id: SessionId, deps: &ServerRuntimeDependencies, history: Option<&devo_core::CanonicalHistory>, ) -> Result { let mut replay = ReplayState::default(); + let mut deleted_at = None; visit_rollout_lines(rollout_path, "resume", |v2| { + if v2.session_id() != Some(session_id) { + return Ok(()); + } + if let RolloutLine::Internal { + entry: + InternalRecord::SessionDeleted { + deleted_at: deleted, + .. + }, + .. + } = &v2 + { + deleted_at = Some(*deleted); + return Ok(()); + } replay .apply_rollout_line(v2) .with_context(|| format!("apply v2 line from {}", rollout_path.display())) })?; + if let Some(deleted_at) = deleted_at { + anyhow::bail!("session {session_id} was deleted at {deleted_at} and cannot be resumed"); + } // Captured before `into_runtime_session` consumes the replay state: // the path-scoping block below needs it to keep the compacted prompt. let compaction_snapshot = replay.latest_compaction_snapshot.clone(); @@ -857,9 +1393,18 @@ impl RolloutStore { .into_runtime_session(deps) .await .with_context(|| format!("replay rollout {}", rollout_path.display()))?; + if recovered.summary.session_id() != session_id { + anyhow::bail!( + "rollout session id mismatch: expected {session_id}, got {}", + recovered.summary.session_id() + ); + } if let Some(turn) = &recovered.latest_turn { - let execution = - devo_core::durable_execution::read_execution_replay(rollout_path, &turn.turn_id())?; + let execution = devo_core::durable_execution::read_execution_replay_for_session( + rollout_path, + &turn.turn_id(), + &session_id, + )?; if execution.has_checkpoint { recovered.core_session.lock().await.set_prompt_messages( devo_core::history::response_items_to_messages(&execution.items), @@ -868,12 +1413,14 @@ impl RolloutStore { } // Absolute path is actor-owned; Native Session does not expose it. recovered.rollout_path = Some(rollout_path.to_path_buf()); + recovered.summary.session_dir = self.session_dir_for(rollout_path, &session_id); // Restrict model context to the active transcript-tree path (root→leaf). let owned_history; let scoped_history = match history { Some(history) => Some(history), None => { - owned_history = read_canonical_history(rollout_path).ok(); + owned_history = + devo_core::read_canonical_history_for_session(rollout_path, &session_id).ok(); owned_history.as_ref() } }; @@ -928,9 +1475,13 @@ impl RolloutStore { pub(crate) fn workspace_checkpoints_at( &self, rollout_path: &Path, + session_id: &SessionId, ) -> Result> { let mut checkpoints = Vec::new(); visit_rollout_lines(rollout_path, "checkpoint read", |v2| { + if v2.session_id() != Some(*session_id) { + return Ok(()); + } if let RolloutLine::WorkspaceCheckpoint { record, .. } = v2 { checkpoints.push(record); } @@ -959,58 +1510,50 @@ impl RolloutStore { .join(format!("{session_id}.jsonl")) } - /// RLM session artifact directory for harness / refine. + /// Root session artifact directory for a durable root session. + pub(crate) fn root_session_dir(&self, root_id: &SessionId) -> PathBuf { + self.data_root + .join("session-artifacts") + .join(root_id.to_string()) + } + + /// Deterministic child/grandchild artifact directory under the true root. /// - /// Root rollouts live at `sessions/.jsonl`; harness state must not sit - /// beside them (that would share one store across all roots). Instead use - /// `session-artifacts//` — the same tree child sessions nest under. - /// Nested child rollouts already live under `session-artifacts/…/sub-…/`, - /// so their parent directory is the artifact dir. - pub(crate) fn rlm_session_dir_for_rollout(rollout_path: &Path) -> Option { - let parent = rollout_path.parent()?; - let stem = rollout_path.file_stem()?.to_str()?; - if parent.file_name().and_then(|s| s.to_str()) == Some("sessions") { - let data_root = parent.parent()?; - return Some(data_root.join("session-artifacts").join(stem)); - } - Some(parent.to_path_buf()) - } - - /// Directory under which the next `sub-xxxxxxxx/` child folder is created. - fn nesting_dir_for_parent( + /// New descendants share the root journal, so they must not derive their + /// artifact directory from the journal's parent (`sessions/`). The id is + /// stable and independent for every child at any depth. + pub(crate) fn child_session_dir(&self, root_id: &SessionId, child_id: &SessionId) -> PathBuf { + self.root_session_dir(root_id) + .join("agents") + .join(child_id.to_string()) + } + + /// Artifact directory for a loaded session, derived from its journal path + /// and session id. Used at hydration time when no live spawn computed it. + pub(crate) fn session_dir_for( &self, - parent_rollout_path: Option<&Path>, - parent_session_id: &devo_protocol::native::ids::SessionId, - ) -> Result { - let Some(parent_rollout_path) = parent_rollout_path else { - // Ephemeral parent (no file): still nest under artifacts by parent id. - return Ok(self - .data_root - .join("session-artifacts") - .join(parent_session_id.to_string())); - }; + rollout_path: &Path, + session_id: &SessionId, + ) -> Option { + let canonical = crate::journal_path::canonical_journal_key(rollout_path).ok()?; + let parent = canonical.parent()?; + let stem = canonical.file_stem()?.to_str()?; let sessions_dir = self.data_root.join("sessions"); - let parent_dir = parent_rollout_path.parent().ok_or_else(|| { - anyhow::anyhow!( - "parent rollout path has no directory: {}", - parent_rollout_path.display() - ) - })?; - if parent_dir == sessions_dir { - // Root session file → session-artifacts/ - let stem = parent_rollout_path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| { - anyhow::anyhow!( - "invalid root rollout file name: {}", - parent_rollout_path.display() - ) - })?; - return Ok(self.data_root.join("session-artifacts").join(stem)); + let sessions_dir = std::fs::canonicalize(&sessions_dir).unwrap_or(sessions_dir); + if parent == sessions_dir { + if stem == session_id.as_str() { + return Some(self.root_session_dir(session_id)); + } + let root_id = SessionId::from_string(stem.to_owned()); + return Some(self.child_session_dir(&root_id, session_id)); + } + if stem == session_id.as_str() { + // Legacy independent child journal: its parent is the child dir. + return Some(parent.to_path_buf()); } - // Already under session-artifacts/…/sub-xxx/ → nest further in that dir. - Ok(parent_dir.to_path_buf()) + // A child sharing a non-root journal (legacy branch). Keep the + // artifact dir independent of the shared file. + Some(parent.join("agents").join(session_id.to_string())) } /// Appends one canonical Native item envelope as a v2 rollout line @@ -1084,31 +1627,6 @@ impl RolloutStore { ) } - /// RLM kernel fence outcome at spawn (design doc §5.3): audit trail for - /// fenced/downgraded-unfenced kernels. Only downgrades need to be audited, - /// but recording fenced spawns keeps the trail complete. - pub(crate) fn append_kernel_fence_state( - &self, - rollout_path: &Path, - session_id: SessionId, - state: &str, - ) -> Result<()> { - self.append_rollout_lines( - rollout_path, - vec![RolloutLine::Internal { - v: 2, - timestamp: Utc::now(), - session_id, - turn_id: None, - seq: 0, - entry: devo_core::InternalRecord::KernelFence { - schema_version: 1, - state: state.to_string(), - }, - }], - ) - } - pub(crate) fn append_usage_record( &self, rollout_path: &Path, @@ -1148,7 +1666,13 @@ impl RolloutStore { ) } - fn append_rollout_lines_with(&self, rollout_path: &Path, build: F) -> Result<()> + fn append_rollout_lines_with( + &self, + rollout_path: &Path, + session_id: SessionId, + mode: JournalWriteMode, + build: F, + ) -> Result<()> where F: FnOnce(&mut WritePathState) -> Result>, { @@ -1156,35 +1680,69 @@ impl RolloutStore { std::fs::create_dir_all(parent) .with_context(|| format!("create rollout directory {}", parent.display()))?; } - let file_lock = { - let mut locks = self - .file_locks - .lock() - .expect("rollout file-locks table poisoned"); - locks - .entry(rollout_path.to_path_buf()) - .or_insert_with(|| Arc::new(StdMutex::new(()))) - .clone() - }; - let _guard = file_lock.lock().expect("rollout per-file lock poisoned"); - let mut write_states = self - .write_states + // Central lease enforcement: every write path takes the same-process + // reusable / cross-process exclusive SessionFileLock before the journal + // state mutex. Actor callers already hold it; this is a cheap same- + // process reacquire, not a second lock scheme. + let _lease = crate::runtime::session_file_lock::SessionFileLock::acquire(rollout_path) + .map_err(|error| anyhow::anyhow!("acquire journal lease: {error}"))?; + let shared = self.shared_journal(rollout_path)?; + let mut guard = shared + .state .lock() - .expect("rollout write-state table poisoned"); - let state = match write_states.get_mut(rollout_path) { - Some(state) => state, - None => { - let state = hydrate_write_state(rollout_path)?; - write_states - .entry(rollout_path.to_path_buf()) - .or_insert(state) - } + .expect("rollout journal state mutex poisoned"); + if guard.closed { + anyhow::bail!("journal {} was deleted", rollout_path.display()); + } + // Existence is checked under the same state mutex that unlink holds, + // so an ordinary append cannot recreate a journal being deleted. + if mode == JournalWriteMode::ExistingOnly && !rollout_path.exists() { + anyhow::bail!( + "journal {} is missing; refusing to recreate an established journal", + rollout_path.display() + ); + } + let observed = journal_stamp(rollout_path); + let mut state = match guard.write.take() { + Some(state) if state.observed == observed => state, + _ => hydrate_write_state(rollout_path)?, }; - let rollout_lines = build(state)?; - for line in &rollout_lines { - state.write_state.observe_rollout_line(line); + // Hydration may repair a crash tail; the rollback length must be the + // repaired length, never the pre-repair observed length. + let original_len = state.observed.len; + let result = (|| -> Result<()> { + let rollout_lines = build(&mut state)?; + for line in &rollout_lines { + if rollout_line_session_id(line) != Some(session_id) { + anyhow::bail!("rollout append batch mixes sessions: expected {session_id}"); + } + if let RolloutLine::SessionMeta { extras, .. } = line { + let merged = merge_session_extras( + state.session_extras.get(&session_id), + extras.as_deref(), + ); + state.session_extras.insert(session_id, merged); + } + } + { + let session_state = state.session_state_mut(session_id); + for line in &rollout_lines { + session_state.observe_rollout_line(line); + } + } + self.write_rollout_lines(rollout_path, &mut state, &rollout_lines)?; + state.observed = journal_stamp(rollout_path); + Ok(()) + })(); + if let Err(error) = result { + if journal_stamp(rollout_path).len > original_len { + let _ = truncate_rollout(rollout_path, original_len); + } + guard.write = None; + return Err(error); } - self.write_rollout_lines(rollout_path, state, &rollout_lines) + guard.write = Some(state); + Ok(()) } pub(crate) fn append_rollout_lines( @@ -1192,7 +1750,21 @@ impl RolloutStore { rollout_path: &Path, rollout_lines: Vec, ) -> Result<()> { - self.append_rollout_lines_with(rollout_path, |_| Ok(rollout_lines)) + let Some(session_id) = rollout_lines.iter().find_map(rollout_line_session_id) else { + return Ok(()); + }; + if rollout_lines + .iter() + .any(|line| rollout_line_session_id(line) != Some(session_id)) + { + anyhow::bail!("rollout append batch mixes sessions: expected {session_id}"); + } + self.append_rollout_lines_with( + rollout_path, + session_id, + JournalWriteMode::ExistingOnly, + |_| Ok(rollout_lines), + ) } fn write_rollout_lines( @@ -1201,6 +1773,8 @@ impl RolloutStore { state: &mut WritePathState, rollout_lines: &[RolloutLine], ) -> Result<()> { + let canonical_journal = crate::journal_path::canonical_journal_key(rollout_path)?; + let rollout_path = canonical_journal.as_path(); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) @@ -1278,6 +1852,8 @@ pub(crate) fn event_log_rows_for_rollout_line( rollout_line: &RolloutLine, ) -> Result> { let timestamp = rollout_line_timestamp(rollout_line); + let canonical_journal = crate::journal_path::canonical_journal_key(rollout_path) + .with_context(|| format!("resolve canonical journal {}", rollout_path.display()))?; let mut rows = Vec::new(); for derived in events_from_rollout_line(rollout_line) { let envelope = EventEnvelope { @@ -1295,7 +1871,7 @@ pub(crate) fn event_log_rows_for_rollout_line( notification: derived.notification, }; rows.push(NewEventLogRow { - source_fact_id: source_fact_id(rollout_path, line_index, sub_index), + source_fact_id: source_fact_id(&canonical_journal, line_index, sub_index), event_kind: derived.event_kind.to_owned(), stream_id: derived.stream_id, event_id: envelope.meta.event_id.to_string(), @@ -1323,6 +1899,50 @@ fn rollout_line_timestamp(line: &RolloutLine) -> chrono::DateTime { } } +/// Session attribution for one v2 line. This mirrors the core +/// `RolloutLine::session_id` contract while the core helper is being landed by +/// the storage worker; server write-state routing and index grouping use it. +fn rollout_line_session_id(line: &RolloutLine) -> Option { + line.session_id() +} + +fn merge_session_extras( + existing: Option<&SessionPersistenceExtras>, + incoming: Option<&SessionPersistenceExtras>, +) -> SessionPersistenceExtras { + let mut merged = existing + .cloned() + .unwrap_or_else(invent_session_persistence_extras); + let Some(incoming) = incoming else { + return merged; + }; + if incoming.session_context.is_some() { + merged.session_context = incoming.session_context.clone(); + } + if !incoming.cli_version.is_empty() { + merged.cli_version = incoming.cli_version.clone(); + } + if !incoming.source.is_empty() { + merged.source = incoming.source.clone(); + } + if incoming.collaboration_mode.is_some() { + merged.collaboration_mode = incoming.collaboration_mode; + } + if incoming.permission_preset.is_some() { + merged.permission_preset = incoming.permission_preset; + } + if incoming.agent_path.is_some() { + merged.agent_path = incoming.agent_path.clone(); + } + if incoming.agent_nickname.is_some() { + merged.agent_nickname = incoming.agent_nickname.clone(); + } + if incoming.kernel_snapshot_path.is_some() { + merged.kernel_snapshot_path = incoming.kernel_snapshot_path.clone(); + } + merged +} + /// Walks a rollout JSONL file, skipping blanks and stopping at a crash tail. /// Returns whether a truncated final line was seen. fn visit_rollout_lines( @@ -1341,7 +1961,24 @@ fn visit_open_rollout_lines( refuse_verb: &str, visit: &mut impl FnMut(RolloutLine) -> Result<()>, ) -> Result { - let reader = BufReader::new(file); + // Snapshot the opened file length so a concurrent append cannot turn an + // in-flight truncated tail into a seemingly damaged middle line. Only the + // final row of this snapshot may be tolerated as a crash tail. + let snapshot_len = file + .metadata() + .with_context(|| format!("stat rollout file {}", rollout_path.display()))? + .len(); + visit_open_rollout_lines_snapshot(rollout_path, file, snapshot_len, refuse_verb, visit) +} + +fn visit_open_rollout_lines_snapshot( + rollout_path: &Path, + file: File, + snapshot_len: u64, + refuse_verb: &str, + visit: &mut impl FnMut(RolloutLine) -> Result<()>, +) -> Result { + let reader = BufReader::new(file.take(snapshot_len)); let mut lines = reader.lines().enumerate().peekable(); while let Some((line_index, line)) = lines.next() { let line = line.with_context(|| format!("read line from {}", rollout_path.display()))?; @@ -1452,15 +2089,16 @@ fn truncate_rollout(rollout_path: &Path, keep_len: u64) -> Result<()> { /// Fails closed on any damaged or unsupported line: appending onto history /// the write state could not fully read would fork the session's history. fn hydrate_write_state(rollout_path: &Path) -> Result { - let mut write_state = RolloutWriteState::new(); - let mut next_line_index = 0u64; + let mut state = WritePathState { + sessions: HashMap::new(), + session_extras: HashMap::new(), + next_line_index: 0, + observed: JournalStamp::missing(), + }; let file = match File::open(rollout_path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(WritePathState { - write_state, - next_line_index, - }); + return Ok(state); } Err(error) => { return Err(error) @@ -1468,8 +2106,19 @@ fn hydrate_write_state(rollout_path: &Path) -> Result { } }; let saw_crash_tail = visit_open_rollout_lines(rollout_path, file, "append", &mut |v2| { - write_state.observe_rollout_line(&v2); - next_line_index += 1; + if let Some(session_id) = rollout_line_session_id(&v2) { + if let RolloutLine::SessionMeta { extras, .. } = &v2 { + let merged = + merge_session_extras(state.session_extras.get(&session_id), extras.as_deref()); + state.session_extras.insert(session_id, merged); + } + state + .sessions + .entry(session_id) + .or_default() + .observe_rollout_line(&v2); + } + state.next_line_index += 1; Ok(()) })?; if saw_crash_tail { @@ -1478,10 +2127,10 @@ fn hydrate_write_state(rollout_path: &Path) -> Result { // is closed (Windows cannot truncate an open file). discard_rollout_crash_tail(rollout_path)?; } - Ok(WritePathState { - write_state, - next_line_index, - }) + // Refresh after the optional repair: the pre-repair length must never + // become the rollback length for a later failed append. + state.observed = journal_stamp(rollout_path); + Ok(state) } /// Resume hydrate state. Session/Turn lines are stored as Native (+ extras). @@ -2352,6 +3001,7 @@ impl ReplayState { agent_path: replayed.agent_path.clone(), agent_nickname: replayed.agent_nickname.clone(), agent_role: replayed.agent_role(), + session_dir: None, prompt_token_estimate: core_session.prompt_token_estimate, last_query_usage: self.latest_query_usage.clone(), last_query_total_tokens, @@ -2912,12 +3562,33 @@ pub(crate) fn apply_turn_item( } } -fn read_rollout_index_fields(path: &Path) -> Result<(SessionRecord, chrono::DateTime)> { +/// One session recovered from a journal's `SessionMeta` and per-session +/// activity lines. A shared journal returns one entry per logical session. +pub(crate) struct IndexedJournalSession { + pub(crate) record: SessionRecord, + pub(crate) last_activity_at: chrono::DateTime, +} + +/// One journal scan: live session entries plus ids whose latest lifecycle +/// marker is a tombstone. Tombstoned ids stay visible to the index rebuild so +/// stale SQLite rows can be deleted. +pub(crate) struct RolloutIndexScan { + pub(crate) sessions: Vec, + pub(crate) deleted: std::collections::HashSet, +} + +/// Reads every logical session header in one rollout journal. +/// +/// The index is a rebuildable cache, so unreadable or unmappable lines are +/// skipped. Per-session activity is computed from that session's own lines; +/// title updates fold into the matching session only. +pub(crate) fn read_rollout_index_scan(path: &Path) -> Result { let file = File::open(path).with_context(|| format!("open rollout file {}", path.display()))?; let reader = BufReader::new(file); - let mut session: Option = None; - let mut last_activity_at: Option> = None; - // The index is a rebuildable cache, so unreadable or unmappable lines are skipped. + let mut records: HashMap = HashMap::new(); + let mut session_extras: HashMap = HashMap::new(); + let mut activity: HashMap> = HashMap::new(); + let mut deleted: std::collections::HashSet = std::collections::HashSet::new(); for line in reader.lines() { let line = line.with_context(|| format!("read line from {}", path.display()))?; if line.trim().is_empty() { @@ -2926,33 +3597,47 @@ fn read_rollout_index_fields(path: &Path) -> Result<(SessionRecord, chrono::Date let Ok(line) = parse_rollout_line(&line) else { continue; }; + let Some(session_id) = rollout_line_session_id(&line) else { + continue; + }; let timestamp = rollout_line_timestamp(&line); - last_activity_at = - Some(last_activity_at.map_or(timestamp, |current| current.max(timestamp))); + activity + .entry(session_id) + .and_modify(|current| *current = (*current).max(timestamp)) + .or_insert(timestamp); match line { RolloutLine::SessionMeta { session: native, extras, .. } => { - let Ok(mut record) = session_record_from_native(&native, extras.as_deref()) else { + let merged = + merge_session_extras(session_extras.get(&session_id), extras.as_deref()); + session_extras.insert(session_id, merged.clone()); + let Ok(mut record) = session_record_from_native(&native, Some(&merged)) else { continue; }; if record.last_activity_at.is_none() { record.last_activity_at = Some(record.created_at); } - session = Some(record); + records.insert(record.id, record); } RolloutLine::SessionTitleUpdated { timestamp, title, .. } => { - if let Some(record) = session.as_mut() { + if let Some(record) = records.get_mut(&session_id) { record.title = Some(title); record.title_state = SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate); record.updated_at = timestamp; } } + RolloutLine::Internal { + entry: InternalRecord::SessionDeleted { .. }, + .. + } => { + deleted.insert(session_id); + } RolloutLine::Turn { .. } | RolloutLine::Item { .. } | RolloutLine::Internal { .. } @@ -2965,12 +3650,32 @@ fn read_rollout_index_fields(path: &Path) -> Result<(SessionRecord, chrono::Date } } - let session = session - .with_context(|| format!("missing SessionMeta line in rollout {}", path.display()))?; - let last_activity_at = last_activity_at - .or(session.last_activity_at) - .unwrap_or(session.created_at); - Ok((session, last_activity_at)) + for session_id in &deleted { + records.remove(session_id); + } + let mut indexed = Vec::with_capacity(records.len()); + for (session_id, record) in records { + let last_activity_at = activity + .get(&session_id) + .copied() + .or(record.last_activity_at) + .unwrap_or(record.created_at); + indexed.push(IndexedJournalSession { + record, + last_activity_at, + }); + } + indexed.sort_by_key(|entry| entry.record.id.to_string()); + Ok(RolloutIndexScan { + sessions: indexed, + deleted, + }) +} + +/// Live sessions only. Use [`read_rollout_index_scan`] when stale DB rows must +/// also be removed for tombstones. +pub(crate) fn read_rollout_index_sessions(path: &Path) -> Result> { + Ok(read_rollout_index_scan(path)?.sessions) } pub(crate) fn session_index_row_from_record( @@ -3022,26 +3727,6 @@ fn collect_rollout_files(root: &Path, files: &mut Vec) -> Result<()> { Ok(()) } -/// Creates `sub-<8 hex chars>/` under `parent_dir` (RLM child dirs). -fn create_unique_sub_dir(parent_dir: &Path) -> Result { - for _ in 0..100 { - let name = format!("sub-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); - let child_dir = parent_dir.join(name); - match std::fs::create_dir(&child_dir) { - Ok(()) => return Ok(child_dir), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(error) => { - return Err(error) - .with_context(|| format!("create child session dir {}", child_dir.display())); - } - } - } - anyhow::bail!( - "unable to create unique subagent session directory under {}", - parent_dir.display() - ) -} - /// Builds path-first turn persistence extras from Native-first runtime state. pub(crate) fn turn_persistence_extras_from_runtime( turn: &crate::turn::RuntimeTurn, diff --git a/crates/server/src/persistence/index_probe.rs b/crates/server/src/persistence/index_probe.rs index 4297c7b3..75557e69 100644 --- a/crates/server/src/persistence/index_probe.rs +++ b/crates/server/src/persistence/index_probe.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::time::Instant; -use super::{RolloutStore, read_rollout_index_fields, session_index_row_from_record}; +use super::{RolloutStore, read_rollout_index_scan, session_index_row_from_record}; use crate::db::Database; use devo_core::SessionRecord; use devo_protocol::native::ids::SessionId; @@ -34,9 +34,11 @@ fn rollout_index_refresh_phase_probe() -> anyhow::Result<()> { Vec::with_capacity(paths.len()); let mut skipped = 0usize; for path in &paths { - match read_rollout_index_fields(path) { - Ok((record, last_activity_at)) => { - scanned.push((path.clone(), record, last_activity_at)); + match read_rollout_index_scan(path) { + Ok(scan) => { + for entry in scan.sessions { + scanned.push((path.clone(), entry.record, entry.last_activity_at)); + } } Err(_) => skipped += 1, } diff --git a/crates/server/src/persistence_tests.rs b/crates/server/src/persistence_tests.rs index 9629ebea..28fe16d0 100644 --- a/crates/server/src/persistence_tests.rs +++ b/crates/server/src/persistence_tests.rs @@ -515,7 +515,288 @@ fn replay_prunes_superseded_turn_from_rollout_projection() { } #[test] -fn root_and_child_rollout_paths_match_agent_layout() { +fn strict_reader_snapshot_ignores_concurrent_partial_append() { + use std::io::Write; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("snapshot.jsonl"); + std::fs::write( + &path, + "{\"v\":2,\"kind\":\"sessionTitleUpdated\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"sessionId\":\"ses_snapshot\",\"title\":\"t\",\"previousTitle\":null}\n", + ) + .expect("seed"); + let file = std::fs::File::open(&path).expect("open"); + let snapshot_len = file.metadata().expect("metadata").len(); + let mut appender = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append"); + appender + .write_all(b"{\"v\":2,\"kind\":\"item\",\"timestamp\":\"2026") + .expect("partial append"); + appender.flush().expect("flush"); + + let mut seen = 0usize; + let saw_tail = super::visit_open_rollout_lines_snapshot( + &path, + file, + snapshot_len, + "test read", + &mut |_line| { + seen += 1; + Ok(()) + }, + ) + .expect("snapshot read"); + assert!(!saw_tail, "append beyond snapshot must not be read"); + assert_eq!(seen, 1); +} + +#[test] +fn hydrate_refreshes_stamp_after_crash_tail_repair() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let store = super::RolloutStore::new(dir.path().to_path_buf(), None); + let record = sample_record(&store, dir.path().to_path_buf(), None); + store.append_session_meta(&record).expect("append meta"); + write_raw_lines( + &record.rollout_path, + &[r#"{"v":2,"kind":"item","timestamp":"2026"#.to_string()], + ); + + let state = super::hydrate_write_state(&record.rollout_path).expect("hydrate"); + assert_eq!( + state.observed, + super::journal_stamp(&record.rollout_path), + "stamp must be refreshed after crash-tail repair" + ); + let repaired_len = std::fs::metadata(&record.rollout_path).unwrap().len(); + assert!(repaired_len > 0); + + store + .append_turn(&record, test_turn_record(record.id, TurnId::new())) + .expect("append after repair"); + let raw = raw_rollout_lines(&record.rollout_path); + assert_eq!(raw.len(), 2, "meta + turn, no retained partial tail"); + for line in raw { + parse_rollout_line(&line).expect("repaired line parses"); + } +} + +#[test] +fn failed_write_after_crash_tail_repair_truncates_to_repaired_length() { + use std::io::Write; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let store = super::RolloutStore::new(dir.path().to_path_buf(), None); + let record = sample_record(&store, dir.path().to_path_buf(), None); + store.append_session_meta(&record).expect("append meta"); + write_raw_lines( + &record.rollout_path, + &[r#"{"v":2,"kind":"item","timestamp":"2026"#.to_string()], + ); + let path = record.rollout_path.clone(); + let error = store + .append_rollout_lines_with( + &path, + record.id, + super::JournalWriteMode::ExistingOnly, + |_state| { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open partial append"); + file.write_all(&vec![b'x'; 1024]) + .expect("write oversized partial row"); + anyhow::bail!("forced write failure") + }, + ) + .expect_err("forced failure"); + assert!(error.to_string().contains("forced")); + // The closure wrote past the repaired tail; rollback must truncate it. + let raw = raw_rollout_lines(&path); + assert_eq!(raw.len(), 1, "only the repaired SessionMeta remains"); + parse_rollout_line(&raw[0]).expect("repaired line parses"); +} + +#[test] +fn durable_root_and_child_tombstones_prevent_reindex_resurrection() { + use devo_protocol::native::session::SessionParent; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let store = super::RolloutStore::new(dir.path().to_path_buf(), None); + let root = sample_record(&store, dir.path().to_path_buf(), None); + store.append_session_meta(&root).expect("root meta"); + let mut child_native = devo_core::native_session_from_record(&root).expect("native root"); + let child_id = SessionId::new(); + child_native.id = child_id; + child_native.parent = Some(SessionParent::Agent { + session_id: root.id, + role: Some("default".into()), + }); + child_native.title = Some("child".into()); + store + .append_session_meta_at(&root.rollout_path, &child_native, None) + .expect("child meta"); + + let deleted_at = chrono::Utc::now(); + store + .append_session_deleted_at(&root.rollout_path, root.id, deleted_at) + .expect("root tombstone"); + store + .append_session_deleted_at(&root.rollout_path, child_id, deleted_at) + .expect("child tombstone"); + // Simulate restart/index before physical unlink: tombstones alone must + // suppress both sessions. + let db = crate::db::Database::open(dir.path().join("devo.db")).expect("db"); + store.index_rollout_metadata(&db).expect("index"); + assert!( + db.get_session_index(&root.id) + .expect("root index") + .is_none() + ); + assert!( + db.get_session_index(&child_id) + .expect("child index") + .is_none() + ); + assert!(db.list_sessions().expect("list").is_empty()); + assert!(root.rollout_path.exists(), "fixture keeps the journal file"); +} + +#[test] +fn same_length_recreate_changes_journal_stamp() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("sessions").join("stamp.jsonl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"same-length-content").unwrap(); + let before = super::journal_stamp(&path); + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, b"same-length-content").unwrap(); + let after = super::journal_stamp(&path); + #[cfg(any(unix, windows))] + assert_ne!( + before, after, + "platform identity must distinguish a same-length recreate" + ); + #[cfg(not(any(unix, windows)))] + assert_eq!(before.len, after.len); +} + +#[test] +fn append_acquires_cross_process_lease_centrally() { + use fs2::FileExt; + use std::io::Write; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let store = super::RolloutStore::new(dir.path().to_path_buf(), None); + let record = sample_record(&store, dir.path().to_path_buf(), None); + store.append_session_meta(&record).expect("append meta"); + let canonical = + crate::journal_path::canonical_journal_key(&record.rollout_path).expect("canonical"); + let lock_path = canonical.with_file_name(format!( + "{}.lock", + canonical.file_name().unwrap().to_string_lossy() + )); + let mut lock_file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .expect("open lock"); + lock_file.try_lock_exclusive().expect("hold lease"); + lock_file.write_all(b"held").expect("write holder"); + + let error = store + .append_turn(&record, test_turn_record(record.id, TurnId::new())) + .expect_err("append must honor the process lease"); + assert!( + error.to_string().contains("lock") || error.to_string().contains("lease"), + "unexpected error: {error:#}" + ); + FileExt::unlock(&lock_file).expect("unlock"); + drop(lock_file); + store + .append_turn(&record, test_turn_record(record.id, TurnId::new())) + .expect("append after lease release"); +} + +#[test] +fn unlinked_root_journal_is_closed_and_never_recreated() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let store = super::RolloutStore::new(dir.path().to_path_buf(), None); + let record = sample_record(&store, dir.path().to_path_buf(), None); + store.append_session_meta(&record).expect("append meta"); + let native = devo_core::native_session_from_record(&record).expect("native session"); + + assert!( + store + .delete_session_journal(&record.id, Some(&record.rollout_path), None, true) + .expect("delete root journal") + ); + assert!(!record.rollout_path.exists()); + + // Explicit create path must still refuse a closed journal. + assert!( + store + .append_session_meta_create_at(&record.rollout_path, &native, None) + .is_err(), + "closed root must reject recreation" + ); + assert!( + !record.rollout_path.exists(), + "append must not recreate root" + ); + + let another_store = super::RolloutStore::new(dir.path().to_path_buf(), None); + assert!( + another_store.journal_is_closed(&record.rollout_path), + "closed state must be visible to a distinct store while the first store lives" + ); +} + +#[test] +fn distinct_stores_share_one_canonical_journal_engine() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let store_a = super::RolloutStore::new(dir.path().to_path_buf(), None); + let store_b = super::RolloutStore::new(dir.path().to_path_buf(), None); + let journal = dir.path().join("sessions").join("ses_shared.jsonl"); + std::fs::create_dir_all(journal.parent().unwrap()).unwrap(); + std::fs::write(&journal, "").unwrap(); + + let shared_a = store_a.shared_journal(&journal).expect("journal a"); + let shared_b = store_b.shared_journal(&journal).expect("journal b"); + assert!( + std::sync::Arc::ptr_eq(&shared_a, &shared_b), + "distinct stores must share one journal mutex/state" + ); + + #[cfg(unix)] + { + let alias = dir.path().join("sessions").join("alias.jsonl"); + std::os::unix::fs::symlink(&journal, &alias).expect("symlink alias"); + let shared_alias = store_a.shared_journal(&alias).expect("alias journal"); + assert!( + std::sync::Arc::ptr_eq(&shared_a, &shared_alias), + "symlink alias must resolve to the same journal engine" + ); + } +} + +#[test] +fn root_and_child_artifact_paths_and_legacy_journal_scan() { use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -530,77 +811,54 @@ fn root_and_child_rollout_paths_match_agent_layout() { root_path, dir.path().join("sessions").join(format!("{root_id}.jsonl")) ); - - let child_path = store - .allocate_child_rollout_path(Some(&root_path), &root_id, &child_id) - .expect("child path"); - let child_rel = child_path - .strip_prefix(dir.path()) - .expect("child under data root"); - let child_parts: Vec<_> = child_rel - .iter() - .map(|s| s.to_string_lossy().into_owned()) - .collect(); - assert_eq!(child_parts[0], "session-artifacts"); - assert_eq!(child_parts[1], root_id.to_string()); - assert!( - child_parts[2].starts_with("sub-") && child_parts[2].len() == 12, - "expected sub-xxxxxxxx, got {}", - child_parts[2] + std::fs::create_dir_all(root_path.parent().unwrap()).unwrap(); + let root_dir = dir + .path() + .join("session-artifacts") + .join(root_id.to_string()); + assert_eq!(store.root_session_dir(&root_id), root_dir); + assert_eq!( + store.session_dir_for(&root_path, &root_id), + Some(root_dir.clone()) ); - assert_eq!(child_parts[3], format!("{child_id}.jsonl")); - // Local harness / RLM_SESSION_DIR must be per-session artifacts, not sessions/. + let child_dir = root_dir.join("agents").join(child_id.to_string()); + let grandchild_dir = root_dir.join("agents").join(grandchild_id.to_string()); + assert_eq!(store.child_session_dir(&root_id, &child_id), child_dir); assert_eq!( - super::RolloutStore::rlm_session_dir_for_rollout(&root_path), - Some( - dir.path() - .join("session-artifacts") - .join(root_id.to_string()) - ) + store.child_session_dir(&root_id, &grandchild_id), + grandchild_dir + ); + // Both children share the root journal; their session dirs stay distinct. + assert_eq!( + store.session_dir_for(&root_path, &child_id), + Some(child_dir.clone()) ); assert_eq!( - super::RolloutStore::rlm_session_dir_for_rollout(&child_path), - child_path.parent().map(|p| p.to_path_buf()) + store.session_dir_for(&root_path, &grandchild_id), + Some(grandchild_dir) ); - let grandchild_path = store - .allocate_child_rollout_path(Some(&child_path), &child_id, &grandchild_id) - .expect("grandchild path"); - let grand_rel = grandchild_path - .strip_prefix(dir.path()) - .expect("grandchild under data root"); - let grand_parts: Vec<_> = grand_rel - .iter() - .map(|s| s.to_string_lossy().into_owned()) - .collect(); - assert_eq!(grand_parts[0], "session-artifacts"); - assert_eq!(grand_parts[1], root_id.to_string()); - assert_eq!(grand_parts[2], child_parts[2]); - assert!( - grand_parts[3].starts_with("sub-") && grand_parts[3].len() == 12, - "expected nested sub-xxxxxxxx, got {}", - grand_parts[3] + // Historical independent child journal fixture: recognized by stem==id, + // artifact dir remains its parent. No production helper creates this layout. + std::fs::create_dir_all(&child_dir).unwrap(); + let legacy_parent = root_dir.join("sub-aaaaaaaa"); + std::fs::create_dir_all(&legacy_parent).unwrap(); + let legacy_child_journal = legacy_parent.join(format!("{child_id}.jsonl")); + assert_eq!( + store.session_dir_for(&legacy_child_journal, &child_id), + Some(std::fs::canonicalize(&legacy_parent).expect("canonical legacy dir")) ); - assert_eq!(grand_parts[4], format!("{grandchild_id}.jsonl")); - // Index scans both trees. - std::fs::create_dir_all(root_path.parent().unwrap()).unwrap(); + // Index scans both layouts and skips harness sidecar JSONL. std::fs::write(&root_path, "{}\n").unwrap(); - std::fs::write(&child_path, "{}\n").unwrap(); - // Harness sidecar state under the artifact dir is not a rollout; the boot - // index/reconcile scans must not pick it up (they would warn "missing - // SessionMeta line" on every server start). - let harness_dir = dir - .path() - .join("session-artifacts") - .join(root_id.to_string()) - .join("harness"); + std::fs::write(&legacy_child_journal, "{}\n").unwrap(); + let harness_dir = root_dir.join("harness"); std::fs::create_dir_all(&harness_dir).unwrap(); std::fs::write(harness_dir.join("refinements.jsonl"), "{}\n").unwrap(); let paths = store.rollout_paths().expect("list"); assert!(paths.contains(&root_path)); - assert!(paths.contains(&child_path)); + assert!(paths.contains(&legacy_child_journal)); assert!(!paths.iter().any(|p| p.ends_with("refinements.jsonl"))); } @@ -1829,7 +2087,6 @@ async fn hydration_tolerates_truncated_final_line() { .expect("append after crash tail must remain loadable"); } -/// Trace: prompt-payload audit (R27, bug #43) /// Verifies: a session restored from its rollout re-advertises the skills /// currently on disk. The persisted `session_context` snapshot used to win /// wholesale, so an existing session kept advertising the skills text @@ -1911,16 +2168,19 @@ async fn versioned_reader_fails_closed_on_mid_file_damage_but_tolerates_crash_ta rollout_store .append_session_meta(&record) .expect("append session meta"); - // Damaged middle line, then a valid line after it. + // Damaged middle line, then a valid line after it. The append path must + // refuse to write onto a journal it cannot fully read, so both lines are + // injected raw to exercise the reader's fail-closed behavior. + let metadata = test_turn_record(record.id, TurnId::new()); + let valid_turn_line = serde_json::to_string( + &devo_core::turn_line_from_record(&metadata, chrono::Utc::now()) + .expect("build valid turn line"), + ) + .expect("serialize valid turn line"); write_raw_lines( &record.rollout_path, - &[r#"{"v":2,"kind":"nope"}"#.to_string()], + &[r#"{"v":2,"kind":"nope"}"#.to_string(), valid_turn_line], ); - let metadata = test_turn_record(record.id, TurnId::new()); - let turn = metadata; - rollout_store - .append_turn(&record, turn) - .expect("append turn"); let error = rollout_store .load_session_from_rollout(&record.rollout_path, &deps) @@ -1948,7 +2208,6 @@ async fn versioned_reader_fails_closed_on_mid_file_damage_but_tolerates_crash_ta assert_eq!(recovered.summary.session_id(), tail_record.id); } -/// Trace: L2-DES-RLM-001 /// Verifies that rollouts without a format version refuse resume. #[tokio::test] async fn unversioned_rollout_refuses_resume() { @@ -2060,8 +2319,10 @@ fn append_projects_events_into_event_log() { let seqs: Vec = rows.iter().map(|row| row.seq).collect(); assert_eq!(seqs, vec![1, 2, 3]); // Three physical rows written; watermark is the last line index. + let canonical_journal = + crate::journal_path::canonical_journal_key(&record.rollout_path).expect("canonical"); assert_eq!( - db.projection_watermark(&record.rollout_path) + db.projection_watermark(&canonical_journal) .expect("watermark"), Some(2) ); @@ -2127,7 +2388,6 @@ fn settings_line( }) } -/// Trace: L2-DES-CONV-002 /// Verifies: a field-level settings line wins over the whole-record /// SessionMeta value during replay (DD-4). #[test] @@ -2158,7 +2418,6 @@ fn session_settings_field_line_wins_over_session_meta_preset() { ); } -/// Trace: L2-DES-CONV-002 /// Verifies: a PermissionPreset line clears the explicit SandboxProfile /// override accumulated so far (approved patch-interaction rule). #[test] @@ -2184,7 +2443,6 @@ fn session_settings_preset_line_clears_explicit_sandbox_override() { assert_eq!(replay.sandbox_profile_override(), None); } -/// Trace: L2-DES-CONV-002 /// Verifies: an explicit SandboxProfile line written after the preset line /// survives replay as the effective override. #[test] @@ -2213,7 +2471,6 @@ fn session_settings_explicit_sandbox_survives_when_written_after_preset() { ); } -/// Trace: L2-DES-CONV-002 /// Verifies: model-family field lines override the corresponding record /// fields during replay. #[test] @@ -2251,7 +2508,6 @@ fn session_settings_model_fields_override_record() { ); } -/// Trace: L2-DES-CONV-002 /// Verifies: a settings line written through RolloutStore lands on disk as /// a v2 Internal SessionSettings record and compatibility-maps back to the /// same retained replay line. diff --git a/crates/server/src/provider_config.rs b/crates/server/src/provider_config.rs index 669d56b0..92105b38 100644 --- a/crates/server/src/provider_config.rs +++ b/crates/server/src/provider_config.rs @@ -522,8 +522,6 @@ mod tests { ); } - /// Trace: sparse Connection overlays omit builtin base_url; adapters must - /// still inherit https://api.deepseek.com instead of defaulting to OpenAI. #[test] fn sparse_deepseek_connection_inherits_builtin_base_url_for_adapter() { let sparse = ProviderConfigEntry { @@ -595,7 +593,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-005, L2-DES-MODEL-001 /// Verifies: server provider construction validates provider custom header configuration. #[tokio::test] async fn load_server_provider_rejects_invalid_custom_headers() { @@ -666,7 +663,6 @@ mod tests { ); } - /// Trace: L2-DES-AUTH-001 /// Verifies: oauth credentials are discoverable for builtins without an explicit providers.json binding. #[test] fn resolve_provider_credential_id_discovers_oauth_convention() { diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 1f9493ba..867b1eb3 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -136,7 +136,7 @@ pub(crate) mod refine_planner; mod session_actor; mod session_cache; mod session_deletion; -mod session_file_lock; +pub(crate) mod session_file_lock; mod session_interactive; mod session_title; mod skills; @@ -175,6 +175,9 @@ pub struct ServerRuntime { pub(crate) usage_ledger: UsageLedger, /// Per-session actor handles; map lock must not be held across await. sessions: Mutex>, + /// Synchronous lifecycle admission: active spawn/resume reservations and + /// in-progress delete trees. Guards hold reservations across the operation. + lifecycle_admission: Arc>, /// Interactive approval and user-input waits outside session actors. session_interactive: SessionInteractiveLanes, /// New-style (`subscription/*`) subscriptions keyed by subscription id @@ -421,6 +424,9 @@ impl ServerRuntime { goal_durable_store, usage_ledger, sessions: Mutex::new(HashMap::new()), + lifecycle_admission: Arc::new(std::sync::Mutex::new( + session_deletion::LifecycleAdmission::default(), + )), session_interactive: SessionInteractiveLanes::default(), event_subscriptions: Mutex::new(HashMap::new()), sessions_by_cwd_subscriptions: std::sync::atomic::AtomicUsize::new(0), diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index cfec2d8a..42946c70 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -123,7 +123,18 @@ impl ServerRuntime { }); native_session.fork_from_id = None; native_session.at_turn_id = None; - native_session.ephemeral = params.ephemeral; + // Persistent children of an ephemeral parent inherit the ephemeral + // policy. Creating a durable root journal for a parent that has no + // durable root would silently orphan a root session. + let child_ephemeral = params.ephemeral || parent_summary.ephemeral; + if params.ephemeral != child_ephemeral { + tracing::warn!( + parent_session_id = %parent_session_id, + child_session_id = %child_session_id, + "persistent child of an ephemeral parent inherits ephemeral policy" + ); + } + native_session.ephemeral = child_ephemeral; native_session.created_at = now; native_session.status = devo_protocol::native::session::SessionStatus::Idle; native_session.flags.clear(); @@ -157,37 +168,72 @@ impl ServerRuntime { updated_at: now, }; - let rollout_path = if params.ephemeral { - None + let lifecycle_root = match self + .resolve_lifecycle_root(parent_session_id, Some(&parent_summary)) + .await + { + Ok(root) => root, + Err(crate::runtime::session_deletion::LifecycleRootError::MissingEvidence { + .. + }) => { + return Err(ToolCallError::InvalidInput(format!( + "parent session has no lifecycle evidence: {parent_session_id}" + ))); + } + Err(error) => return Err(ToolCallError::InternalError(error.to_string())), + }; + let _lifecycle_admission = self + .begin_lifecycle_operation_for_root(lifecycle_root) + .map_err(ToolCallError::InvalidInput)?; + let (rollout_path, child_session_dir_path, session_file_lock) = if child_ephemeral { + (None, None, None) } else { - let parent_rollout = parent_handle.rollout_path().await.flatten(); - let mut invented = self + let root_journal = self.rollout_store.allocate_rollout_path(&lifecycle_root); + let child_session_dir = self .rollout_store - .invent_child_session_persistence( - parent_rollout.as_deref(), - &parent_session_id, - &child_session_id, - ) - .map_err(|error| ToolCallError::InternalError(error.to_string()))?; - invented.extras.collaboration_mode = Some(parent_summary.collaboration_mode); - invented.extras.permission_preset = parent_summary.permission_preset(); - invented.extras.agent_path = Some(agent_path.clone()); - invented.extras.agent_nickname = Some(nickname.clone()); + .child_session_dir(&lifecycle_root, &child_session_id); + std::fs::create_dir_all(&child_session_dir).map_err(|error| { + ToolCallError::InternalError(format!( + "create child session dir {}: {error}", + child_session_dir.display() + )) + })?; + if let Some(parent) = root_journal.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + ToolCallError::InternalError(format!( + "create root journal dir {}: {error}", + parent.display() + )) + })?; + } + if self.rollout_store.journal_is_closed(&root_journal) { + return Err(ToolCallError::InvalidInput(format!( + "parent root journal {} was deleted; refusing to spawn", + root_journal.display() + ))); + } + let lease = crate::runtime::session_file_lock::SessionFileLock::acquire(&root_journal) + .map_err(|error| { + ToolCallError::InternalError(format!( + "acquire root journal lock for {child_session_id}: {error}" + )) + })?; + let mut extras = crate::persistence::invent_session_persistence_extras(); + extras.collaboration_mode = Some(parent_summary.collaboration_mode); + extras.permission_preset = parent_summary.permission_preset(); + extras.agent_path = Some(agent_path.clone()); + extras.agent_nickname = Some(nickname.clone()); self.rollout_store - .append_session_meta_at( - &invented.rollout_path, - &native_session, - Some(invented.extras), - ) + .append_session_meta_at(&root_journal, &native_session, Some(extras)) .map_err(|error| ToolCallError::InternalError(error.to_string()))?; - Some(invented.rollout_path) + (Some(root_journal), Some(child_session_dir), Some(lease)) }; - // Child artifact directory (rollout parent dir): surfaced on the spawn - // result and subagent registry so RLM handles can address child files. - let child_session_dir = rollout_path + // Independent child artifact directory; surfaced on the spawn result + // and subagent registry so RLM handles can address child files. It is + // never the shared journal parent (`sessions/`). + let child_session_dir = child_session_dir_path .as_ref() - .and_then(|path| path.parent()) .map(|dir| dir.display().to_string()); let rollout_path_for_db = rollout_path.clone(); let mut core_session = runtime_context.new_session_state( @@ -230,6 +276,7 @@ impl ServerRuntime { summary.agent_path = Some(agent_path.clone()); summary.agent_nickname = Some(nickname.clone()); summary.agent_role = Some(role.clone()); + summary.session_dir = child_session_dir_path.clone(); summary.prompt_token_estimate = core_session.prompt_token_estimate; let child_session = RuntimeSession { runtime_context, @@ -258,7 +305,8 @@ impl ServerRuntime { turn_approval_cache: crate::execution::ApprovalGrantCache::default(), session_context_recorded: false, }; - let child_state = SessionActorState::from_runtime_session(child_session); + let mut child_state = SessionActorState::from_runtime_session(child_session); + child_state.session_file_lock = session_file_lock; let child_handle = self.insert_session_actor(child_state).await; self.agent_mailboxes .lock() @@ -490,9 +538,9 @@ impl ServerRuntime { .map(|turn| terminal_subagent_status_from_turn(turn.native.status, false)) .unwrap_or(SubagentStatus::Running); let session_dir = runtime_session - .rollout_path - .as_deref() - .and_then(|path| path.parent()) + .summary + .session_dir + .as_ref() .map(|dir| dir.display().to_string()); self.register_child_agent( parent_session_id, @@ -608,6 +656,13 @@ impl ServerRuntime { ToolCallError::InvalidInput(format!("session not found: {session_id}")) })?; let _state_change_guard = session_handle.lock_state_change().await; + if let Some(rollout_path) = session_handle.rollout_path().await.flatten() + && self.rollout_store.journal_is_closed(&rollout_path) + { + return Err(ToolCallError::InvalidInput(format!( + "session journal for {session_id} was deleted" + ))); + } let reservation = self .session_turn_reservation_snapshot(session_id) @@ -617,6 +672,31 @@ impl ServerRuntime { "failed to snapshot session reservation: {session_id}" )) })?; + // Resolve admission without round-tripping this actor's own mailbox: + // the reservation snapshot already carries the parent/root identity. + let lifecycle_root = if reservation.ephemeral { + match reservation.parent_session_id { + Some(parent_session_id) => self + .resolve_lifecycle_root(parent_session_id, None) + .await + .map_err(|error| ToolCallError::InternalError(error.to_string()))?, + None => session_id, + } + } else { + self.resolve_lifecycle_root(session_id, None) + .await + .map_err(|error| match error { + crate::runtime::session_deletion::LifecycleRootError::MissingEvidence { + .. + } => ToolCallError::InvalidInput(format!( + "session has no lifecycle evidence: {session_id}" + )), + other => ToolCallError::InternalError(other.to_string()), + })? + }; + let _lifecycle_admission = self + .begin_lifecycle_operation_for_root(lifecycle_root) + .map_err(ToolCallError::InvalidInput)?; if reservation.max_turns.is_some_and(|max_turns| { max_turns == 0 diff --git a/crates/server/src/runtime/approval_checkpoint.rs b/crates/server/src/runtime/approval_checkpoint.rs index 841e7a58..b6092484 100644 --- a/crates/server/src/runtime/approval_checkpoint.rs +++ b/crates/server/src/runtime/approval_checkpoint.rs @@ -9,7 +9,7 @@ use devo_core::tools::{ AdditionalSandboxPermissions, NetworkPermission, SandboxPermissionRequest, ToolPermissionRequest, }; -use devo_core::{ContentBlock, Message, Role, read_canonical_history}; +use devo_core::{ContentBlock, Message, Role}; use devo_protocol::CollaborationMode; use devo_protocol::native::ids::{SessionId, TurnId}; use devo_safety::ResourceKind; @@ -242,8 +242,9 @@ pub(crate) fn permission_request_from_messages( pub(crate) fn latest_approval_checkpoints_for_rollout( path: &Path, + session_id: &devo_protocol::native::ids::SessionId, ) -> std::collections::HashMap { - read_canonical_history(path) + devo_core::read_canonical_history_for_session(path, session_id) .map(|history| history.approval_checkpoints) .unwrap_or_default() } diff --git a/crates/server/src/runtime/command_exec.rs b/crates/server/src/runtime/command_exec.rs index 64c11392..461c3fdd 100644 --- a/crates/server/src/runtime/command_exec.rs +++ b/crates/server/src/runtime/command_exec.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use base64::Engine; use base64::engine::general_purpose::STANDARD; use devo_core::tools::AgentToolCoordinator; +use devo_core::tools::unified_exec::process::SandboxExecutionOptions; use devo_core::tools::unified_exec::process::TerminalSize; use devo_core::tools::unified_exec::process::UnifiedExecProcess; use devo_core::tools::unified_exec::store::ProcessStore; @@ -91,6 +92,18 @@ struct CommandExecSession { command: String, } +/// Execution directory and sandbox inputs resolved for one command/exec spawn. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandExecContext { + /// Directory the client's terminal process starts in. A client may choose + /// this independently of the session workspace. + cwd: PathBuf, + /// Trusted session workspace used to resolve sandbox config and profile + /// roots. `None` for intentional sessionless execution. + sandbox_workspace: Option, + sandbox_profile: Option, +} + type CommandExecRuntimeResult = Result; impl CommandExecManager { @@ -238,8 +251,7 @@ impl CommandExecManager { runtime: Arc, connection_id: u64, params: CommandExecParams, - cwd: PathBuf, - sandbox_profile: Option, + context: CommandExecContext, ) -> CommandExecRuntimeResult { if params.process_id.trim().is_empty() { return Err(( @@ -289,12 +301,18 @@ impl CommandExecManager { )); } + let CommandExecContext { + cwd, + sandbox_workspace, + sandbox_profile, + } = context; let spawn_result = spawn_command_exec_process( store_process_id, params.program, cwd, params.size, sandbox_profile, + sandbox_workspace, ) .await; let (process, output_rx) = match spawn_result { @@ -975,16 +993,13 @@ impl ServerRuntime { request_id: serde_json::Value, params: CommandExecParams, ) -> serde_json::Value { - let cwd = match self - .command_exec_cwd(params.session_id, params.cwd.clone()) + let context = match self + .command_exec_context(params.session_id, params.cwd.clone()) .await { - Ok(cwd) => cwd, + Ok(context) => context, Err((code, message)) => return self.error_response(request_id, code, message), }; - let sandbox_profile = self - .command_exec_sandbox_profile(params.session_id, &cwd) - .await; let command_exec_event_types = HashSet::from([ "command/exec/outputDelta".to_string(), "command/exec/exited".to_string(), @@ -1004,13 +1019,7 @@ impl ServerRuntime { } match self .command_exec_manager - .start( - Arc::clone(self), - connection_id, - params, - cwd, - sandbox_profile, - ) + .start(Arc::clone(self), connection_id, params, context) .await { Ok(result) => success_response(request_id, result), @@ -1018,26 +1027,6 @@ impl ServerRuntime { } } - /// The session's sandbox profile for a command/exec spawn, or `None` for - /// sessionless execs and sessions without a profile. Resolved through the - /// session actor so PTY spawns enforce the same profile as tool-run shells. - /// Temporarily overridden: the sandbox is disabled repo-wide, so terminals - /// spawn bare even when the session carries a profile. - async fn command_exec_sandbox_profile( - &self, - session_id: Option, - cwd: &std::path::Path, - ) -> Option { - if devo_sandbox::SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED { - return None; - } - let session = self.sessions.lock().await.get(&session_id?).cloned()?; - session - .shell_exec_context(cwd.to_path_buf()) - .await - .and_then(|context| context.sandbox_profile) - } - pub(crate) async fn handle_command_exec_write( &self, connection_id: u64, @@ -1112,19 +1101,33 @@ impl ServerRuntime { } } - async fn command_exec_cwd( + /// Resolve the execution cwd and sandbox inputs for one command/exec spawn. + /// + /// An explicit session must exist even when the client supplies `cwd`; + /// otherwise an unknown session id could silently run without that + /// session's sandbox profile. Intentional sessionless requests (no + /// `session_id`) keep their own contract and require an explicit cwd. + /// + /// The client may choose an execution cwd that differs from the session + /// workspace. Sandbox config and profile roots are always resolved from + /// the trusted session workspace, never from the client cwd. + async fn command_exec_context( &self, session_id: Option, - cwd: Option, - ) -> CommandExecRuntimeResult { - if let Some(cwd) = cwd { - return Ok(cwd); - } + requested_cwd: Option, + ) -> CommandExecRuntimeResult { let Some(session_id) = session_id else { - return Err(( - ProtocolErrorCode::InvalidParams, - "command/exec cwd is required when session_id is omitted".to_string(), - )); + let Some(cwd) = requested_cwd else { + return Err(( + ProtocolErrorCode::InvalidParams, + "command/exec cwd is required when session_id is omitted".to_string(), + )); + }; + return Ok(CommandExecContext { + cwd, + sandbox_workspace: None, + sandbox_profile: None, + }); }; let session = self .sessions @@ -1144,7 +1147,16 @@ impl ServerRuntime { "failed to read session summary".to_string(), )); }; - Ok(summary.cwd.clone()) + let trusted_workspace = summary.cwd.clone(); + let sandbox_profile = session + .shell_exec_context(trusted_workspace.clone()) + .await + .and_then(|context| context.sandbox_profile); + Ok(CommandExecContext { + cwd: requested_cwd.unwrap_or_else(|| trusted_workspace.clone()), + sandbox_workspace: Some(trusted_workspace), + sandbox_profile, + }) } /// Persist a completed user `!` shell task into the session rollout so @@ -1219,31 +1231,42 @@ async fn spawn_command_exec_process( cwd: PathBuf, size: Option, sandbox_profile: Option, + sandbox_workspace: Option, ) -> Result<(UnifiedExecProcess, broadcast::Receiver>), String> { match program { CommandExecProgram::OneShot { command } => { if command.trim().is_empty() { return Err("command/exec one-shot command must not be empty".to_string()); } - UnifiedExecProcess::spawn_with_sandbox( + UnifiedExecProcess::spawn_with_sandbox_overlay( store_process_id, &command, &cwd, /*shell*/ None, /*login*/ true, /*tty*/ true, - sandbox_profile, + SandboxExecutionOptions { + output_capture: None, + sandbox_profile, + sandbox_overlay: None, + sandbox_workspace, + }, ) .await } CommandExecProgram::InteractiveShell => { - UnifiedExecProcess::spawn_interactive_shell( + UnifiedExecProcess::spawn_interactive_shell_with_overlay( store_process_id, &cwd, /*shell*/ None, /*login*/ true, size.map(protocol_terminal_size), - sandbox_profile, + SandboxExecutionOptions { + output_capture: None, + sandbox_profile, + sandbox_overlay: None, + sandbox_workspace, + }, ) .await } diff --git a/crates/server/src/runtime/command_exec_tests.rs b/crates/server/src/runtime/command_exec_tests.rs index 14ac3065..e0fbb453 100644 --- a/crates/server/src/runtime/command_exec_tests.rs +++ b/crates/server/src/runtime/command_exec_tests.rs @@ -1,5 +1,10 @@ use super::*; +use std::fs; +use std::sync::Arc; + +use crate::test_support::TestRuntime; use pretty_assertions::assert_eq; +use serde_json::json; #[tokio::test] async fn terminal_snapshots_wait_for_retained_output_and_do_not_duplicate_live_entries() { @@ -70,3 +75,103 @@ async fn terminal_snapshots_wait_for_retained_output_and_do_not_duplicate_live_e vec![expected] ); } + +async fn create_session(runtime: &Arc, cwd: &std::path::Path) -> SessionId { + let response = runtime + .handle_native_session_new( + 1, + json!(1), + json!({ + "cwd": cwd, + "idempotencyKey": format!("exec-context-{}", cwd.display()), + }), + ) + .await; + let native = serde_json::from_value::< + crate::SuccessResponse, + >(response.clone()) + .unwrap_or_else(|error| panic!("session/new result: {error}; response={response}")); + SessionId::from(native.result.session.id.as_str()) +} + +#[tokio::test] +async fn unknown_explicit_session_is_not_found_even_with_cwd() { + let home = tempfile::tempdir().expect("tempdir"); + let runtime = TestRuntime::noop().runtime(home.path()); + let execution = home.path().join("execution"); + fs::create_dir_all(&execution).expect("create execution cwd"); + + let response = runtime + .handle_command_exec_translated( + 1, + json!(1), + CommandExecParams { + session_id: Some(SessionId::from_string("ses_missing".to_string())), + process_id: "unknown-session".to_string(), + cwd: Some(execution), + program: CommandExecProgram::OneShot { + command: "echo must-not-run".to_string(), + }, + size: None, + }, + ) + .await; + + assert_eq!(response["error"]["code"], json!("SessionNotFound")); +} + +#[tokio::test] +async fn sessionless_command_exec_requires_explicit_cwd() { + let home = tempfile::tempdir().expect("tempdir"); + let runtime = TestRuntime::noop().runtime(home.path()); + + let error = runtime + .command_exec_context(None, None) + .await + .expect_err("sessionless exec without cwd must fail"); + + assert_eq!(error.0, ProtocolErrorCode::InvalidParams); +} + +#[tokio::test] +async fn sessionless_command_exec_keeps_explicit_cwd_without_sandbox_workspace() { + let home = tempfile::tempdir().expect("tempdir"); + let runtime = TestRuntime::noop().runtime(home.path()); + let execution = home.path().join("execution"); + fs::create_dir_all(&execution).expect("create execution cwd"); + + let context = runtime + .command_exec_context(None, Some(execution.clone())) + .await + .expect("sessionless context"); + + assert_eq!( + context, + CommandExecContext { + cwd: execution, + sandbox_workspace: None, + sandbox_profile: None, + } + ); +} + +#[tokio::test] +async fn explicit_session_resolves_sandbox_workspace_from_session() { + let home = tempfile::tempdir().expect("tempdir"); + let workspace = home.path().join("workspace"); + let execution = home.path().join("execution"); + fs::create_dir_all(&workspace).expect("create session workspace"); + fs::create_dir_all(&execution).expect("create execution cwd"); + let runtime = TestRuntime::noop().runtime(home.path()); + let session_id = create_session(&runtime, &workspace).await; + + let context = runtime + .command_exec_context(Some(session_id), Some(execution.clone())) + .await + .expect("session context"); + + // Execution may use the client cwd, but sandbox roots remain tied to the + // trusted session workspace. + assert_eq!(context.cwd, execution); + assert_eq!(context.sandbox_workspace, Some(workspace)); +} diff --git a/crates/server/src/runtime/compact_host.rs b/crates/server/src/runtime/compact_host.rs index 0e92b2d1..71aa8875 100644 --- a/crates/server/src/runtime/compact_host.rs +++ b/crates/server/src/runtime/compact_host.rs @@ -192,7 +192,6 @@ mod tests { SessionId::from_string(format!("ses_{}", Uuid::new_v4())) } - /// Trace: L2-DES-CONTEXT-002, L1-REQ-RLM-001 /// Verifies: compact.status shape exposes tokens, context_window, percent, scheduled. #[test] fn status_shape_from_occupancy() { @@ -217,7 +216,6 @@ mod tests { assert_eq!(wire["scheduled"], false); } - /// Trace: L2-DES-CONTEXT-002 /// Verifies: after compaction before next usage, tokens/percent are null. #[test] fn status_nulls_when_usage_unknown() { @@ -232,7 +230,6 @@ mod tests { assert!(wire["percent"].is_null()); } - /// Trace: L2-DES-CONTEXT-002, L1-REQ-RLM-001 /// Verifies: compact.run sets pending only while a turn is active. #[test] fn schedule_requires_active_turn_and_sets_flag() { @@ -252,7 +249,6 @@ mod tests { assert!(!peek_pending_compact(&id)); } - /// Trace: L2-DES-CONTEXT-002 /// Verifies: last-write-wins pending + abort clear. #[test] fn schedule_last_write_wins_and_clear() { diff --git a/crates/server/src/runtime/connection_tests.rs b/crates/server/src/runtime/connection_tests.rs index 01aa1b30..7ab07db0 100644 --- a/crates/server/src/runtime/connection_tests.rs +++ b/crates/server/src/runtime/connection_tests.rs @@ -4550,6 +4550,7 @@ async fn metadata_update_auto_refine_settings_reach_live_actor() -> Result<()> { .runtime .native_session_snapshot(session_id) .await + .expect("session snapshot result") .expect("session snapshot"); assert_eq!(snapshot.settings.auto_refine_enabled, Some(true)); assert_eq!(snapshot.settings.auto_refine_turn_interval, Some(2)); @@ -6374,21 +6375,24 @@ async fn native_task_start_agent_spawns_child_session() -> Result<()> { let child_rollout = child_index .rollout_path .expect("durable child must have rollout_path"); - let rel = child_rollout - .strip_prefix(env.cwd()) - .expect("child under data root"); - let parts: Vec<_> = rel - .iter() - .map(|s| s.to_string_lossy().into_owned()) - .collect(); - assert_eq!(parts[0], "session-artifacts"); - assert_eq!(parts[1], session_id.to_string()); + let root_journal = env + .cwd() + .join("sessions") + .join(format!("{session_id}.jsonl")); + assert_eq!(child_rollout, root_journal); + let child_dir = env + .cwd() + .join("session-artifacts") + .join(session_id.to_string()) + .join("agents") + .join(child_session_id.to_string()); + assert!(child_dir.is_dir(), "child artifact dir must exist"); assert!( - parts[2].starts_with("sub-") && parts[2].len() == 12, - "expected sub-xxxxxxxx, got {}", - parts[2] + !child_dir + .with_file_name(format!("{child_session_id}.jsonl")) + .exists(), + "child must not have its own journal" ); - assert_eq!(parts[3], format!("{child_session_id}.jsonl")); Ok(()) } diff --git a/crates/server/src/runtime/goal_accounting.rs b/crates/server/src/runtime/goal_accounting.rs index d4cea09a..8683a07f 100644 --- a/crates/server/src/runtime/goal_accounting.rs +++ b/crates/server/src/runtime/goal_accounting.rs @@ -127,7 +127,6 @@ mod tests { #[test] fn goal_token_delta_counts_non_cached_input_and_output() { - // Trace: L2-DES-GOAL-001 let usage = devo_protocol::native::usage::TurnUsage { query: devo_protocol::native::usage::UsageTotals { total_tokens: 150, @@ -145,7 +144,6 @@ mod tests { #[test] fn apply_goal_usage_delta_leaves_budget_limit_for_wrap_up_turn() { - // Trace: L2-DES-GOAL-001 let mut goal = goal_with_budget(100); goal.usage.tokens_used = 75; diff --git a/crates/server/src/runtime/goal_continuation.rs b/crates/server/src/runtime/goal_continuation.rs index 36063d6f..f335eb8a 100644 --- a/crates/server/src/runtime/goal_continuation.rs +++ b/crates/server/src/runtime/goal_continuation.rs @@ -731,7 +731,6 @@ mod tests { #[test] fn failed_turn_after_goal_update_suppresses_continuation() { - // Trace: L2-DES-GOAL-001 let goal_updated_at = Utc::now(); let latest_turn = turn( TurnStatus::Failed, @@ -746,7 +745,6 @@ mod tests { #[test] fn failed_turn_before_goal_update_allows_manual_resume() { - // Trace: L2-DES-GOAL-001 let latest_turn_completed_at = Utc::now(); let goal_updated_at = latest_turn_completed_at + chrono::Duration::seconds(1); let latest_turn = turn(TurnStatus::Failed, latest_turn_completed_at); @@ -759,7 +757,6 @@ mod tests { #[test] fn completed_turn_does_not_suppress_continuation() { - // Trace: L2-DES-GOAL-001 let goal_updated_at = Utc::now(); let latest_turn = turn( TurnStatus::Completed, @@ -774,7 +771,6 @@ mod tests { #[test] fn classifies_tool_call_adjacency_failure() { - // Trace: L2-DES-GOAL-001 let message = "Invalid status code: 400 Bad Request; response body: assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'"; assert_eq!( @@ -786,7 +782,6 @@ mod tests { #[test] fn classifies_provider_parameter_failure() { - // Trace: L2-DES-GOAL-001 assert_eq!( classify_goal_failure(Some("400 BAD REQUEST invalid_request_error")), GoalFailureClass::ProviderParameter @@ -795,7 +790,6 @@ mod tests { #[test] fn classifies_authentication_and_permission_failures() { - // Trace: L2-DES-GOAL-001 assert_eq!( classify_goal_failure(Some("401 unauthorized invalid api key")), GoalFailureClass::Authentication diff --git a/crates/server/src/runtime/handlers/acp/session_support.rs b/crates/server/src/runtime/handlers/acp/session_support.rs index 72f2885d..ca53fae4 100644 --- a/crates/server/src/runtime/handlers/acp/session_support.rs +++ b/crates/server/src/runtime/handlers/acp/session_support.rs @@ -103,16 +103,18 @@ impl ServerRuntime { } session_arc.update_summary(updated_summary.clone()).await; - if let Some(rollout_path) = snapshot.rollout_path.as_ref() - && let Err(error) = self.rollout_store.append_session_meta_at( + if let Some(rollout_path) = snapshot.rollout_path.as_ref() { + let _lease = crate::runtime::session_file_lock::SessionFileLock::acquire(rollout_path) + .map_err(|error| format!("lock ACP session journal: {error}"))?; + if let Err(error) = self.rollout_store.append_session_meta_at( rollout_path, &updated_summary.native, /*extras*/ None, - ) - { - return Err(format!( - "failed to persist ACP session additional directories: {error}" - )); + ) { + return Err(format!( + "failed to persist ACP session additional directories: {error}" + )); + } } if !updated_summary.ephemeral diff --git a/crates/server/src/runtime/handlers/acp_config_options.rs b/crates/server/src/runtime/handlers/acp_config_options.rs index 96b7a883..4b567404 100644 --- a/crates/server/src/runtime/handlers/acp_config_options.rs +++ b/crates/server/src/runtime/handlers/acp_config_options.rs @@ -129,14 +129,17 @@ impl ServerRuntime { "session actor unavailable".to_string(), ) })?; - if let Some(rollout_path) = updated_snapshot.rollout_path.as_ref() - && let Err(error) = self.rollout_store.append_session_meta_at( + if let Some(rollout_path) = updated_snapshot.rollout_path.as_ref() { + let _lease = + crate::runtime::session_file_lock::SessionFileLock::acquire(rollout_path) + .map_err(|error| (AcpErrorCode::ServerError, error.to_string()))?; + if let Err(error) = self.rollout_store.append_session_meta_at( rollout_path, &updated_snapshot.summary.native, /*extras*/ None, - ) - { - return Err((AcpErrorCode::ServerError, error.to_string())); + ) { + return Err((AcpErrorCode::ServerError, error.to_string())); + } } ( @@ -202,14 +205,17 @@ impl ServerRuntime { "session actor unavailable".to_string(), ) })?; - if let Some(rollout_path) = updated_snapshot.rollout_path.as_ref() - && let Err(error) = self.rollout_store.append_session_meta_at( + if let Some(rollout_path) = updated_snapshot.rollout_path.as_ref() { + let _lease = + crate::runtime::session_file_lock::SessionFileLock::acquire(rollout_path) + .map_err(|error| (AcpErrorCode::ServerError, error.to_string()))?; + if let Err(error) = self.rollout_store.append_session_meta_at( rollout_path, &updated_snapshot.summary.native, /*extras*/ None, - ) - { - return Err((AcpErrorCode::ServerError, error.to_string())); + ) { + return Err((AcpErrorCode::ServerError, error.to_string())); + } } ( diff --git a/crates/server/src/runtime/handlers/export.rs b/crates/server/src/runtime/handlers/export.rs index bdba385b..a00ed7a6 100644 --- a/crates/server/src/runtime/handlers/export.rs +++ b/crates/server/src/runtime/handlers/export.rs @@ -28,19 +28,27 @@ impl ServerRuntime { } }; let session_id = params.session_id; - let rollout_path = self + let indexed_path = self .deps .db .get_session_index(&session_id) .ok() .flatten() - .and_then(|index| index.rollout_path) - .or_else(|| { - self.rollout_store - .find_rollout_by_session_id(&session_id) - .ok() - .flatten() - }); + .and_then(|index| index.rollout_path); + let rollout_path = match self.rollout_store.resolve_session_journal( + &session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::Live, + ) { + Ok(path) => path, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to resolve session journal: {error}"), + ); + } + }; let Some(rollout_path) = rollout_path else { return self.error_response( request_id, @@ -97,12 +105,22 @@ impl ServerRuntime { format!("failed to create export parent: {error}"), ); } + let scoped_jsonl = match read_session_scoped_jsonl(&rollout_path, &session_id) { + Ok(jsonl) => jsonl, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read session export: {error}"), + ); + } + }; let result = match params.format { devo_protocol::native::rpc_schedule::SessionExportFormat::Jsonl => { - std::fs::copy(&rollout_path, &out_path).map(|_| ()) + std::fs::write(&out_path, scoped_jsonl) } devo_protocol::native::rpc_schedule::SessionExportFormat::Html => { - export_rollout_as_html(&rollout_path, &out_path, session_id.as_str()) + export_rollout_as_html(&scoped_jsonl, &out_path, session_id.as_str()) } }; if let Err(error) = result { @@ -201,21 +219,27 @@ impl ServerRuntime { ); } }; - let dest = match self + let indexed_path = self .deps .db .get_session_index(&session_id) .ok() .flatten() - .and_then(|index| index.rollout_path) - .or_else(|| { - self.rollout_store - .find_rollout_by_session_id(&session_id) - .ok() - .flatten() - }) { - Some(path) => path, - None => { + .and_then(|index| index.rollout_path); + let dest = match self.rollout_store.resolve_session_journal( + &session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::Live, + ) { + Ok(Some(path)) => path, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to resolve imported session journal: {error}"), + ); + } + Ok(None) => { return self.error_response( request_id, ProtocolErrorCode::InternalError, @@ -228,7 +252,20 @@ impl ServerRuntime { self.runtime_arc() .evict_parent_session_cascade(session_id) .await; - if let Err(error) = append_import_jsonl(&import_jsonl, &dest, session_id.as_str()) { + let _lease = match crate::runtime::session_file_lock::SessionFileLock::acquire(&dest) { + Ok(lease) => lease, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::SessionLocked, + format!("cannot lock import destination: {error}"), + ); + } + }; + let import_result = self.rollout_store.with_journal_state(&dest, || { + append_import_jsonl(&import_jsonl, &dest, session_id.as_str()) + }); + if let Err(error) = import_result { return self.error_response( request_id, ProtocolErrorCode::InternalError, @@ -287,12 +324,27 @@ fn normalize_lexically(path: &Path) -> PathBuf { out } -fn export_rollout_as_html( +fn read_session_scoped_jsonl( rollout_path: &Path, - out_path: &Path, - session_id: &str, -) -> std::io::Result<()> { + session_id: &devo_protocol::native::ids::SessionId, +) -> anyhow::Result { let raw = std::fs::read_to_string(rollout_path)?; + let mut scoped = String::new(); + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let parsed = devo_core::parse_rollout_line(line) + .with_context(|| format!("parse export line {}", index + 1))?; + if parsed.session_id() == Some(*session_id) { + scoped.push_str(line); + scoped.push('\n'); + } + } + Ok(scoped) +} + +fn export_rollout_as_html(raw: &str, out_path: &Path, session_id: &str) -> std::io::Result<()> { let mut body = String::new(); for line in raw.lines() { if line.trim().is_empty() { @@ -342,6 +394,14 @@ fn validate_import_jsonl(source_raw: &str) -> anyhow::Result<()> { fn append_import_jsonl(source_raw: &str, dest: &Path, dest_session_id: &str) -> anyhow::Result<()> { validate_import_jsonl(source_raw)?; + let session_meta_count = source_raw + .lines() + .filter(|line| is_session_meta_line(line.trim())) + .count(); + anyhow::ensure!( + session_meta_count <= 1, + "session/import source contains multiple sessions; export a single session first" + ); let old_session_id = source_raw.lines().find_map(extract_session_meta_id); let mut dest_file = std::fs::OpenOptions::new() .create(true) diff --git a/crates/server/src/runtime/handlers/goal.rs b/crates/server/src/runtime/handlers/goal.rs index c07c905a..0f4181f7 100644 --- a/crates/server/src/runtime/handlers/goal.rs +++ b/crates/server/src/runtime/handlers/goal.rs @@ -282,7 +282,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-GOAL-001 /// Verifies: kernel `goal.create` refuses to displace a pending goal /// (active/paused/blocked/limited) — the SKILL contract — and replaces a /// terminal goal. @@ -416,7 +415,6 @@ mod tests { #[test] fn set_status_can_update_terminal_goal() { - // Trace: L2-DES-GOAL-001 let mut store = GoalStore::new(); let params = GoalCreateParams { session_id: SessionId::new(), diff --git a/crates/server/src/runtime/handlers/history.rs b/crates/server/src/runtime/handlers/history.rs index fd9899f6..7434994d 100644 --- a/crates/server/src/runtime/handlers/history.rs +++ b/crates/server/src/runtime/handlers/history.rs @@ -125,12 +125,22 @@ impl ServerRuntime { request_id: &serde_json::Value, session_id: devo_protocol::native::ids::SessionId, ) -> Result, serde_json::Value> { - let Some(rollout_path) = self.resolve_rollout_path(&session_id).await else { - return Err(self.error_response( - request_id.clone(), - ProtocolErrorCode::SessionNotFound, - "session does not exist", - )); + let rollout_path = match self.resolve_rollout_path(&session_id).await { + Ok(Some(path)) => path, + Ok(None) => { + return Err(self.error_response( + request_id.clone(), + ProtocolErrorCode::SessionNotFound, + "session does not exist", + )); + } + Err(error) => { + return Err(self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + error, + )); + } }; self.history_cache .load(session_id, rollout_path) @@ -151,26 +161,29 @@ impl ServerRuntime { pub(crate) async fn resolve_rollout_path( &self, session_id: &devo_protocol::native::ids::SessionId, - ) -> Option { - if let Ok(Some(index)) = self.deps.db.get_session_index(session_id) - && let Some(path) = index.rollout_path - { - return Some(path); - } - if let Some(path) = self - .rollout_store - .find_rollout_by_session_id(session_id) + ) -> Result, String> { + let indexed_path = self + .deps + .db + .get_session_index(session_id) .ok() .flatten() - { - return Some(path); + .and_then(|index| index.rollout_path); + match self.rollout_store.resolve_session_journal( + session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::Live, + ) { + Ok(Some(path)) => return Ok(Some(path)), + Ok(None) => {} + Err(error) => return Err(format!("resolve session journal: {error}")), } if let Some(handle) = self.session(*session_id).await && let Some(path) = handle.rollout_path().await.flatten() { - return Some(path); + return Ok(Some(path)); } - None + Ok(None) } } diff --git a/crates/server/src/runtime/handlers/message_edit.rs b/crates/server/src/runtime/handlers/message_edit.rs index 0a100566..df053cc7 100644 --- a/crates/server/src/runtime/handlers/message_edit.rs +++ b/crates/server/src/runtime/handlers/message_edit.rs @@ -54,10 +54,20 @@ impl ServerRuntime { let target_item_id = devo_protocol::ItemId::from(params.item_id.as_str()); if params.expected_revision > 0 { - match self + let revision = match self .native_item_revision(legacy_session_id, params.item_id.as_str()) .await { + Ok(revision) => revision, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error, + ); + } + }; + match revision { Some(revision) if revision == params.expected_revision => {} Some(_) => { return self.error_response( @@ -158,26 +168,36 @@ impl ServerRuntime { } /// Current revision of one item in the rollout-backed canonical history. - async fn native_item_revision(&self, session_id: SessionId, item_id: &str) -> Option { - let rollout_path = self + async fn native_item_revision( + &self, + session_id: SessionId, + item_id: &str, + ) -> Result, String> { + let indexed_path = self .deps .db .get_session_index(&session_id) .ok() .flatten() - .and_then(|index| index.rollout_path) - .or_else(|| { - self.rollout_store - .find_rollout_by_session_id(&session_id) - .ok() - .flatten() - })?; - let history = devo_core::read_canonical_history(&rollout_path).ok()?; - history + .and_then(|index| index.rollout_path); + let Some(rollout_path) = self + .rollout_store + .resolve_session_journal( + &session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::Live, + ) + .map_err(|error| format!("resolve session journal: {error}"))? + else { + return Ok(None); + }; + let history = devo_core::read_canonical_history_for_session(&rollout_path, &session_id) + .map_err(|error| format!("read session history: {error}"))?; + Ok(history .items .iter() .find(|item| item.id.as_str() == item_id) - .map(|item| item.revision) + .map(|item| item.revision)) } /// Interrupts the session's active turn before message edit takes the diff --git a/crates/server/src/runtime/handlers/message_edit_restore.rs b/crates/server/src/runtime/handlers/message_edit_restore.rs index 2241f006..e5cd4f94 100644 --- a/crates/server/src/runtime/handlers/message_edit_restore.rs +++ b/crates/server/src/runtime/handlers/message_edit_restore.rs @@ -505,7 +505,6 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; - /// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: safe restore preserves diverged file content and reports a skipped outcome. #[tokio::test] async fn safe_restore_skips_diverged_added_file() { @@ -536,7 +535,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: safe restore removes an unchanged file that was added by the superseded turn. #[tokio::test] async fn safe_restore_removes_unchanged_added_file() { @@ -564,7 +562,6 @@ mod tests { assert!(!path.exists()); } - /// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: safe restore restores an unchanged modified file to its pre-turn content. #[tokio::test] async fn safe_restore_restores_unchanged_modified_file() { @@ -596,7 +593,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-003 /// Verifies: restore candidate discovery reads structured file metadata from edit tool results. #[test] fn discovers_structured_file_change_candidates() { @@ -633,7 +629,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: update metadata with post content plus a per-file diff is restorable. #[tokio::test] async fn discovers_and_restores_modified_file_from_reverse_diff() { @@ -679,7 +674,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: move metadata with target content plus a diff can be reversed safely. #[tokio::test] async fn discovers_and_restores_moved_file_from_reverse_diff() { @@ -734,7 +728,6 @@ mod tests { assert!(!target_path.exists()); } - /// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: safe restore preserves a moved target that diverged after the superseded turn. #[tokio::test] async fn safe_restore_skips_diverged_moved_file() { diff --git a/crates/server/src/runtime/handlers/native_surface.rs b/crates/server/src/runtime/handlers/native_surface.rs index ebd02cd6..6ad12d54 100644 --- a/crates/server/src/runtime/handlers/native_surface.rs +++ b/crates/server/src/runtime/handlers/native_surface.rs @@ -107,16 +107,28 @@ impl ServerRuntime { }; session.cwd = cwd.clone(); session.version = session.version.saturating_add(1); - if let Some(rollout_path) = handle.rollout_path().await.flatten() - && let Err(error) = + if let Some(rollout_path) = handle.rollout_path().await.flatten() { + let _lease = + match crate::runtime::session_file_lock::SessionFileLock::acquire(&rollout_path) { + Ok(lease) => lease, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::SessionLocked, + format!("cannot lock session cwd journal: {error}"), + ); + } + }; + if let Err(error) = self.rollout_store .append_session_meta_at(&rollout_path, &session, None) - { - return self.error_response( - request_id, - ProtocolErrorCode::InternalError, - format!("failed to persist session cwd change: {error}"), - ); + { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to persist session cwd change: {error}"), + ); + } } if let Some(summary) = handle.summary().await { self.persist_session_summary_if_persistent(params.session_id, &summary) @@ -175,18 +187,39 @@ impl ServerRuntime { "session does not exist", ); }; - if let Some(rollout_path) = handle.rollout_path().await.flatten() - && let Err(error) = + let summary = handle.summary().await; + let extras = summary.as_ref().map(|summary| { + let mut extras = crate::persistence::invent_session_persistence_extras(); + extras.collaboration_mode = Some(summary.collaboration_mode); + extras.permission_preset = summary.permission_preset(); + extras.agent_path = summary.agent_path.clone(); + extras.agent_nickname = summary.agent_nickname.clone(); + extras + }); + if let Some(rollout_path) = handle.rollout_path().await.flatten() { + let _lease = + match crate::runtime::session_file_lock::SessionFileLock::acquire(&rollout_path) { + Ok(lease) => lease, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::SessionLocked, + format!("cannot lock session archive journal: {error}"), + ); + } + }; + if let Err(error) = self.rollout_store - .append_session_meta_at(&rollout_path, &session, None) - { - return self.error_response( - request_id, - ProtocolErrorCode::InternalError, - format!("failed to persist session archive: {error}"), - ); + .append_session_meta_at(&rollout_path, &session, extras) + { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to persist session archive: {error}"), + ); + } } - if let Some(summary) = handle.summary().await { + if let Some(summary) = summary { self.persist_session_summary_if_persistent(params.session_id, &summary) .await; } diff --git a/crates/server/src/runtime/handlers/rollback.rs b/crates/server/src/runtime/handlers/rollback.rs index d32c75cd..73e4ecda 100644 --- a/crates/server/src/runtime/handlers/rollback.rs +++ b/crates/server/src/runtime/handlers/rollback.rs @@ -125,7 +125,7 @@ impl ServerRuntime { let rollout_store = self.rollout_store.clone(); let path = path.clone(); let checkpoints = tokio::task::spawn_blocking(move || { - rollout_store.workspace_checkpoints_at(&path) + rollout_store.workspace_checkpoints_at(&path, &session_id) }) .await; match checkpoints { @@ -664,9 +664,10 @@ impl ServerRuntime { // exactly like session/tree/navigate does. if let Some(rollout_path) = rollout_path.as_ref() { let retained_tip = rebuilt.persisted_turn_items.last().map(|item| item.item_id); - let epoch = devo_core::read_canonical_history(rollout_path) - .map(|history| history.leaf_epoch.saturating_add(1)) - .unwrap_or(1); + let epoch = + devo_core::read_canonical_history_for_session(rollout_path, &plan.session_id) + .map(|history| history.leaf_epoch.saturating_add(1)) + .unwrap_or(1); if let Err(error) = self.rollout_store.append_session_leaf_at( rollout_path, plan.session_id, diff --git a/crates/server/src/runtime/handlers/schedule.rs b/crates/server/src/runtime/handlers/schedule.rs index 1d0f1a05..ef9ab991 100644 --- a/crates/server/src/runtime/handlers/schedule.rs +++ b/crates/server/src/runtime/handlers/schedule.rs @@ -649,8 +649,9 @@ impl ServerRuntime { let rollout_path = self .resolve_rollout_path(&session_id) .await + .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("session has no rollout path"))?; - let history = devo_core::read_canonical_history(&rollout_path)?; + let history = devo_core::read_canonical_history_for_session(&rollout_path, &session_id)?; Ok(history.items.iter().any(|envelope| { matches!( &envelope.item, diff --git a/crates/server/src/runtime/handlers/schedule_tests.rs b/crates/server/src/runtime/handlers/schedule_tests.rs index 0e341c7a..1268b0fc 100644 --- a/crates/server/src/runtime/handlers/schedule_tests.rs +++ b/crates/server/src/runtime/handlers/schedule_tests.rs @@ -124,7 +124,7 @@ async fn scheduled_message_count( }) .count(); } - if let Some(rollout_path) = runtime.resolve_rollout_path(&session_id).await { + if let Ok(Some(rollout_path)) = runtime.resolve_rollout_path(&session_id).await { let history = devo_core::read_canonical_history(&rollout_path)?; count += history .items diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 551855e5..7f362263 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -233,6 +233,18 @@ impl ServerRuntime { Default::default(), ); summary.settings.effective_context_window = Some(applied_compaction_limit); + summary.session_dir = invented_persistence + .as_ref() + .map(|_| self.rollout_store.root_session_dir(&session_id)); + if let Some(session_dir) = summary.session_dir.as_ref() + && let Err(error) = std::fs::create_dir_all(session_dir) + { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("create session artifact dir: {error}"), + ); + } let mut core_session = runtime_context.new_session_state( session_id, params.cwd.clone(), @@ -244,8 +256,50 @@ impl ServerRuntime { if let Some(invented) = invented_persistence.as_mut() { invented.extras.permission_preset = Some(permission_preset); } + // Fresh sessions must hold their cross-process lock before the first + // durable line. A contention here means another live process somehow + // created the same id — refuse. + let session_file_lock = match invented_persistence + .as_ref() + .map(|entry| entry.rollout_path.as_path()) + { + Some(path) => { + if let Some(parent) = path.parent() + && let Err(error) = std::fs::create_dir_all(parent) + { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("create session journal parent: {error}"), + ); + } + match crate::runtime::session_file_lock::SessionFileLock::acquire(path) { + Ok(lock) => Some(lock), + Err(crate::runtime::session_file_lock::SessionLockError::Contention { + holder_pid, + }) => { + return self.error_response( + request_id, + ProtocolErrorCode::SessionLocked, + format!( + "session rollout is already locked by another Devo process \ + (pid {holder_pid}); refusing to create a conflicting session" + ), + ); + } + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("cannot acquire session journal lock: {error}"), + ); + } + } + } + None => None, + }; if let Some(invented) = &invented_persistence - && let Err(error) = self.rollout_store.append_session_meta_at( + && let Err(error) = self.rollout_store.append_session_meta_create_at( &invented.rollout_path, &summary.native, Some(invented.extras.clone()), @@ -268,28 +322,6 @@ impl ServerRuntime { .as_ref() .map(|entry| entry.rollout_path.clone()); let rollout_path_for_db = rollout_path.clone(); - // Fresh sessions must hold their cross-process lock from creation: - // a brand-new id cannot collide with an EXISTING session, but another - // process can `devo resume` this id any time after this response, and - // concurrent ownership would corrupt the rollout. A contention here - // means another live process somehow created the same id — refuse. - let session_file_lock = match rollout_path.as_deref() { - Some(path) => match crate::runtime::session_cache::SessionFileLock::acquire(path) { - Ok(lock) => Some(lock), - Err(contention) => { - return self.error_response( - request_id, - ProtocolErrorCode::SessionLocked, - format!( - "session rollout is already locked by another Devo process \ - (pid {}); refusing to create a conflicting session", - contention.holder_pid - ), - ); - } - }, - None => None, - }; let actor_state = SessionActorState { runtime_context, rollout_path, @@ -659,7 +691,10 @@ impl ServerRuntime { session_additional_dirs, mut current, ) = if let Some(rollout_path) = &rollout_path { - let history = match devo_core::read_canonical_history(rollout_path) { + let history = match devo_core::read_canonical_history_for_session( + rollout_path, + &legacy_session_id, + ) { Ok(history) => history, Err(error) => { return self.error_response( @@ -948,6 +983,22 @@ impl ServerRuntime { serde_json::to_value(model_binding_id).expect("serialize model binding setting"), )); } + let _settings_lease = if let Some(path) = rollout_path.as_ref() + && !settings_changes.is_empty() + { + match crate::runtime::session_file_lock::SessionFileLock::acquire(path) { + Ok(lease) => Some(lease), + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::SessionLocked, + format!("cannot lock session metadata journal: {error}"), + ); + } + } + } else { + None + }; if let Some(path) = rollout_path.as_ref() && !settings_changes.is_empty() && let Err(error) = self.rollout_store.append_session_settings_batch_at( @@ -1174,7 +1225,8 @@ impl ServerRuntime { // index with the applied patch. let mut session = if let Some(path) = rollout_path.as_ref() && !settings_changes.is_empty() - && let Ok(history) = devo_core::read_canonical_history(path) + && let Ok(history) = + devo_core::read_canonical_history_for_session(path, &legacy_session_id) && let Some(persisted) = history.session { // Post-write re-read so `version` includes the settings-epoch bump @@ -1297,7 +1349,17 @@ impl ServerRuntime { request_id: serde_json::Value, session_id: SessionId, ) -> Option { - let session = self.native_session_snapshot(session_id).await?; + let session = match self.native_session_snapshot(session_id).await { + Ok(Some(session)) => session, + Ok(None) => return None, + Err(error) => { + return Some(self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error, + )); + } + }; Some( serde_json::to_value(SuccessResponse { id: request_id, @@ -1321,14 +1383,13 @@ impl ServerRuntime { session_id: SessionId, indexed_rollout_path: Option, ) -> Result, String> { - Ok(match indexed_rollout_path.filter(|path| path.exists()) { - Some(path) => Some(path), - _ => self - .rollout_store - .find_rollout_by_session_id(&session_id) - .map_err(|error| format!("failed to locate session rollout: {error}"))? - .filter(|path| path.exists()), - }) + self.rollout_store + .resolve_session_journal( + &session_id, + indexed_rollout_path.as_deref(), + crate::persistence::JournalVisibility::Live, + ) + .map_err(|error| format!("failed to locate session rollout: {error}")) } /// Reads the rollout-backed canonical session snapshot; `None` when the @@ -1336,19 +1397,26 @@ impl ServerRuntime { pub(crate) async fn native_session_snapshot( &self, session_id: SessionId, - ) -> Option { - let rollout_path = self - .resolve_existing_rollout_path(session_id, self.indexed_rollout_path(session_id)) - .ok() - .flatten(); + ) -> Result, String> { + let rollout_path = + self.resolve_existing_rollout_path(session_id, self.indexed_rollout_path(session_id))?; // Only the metadata header is needed here; the filtered meta reader // skips the typed parse of every non-meta line, which dominates a // full canonical-history read on large journals. - let history_session = rollout_path.as_ref().and_then(|path| { - devo_core::read_rollout_session_meta(path) - .ok()? - .map(|session| *session) - }); + let history_session = match rollout_path { + Some(path) => { + match devo_core::read_rollout_session_meta_for_session(&path, &session_id) { + Ok(session) => session.map(|session| *session), + Err(devo_core::HistoryReadError::SessionDeleted { .. }) => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read session metadata for {session_id}: {error}" + )); + } + } + } + None => None, + }; if let Some(handle) = self.session(session_id).await && let Some(mut session) = handle.native_session().await { @@ -1358,14 +1426,16 @@ impl ServerRuntime { if let Some(history) = history_session.as_ref() { session.version = session.version.max(history.version); } - return Some(session); + return Ok(Some(session)); } - let mut session = history_session?; + let Some(mut session) = history_session else { + return Ok(None); + }; if let Some(model) = self.deps.model_catalog.get(&session.model.model) { session.settings.effective_context_window = Some(crate::runtime::context_occupancy::resolved_compaction_limit(model)); } - Some(session) + Ok(Some(session)) } /// Overlays live runtime pointers onto a durable session snapshot. @@ -1404,8 +1474,12 @@ impl ServerRuntime { Err(error) => return error, }; let session_id = params.session_id; - let Some(mut session) = self.native_session_snapshot(session_id).await else { - return self.session_missing(request_id); + let mut session = match self.native_session_snapshot(session_id).await { + Ok(Some(session)) => session, + Ok(None) => return self.session_missing(request_id), + Err(error) => { + return self.error_response(request_id, ProtocolErrorCode::InternalError, error); + } }; self.apply_live_session_runtime_fields(session_id, &mut session) .await; @@ -1585,8 +1659,15 @@ impl ServerRuntime { // Indexed path only — no suffix walk. Metadata is best-effort; a // missing file leaves the list-cache field unset. if let Some(path) = rollout_paths.get(&session_id) { - session.transcript_size_bytes = - std::fs::metadata(path).ok().map(|metadata| metadata.len()); + // A shared child reports the whole root journal size otherwise. + // Keep the field honest: only a session that owns its journal + // reports its physical transcript size. + session.transcript_size_bytes = path + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| *stem == session_id.as_str()) + .and_then(|_| std::fs::metadata(path).ok()) + .map(|metadata| metadata.len()); } if session.message_count.is_none() { session.message_count = turn_counts.get(&session_id).copied(); @@ -1693,7 +1774,17 @@ impl ServerRuntime { request_id: serde_json::Value, session_id: SessionId, ) -> Option { - let session = self.native_session_snapshot(session_id).await?; + let session = match self.native_session_snapshot(session_id).await { + Ok(Some(session)) => session, + Ok(None) => return None, + Err(error) => { + return Some(self.error_response( + request_id, + ProtocolErrorCode::InternalError, + error, + )); + } + }; let stats = self.deps.db.get_stats(&session_id).ok().flatten(); // Occupancy preference: persisted stats first, then the resident // actor summary, and only then the rollout — the rollout scan is a @@ -1743,7 +1834,7 @@ impl ServerRuntime { .resolve_existing_rollout_path(session_id, self.indexed_rollout_path(session_id)) .ok() .flatten()?; - devo_core::read_canonical_history(&rollout_path) + devo_core::read_canonical_history_for_session(&rollout_path, &session_id) .ok() .and_then(|history| history.latest_context_occupancy) } @@ -2048,8 +2139,9 @@ impl ServerRuntime { ) } }; + let source_session_id = source.summary.session_id(); let path_scoped_items = source.rollout_path.as_deref().and_then(|rollout_path| { - match devo_core::read_canonical_history(rollout_path) { + match devo_core::read_canonical_history_for_session(rollout_path, &source_session_id) { Ok(history) => filter_items_to_active_path( &source.persisted_turn_items, &history, diff --git a/crates/server/src/runtime/handlers/session_fork.rs b/crates/server/src/runtime/handlers/session_fork.rs index bc24a95c..c54d1336 100644 --- a/crates/server/src/runtime/handlers/session_fork.rs +++ b/crates/server/src/runtime/handlers/session_fork.rs @@ -70,7 +70,24 @@ impl ServerRuntime { .invent_session_persistence(&forked_id.clone()); invented.extras.collaboration_mode = Some(forked_runtime.summary.collaboration_mode); invented.extras.permission_preset = forked_runtime.summary.permission_preset(); - if let Err(error) = self.rollout_store.append_session_meta_at( + if let Some(parent) = invented.rollout_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to persist forked session metadata: create fork journal dir: {error}" + ) + })?; + } + let _lease = + crate::runtime::session_file_lock::SessionFileLock::acquire(&invented.rollout_path) + .map_err(|error| { + format!("failed to persist forked session metadata: lock fork journal: {error}") + })?; + let forked_session_dir = self.rollout_store.root_session_dir(&forked_id); + std::fs::create_dir_all(&forked_session_dir).map_err(|error| { + format!("failed to persist forked session metadata: create fork artifact dir: {error}") + })?; + forked_runtime.summary.session_dir = Some(forked_session_dir); + if let Err(error) = self.rollout_store.append_session_meta_create_at( &invented.rollout_path, &forked_runtime.summary.native, Some(invented.extras.clone()), @@ -114,8 +131,12 @@ impl ServerRuntime { .iter() .filter_map(|item| crate::persisted_native_item::tool_call_id(&item.item)) .collect::>(); - let references = devo_core::output_replay::read_output_references(source_path) - .map_err(|error| format!("failed to read fork output references: {error}"))?; + let source_session_id = source.summary.session_id(); + let references = devo_core::output_replay::read_output_references_for_session( + source_path, + &source_session_id, + ) + .map_err(|error| format!("failed to read fork output references: {error}"))?; let artifacts = references .into_iter() .filter(|artifact| kept_calls.contains(artifact.call_id.as_str())) diff --git a/crates/server/src/runtime/handlers/session_tree.rs b/crates/server/src/runtime/handlers/session_tree.rs index 60882dcb..16708068 100644 --- a/crates/server/src/runtime/handlers/session_tree.rs +++ b/crates/server/src/runtime/handlers/session_tree.rs @@ -102,12 +102,18 @@ impl ServerRuntime { _ => (Some(target.id), None), }; - let Some(rollout_path) = self.resolve_rollout_path(¶ms.session_id).await else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); + let rollout_path = match self.resolve_rollout_path(¶ms.session_id).await { + Ok(Some(path)) => path, + Ok(None) => { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + } + Err(error) => { + return self.error_response(request_id, ProtocolErrorCode::InternalError, error); + } }; let epoch = history.leaf_epoch.saturating_add(1); diff --git a/crates/server/src/runtime/handlers/subscription.rs b/crates/server/src/runtime/handlers/subscription.rs index 1d7f40ec..cbf685d5 100644 --- a/crates/server/src/runtime/handlers/subscription.rs +++ b/crates/server/src/runtime/handlers/subscription.rs @@ -514,7 +514,7 @@ impl ServerRuntime { let stream_id = selector_stream_id(selector); match selector { StreamSelector::Session { session_id } => { - let Some(rollout_path) = self.snapshot_rollout_path(session_id).await else { + let Some(rollout_path) = self.snapshot_rollout_path(session_id).await? else { return Ok(None); }; let history = self @@ -587,24 +587,29 @@ impl ServerRuntime { async fn snapshot_rollout_path( &self, session_id: &NativeSessionId, - ) -> Option { + ) -> Result, String> { let session_id = *session_id; if let Some(stream) = self.active_stream_state(session_id).await && let Ok(stream) = stream.try_lock() && let Some(inline) = stream.turn_inline.as_ref() && let Some(path) = inline.rollout_path.clone() { - return Some(path); + return Ok(Some(path)); } - if let Ok(Some(index)) = self.deps.db.get_session_index(&session_id) - && let Some(path) = index.rollout_path - { - return Some(path); - } - self.rollout_store - .find_rollout_by_session_id(&session_id) + let indexed_path = self + .deps + .db + .get_session_index(&session_id) .ok() .flatten() + .and_then(|index| index.rollout_path); + self.rollout_store + .resolve_session_journal( + &session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::Live, + ) + .map_err(|error| format!("resolve session journal: {error}")) } /// Queue source for session snapshots: the session's in-memory turn diff --git a/crates/server/src/runtime/history_cache.rs b/crates/server/src/runtime/history_cache.rs index 76144233..30e146df 100644 --- a/crates/server/src/runtime/history_cache.rs +++ b/crates/server/src/runtime/history_cache.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Weak}; use std::time::SystemTime; use anyhow::Context; -use devo_core::{CanonicalHistory, read_canonical_history}; +use devo_core::{CanonicalHistory, read_canonical_history_for_session}; use devo_protocol::native::ids::SessionId; use tokio::sync::Mutex; @@ -74,9 +74,11 @@ impl HistoryCache { self.parse_count .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let read_path = path.clone(); - let history = tokio::task::spawn_blocking(move || read_canonical_history(&read_path)) - .await - .context("join canonical history read")??; + let history = tokio::task::spawn_blocking(move || { + read_canonical_history_for_session(&read_path, &session_id) + }) + .await + .context("join canonical history read")??; let history = Arc::new(history); if let Some(version) = version diff --git a/crates/server/src/runtime/kernel_host.rs b/crates/server/src/runtime/kernel_host.rs index 5f011405..bcf7efad 100644 --- a/crates/server/src/runtime/kernel_host.rs +++ b/crates/server/src/runtime/kernel_host.rs @@ -265,8 +265,12 @@ pub async fn dispatch_host_request_with_bridge(bridge: &HostBridge, data: Value) }; runtime.host_goal_complete(session_id).await } - "bash" | "bash.start" => { - crate::runtime::kernel_host_bridge::handle_bash(bridge, ¶ms).await + "bash" => crate::runtime::kernel_host_bridge::handle_bash(bridge, ¶ms).await, + "bash.start" => { + crate::runtime::kernel_host_bridge::handle_bash_start(bridge, ¶ms).await + } + "bash.poll" | "bash.kill" | "bash.forget" => { + devo_core::tools::host_bash::control(session_id, &action, ¶ms) } "bash.completed" => { push_bash_notice( @@ -289,10 +293,14 @@ pub async fn dispatch_host_request_with_bridge(bridge: &HostBridge, data: Value) ok_result(json!({ "accepted": true })) } "bash.consumed" => { - let Some(pid) = params.get("pid").and_then(Value::as_i64) else { - return error_reply("bash.consumed requires a pid"); + let Some(identity) = params + .get("handle_id") + .filter(|value| value.is_string()) + .or_else(|| params.get("pid").filter(|value| value.is_i64())) + else { + return error_reply("bash.consumed requires a handle_id or pid"); }; - consume_bash_notice(session_id, pid); + consume_bash_notice(session_id, identity); ok_result(json!({ "accepted": true })) } "write" => crate::runtime::kernel_host_bridge::handle_write(bridge, ¶ms).await, @@ -389,7 +397,7 @@ pub(crate) fn take_bash_notices(session_id: &str) -> Vec { .unwrap_or_default() } -pub(crate) fn consume_bash_notice(session_id: &str, pid: i64) -> bool { +pub(crate) fn consume_bash_notice(session_id: &str, identity: &Value) -> bool { let mut notices = BASH_NOTICES.lock().unwrap_or_else(|e| e.into_inner()); let Some(queue) = notices.get_mut(session_id) else { return false; @@ -399,9 +407,8 @@ pub(crate) fn consume_bash_notice(session_id: &str, pid: i64) -> bool { notice.get("kind").and_then(Value::as_str) != Some("async_bash_completion") || notice .get("params") - .and_then(|params| params.get("pid")) - .and_then(Value::as_i64) - != Some(pid) + .and_then(|params| params.get("handle_id").or_else(|| params.get("pid"))) + != Some(identity) }); let removed = queue.len() != original_len; if queue.is_empty() { @@ -455,7 +462,6 @@ mod tests { use devo_core::tools::PermissionChecker; use devo_protocol::CollaborationMode; - /// Trace: L2-DES-RLM-001 /// Verifies: rlm.create_session is denied. #[tokio::test] async fn denies_create_session() { @@ -488,7 +494,6 @@ mod tests { ); } - /// Trace: L2-DES-CONTEXT-002, L2-DES-RLM-001 /// Verifies: compact.status returns shape with percent field (null ok). #[tokio::test] async fn compact_status_shape_includes_percent() { @@ -507,7 +512,6 @@ mod tests { assert!(result["percent"].is_null(), "{reply}"); } - /// Trace: L2-DES-CONTEXT-002, L1-REQ-RLM-001 /// Verifies: compact.status reads real occupancy through the runtime — a /// fresh session (no measured usage yet) still reports the model's /// context window. The pre-fix handler hardcoded `None` occupancy + @@ -585,6 +589,7 @@ mod tests { file_read_ledger: Arc::new(FileReadLedger::new()), sandbox_profile: None, cancel_token: tokio_util::sync::CancellationToken::new(), + kernel_lifetime: None, mcp_manager: None, agent_scope: ToolAgentScope::Parent, local_web_search: None, @@ -611,7 +616,6 @@ mod tests { assert_eq!(result["scheduled"], false); } - /// Trace: L2-DES-RLM-001 /// Verifies: refine.status / refine.run use `{status, result}` envelope. #[tokio::test] async fn refine_host_reply_uses_result_envelope() { @@ -634,7 +638,6 @@ mod tests { assert!(run["result"].get("note").is_some(), "{run}"); } - /// Trace: L2-DES-RLM-001 /// Verifies: rlm.run/list_subagents/delete_subagents reach their real /// handlers — no spawn stub. Without a runtime the reply must be an /// explicit error (never the old stub's fake pending handle), and prompt @@ -697,7 +700,6 @@ mod tests { assert!(models["result"]["models"].as_array().is_some()); } - /// Trace: L2-DES-RLM-001 /// Verifies: bash.completed enqueues an in-memory notice. #[tokio::test] async fn bash_completed_enqueues_notice() { @@ -766,7 +768,6 @@ mod tests { ); } - /// Trace: L2-DES-RLM-001 /// Verifies: agent_message.send queues a wake notice for family projection. #[tokio::test] async fn agent_message_send_enqueues_notice() { @@ -786,7 +787,6 @@ mod tests { assert_eq!(notices[0]["kind"], "agent_message"); } - /// Trace: L2-DES-RLM-001, L2-DES-SAFETY-001 /// Verifies: web.search fails closed when local provider is not configured. #[tokio::test] async fn web_search_requires_local_config() { @@ -809,24 +809,27 @@ mod tests { ); } - /// Trace: L2-DES-RLM-001, L2-DES-SAFETY-001 - /// Verifies: Plan mode denies bash even with always_allow permission. + /// Verifies: Plan mode bash follows the configured policy and reaches normal execution. #[tokio::test] - async fn bash_denied_in_plan_mode_with_always_allow() { + async fn plan_mode_bash_follows_normal_policy() { let mut bridge = HostBridge::for_tests(format!("ses_{}", Uuid::new_v4())); bridge.collaboration_mode = CollaborationMode::Plan; + bridge.sandbox_profile = Some("off".to_string()); bridge.permission = PermissionChecker::always_allow(); let reply = dispatch_host_request_with_bridge( &bridge, json!({ "type": "bash", - "params": { "command": "echo should-not-run" } + "params": { "command": "echo PLAN_DISPATCH_OK" } }), ) .await; - assert_eq!(reply["status"], "error"); + assert_eq!(reply["status"], json!("ok"), "{reply}"); assert!( - reply["error"].as_str().unwrap_or("").contains("Plan"), + reply["stdout"] + .as_str() + .unwrap_or_default() + .contains("PLAN_DISPATCH_OK"), "{reply}" ); } diff --git a/crates/server/src/runtime/kernel_host_bridge.rs b/crates/server/src/runtime/kernel_host_bridge.rs index 6188c98b..6b910f64 100644 --- a/crates/server/src/runtime/kernel_host_bridge.rs +++ b/crates/server/src/runtime/kernel_host_bridge.rs @@ -21,11 +21,10 @@ use super::ServerRuntime; use devo_core::McpManager; use devo_core::tools::contracts::ToolBudgets; mod shell; -pub(crate) use shell::handle_bash; +pub(crate) use shell::{handle_bash, handle_bash_start}; use devo_core::tools::handlers::{ - EditHandler, PlanHandler, QuestionHandler, ShellCommandHandler, WebFetchHandler, - WebSearchHandler, WriteHandler, + EditHandler, PlanHandler, QuestionHandler, WebFetchHandler, WebSearchHandler, WriteHandler, }; use devo_core::tools::{ ClientFilesystem, FileReadLedger, PermissionChecker, SandboxPermissionRequest, ToolAgentScope, @@ -52,6 +51,7 @@ pub(crate) struct HostBridge { pub file_read_ledger: Arc, pub sandbox_profile: Option, pub cancel_token: CancellationToken, + pub kernel_lifetime: Option, pub mcp_manager: Option>, pub agent_scope: ToolAgentScope, pub local_web_search: Option, @@ -80,6 +80,7 @@ impl HostBridge { file_read_ledger: Arc::new(FileReadLedger::new()), sandbox_profile: None, cancel_token: CancellationToken::new(), + kernel_lifetime: None, mcp_manager: None, agent_scope: ToolAgentScope::Parent, local_web_search: None, diff --git a/crates/server/src/runtime/kernel_host_bridge/shell.rs b/crates/server/src/runtime/kernel_host_bridge/shell.rs index 9a2d9090..5d6784d6 100644 --- a/crates/server/src/runtime/kernel_host_bridge/shell.rs +++ b/crates/server/src/runtime/kernel_host_bridge/shell.rs @@ -5,10 +5,17 @@ use devo_core::tools::{ sandbox_permission_request_from_input, }; -pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { - if plan_mode_denies_mutation(bridge) { - return deny_plan_mutation("bash"); - } +async fn prepare_bash( + bridge: &HostBridge, + params: &Value, +) -> Result< + ( + ToolContext, + Value, + devo_core::tools::host_bash::SandboxRetry, + ), + Value, +> { let command = params .get("command") .or_else(|| params.get("cmd")) @@ -16,12 +23,31 @@ pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { .unwrap_or(Value::Null); let command_str = command.as_str().unwrap_or("").to_string(); if command_str.is_empty() { - return error_reply("missing bash command"); + return Err(error_reply("missing bash command")); } let mut input = params.clone(); if input.get("command").is_none() { input["command"] = json!(command_str.clone()); } + // Resolve once so approval, command matching, and execution use the same cwd. + let workdir = match input.get("workdir") { + Some(Value::String(path)) => { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + bridge.cwd.join(path) + } + } + None => bridge.cwd.clone(), + Some(_) => return Err(error_reply("bash workdir must be a string")), + }; + let workdir = match std::fs::canonicalize(&workdir) { + Ok(path) if path.is_dir() => path, + Ok(_) => return Err(error_reply("bash workdir must be a directory")), + Err(error) => return Err(error_reply(format!("invalid bash workdir: {error}"))), + }; + input["workdir"] = json!(workdir); let tool_call_id = synthetic_tool_call_id("bash"); let mut req = permission_request( bridge, @@ -33,9 +59,14 @@ pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { /*path*/ None, Some(command_str), ); + req.cwd = workdir.clone(); req.sandbox_permissions = match sandbox_permission_request_from_input(&input) { Ok(permissions) => permissions, - Err(error) => return error_reply(format!("invalid sandbox permission request: {error}")), + Err(error) => { + return Err(error_reply(format!( + "invalid sandbox permission request: {error}" + ))); + } }; req.justification = input .get("justification") @@ -44,58 +75,69 @@ pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { req.command_prefix = command_prefix_for_tool_input("shell_command", &input); req.command_argv = safe_shell_argv(command.as_str().unwrap_or_default()); req.command_pattern = - generalize_command_pattern(command.as_str().unwrap_or_default(), &bridge.cwd); - let grant = match check_or_grant(bridge, req).await { - Ok(grant) => grant, - Err(reply) => return reply, + generalize_command_pattern(command.as_str().unwrap_or_default(), &workdir); + let kernel_stopped = async { + if let Some(lifetime) = &bridge.kernel_lifetime { + lifetime.cancelled().await; + } else { + std::future::pending::<()>().await; + } }; + if bridge.cancel_token.is_cancelled() { + return Err(error_reply("originating turn cancelled")); + } + let authorization = check_or_grant(bridge, req); + tokio::pin!(authorization); + let grant = tokio::select! { + biased; + _ = kernel_stopped => { + // Drive the checker's existing cancellation cleanup instead of dropping + // its pending controller request when the owning kernel disappears. + bridge.cancel_token.cancel(); + authorization.await + } + result = &mut authorization => result, + }?; let mut ctx = tool_context_from_bridge(bridge, tool_call_id.clone()); if grant.bypass_sandbox { ctx.sandbox_profile = Some("off".to_string()); } ctx.sandbox_permission_overlay = grant.sandbox_permission_overlay.clone(); - let outcome = ShellCommandHandler::new() - .handle(ctx, input.clone(), /*_progress*/ None) - .await; - // UnlessTrusted retry (mirrors `ToolRuntime::execute_single`): a - // user-approved shell call the OS sandbox refused retries once unsandboxed - // — without this, kernel `bash` approvals visibly succeed while the - // command still fails with EACCES. Policy-allows (no approval) keep the - // denial so the model can explicitly request escalation through rlm.shell(). - let outcome = match outcome { - Ok(ref output) - if grant.already_approved - && !grant.bypass_sandbox - && grant.sandbox_permission_overlay.is_none() - && tool_result_is_sandbox_denied_bridge(output) - && devo_sandbox::unsandboxed_execution_allowed( - bridge.sandbox_profile.as_deref(), - &bridge.cwd, - ) => - { - let mut ctx = tool_context_from_bridge(bridge, tool_call_id); - ctx.sandbox_profile = Some("off".to_string()); - ShellCommandHandler::new() - .handle(ctx, input, /*_progress*/ None) - .await - } - other => other, + let retry = if grant.already_approved + && !grant.bypass_sandbox + && grant.sandbox_permission_overlay.is_none() + && devo_sandbox::unsandboxed_execution_allowed(bridge.sandbox_profile.as_deref(), &workdir) + { + devo_core::tools::host_bash::SandboxRetry::ApprovedDenial + } else { + devo_core::tools::host_bash::SandboxRetry::Never }; - map_tool_outcome(outcome) + Ok((ctx, input, retry)) } -/// Bridge twin of the router's `tool_result_is_sandbox_denied` (private there): -/// only the structured `SANDBOX_DENIED:` prefix triggers the retry. -fn tool_result_is_sandbox_denied_bridge(output: &ToolResult) -> bool { - let text = match &output.content { - devo_core::tools::contracts::ToolResultContent::Text(text) => text.as_str(), - devo_core::tools::contracts::ToolResultContent::Mixed { - text: Some(text), .. - } => text.as_str(), - devo_core::tools::contracts::ToolResultContent::Json(_) - | devo_core::tools::contracts::ToolResultContent::Mixed { text: None, .. } => "", +pub(crate) async fn handle_bash(bridge: &HostBridge, params: &Value) -> Value { + let (ctx, input, retry) = match prepare_bash(bridge, params).await { + Ok(prepared) => prepared, + Err(reply) => return reply, }; - text.starts_with("SANDBOX_DENIED:") || output.result_summary.starts_with("SANDBOX_DENIED:") + map_tool_outcome( + devo_core::tools::host_bash::execute( + ctx, + input, + retry, + None, + devo_core::tools::handlers::ShellExecutionMode::Foreground, + ) + .await, + ) +} + +pub(crate) async fn handle_bash_start(bridge: &HostBridge, params: &Value) -> Value { + let (ctx, input, retry) = match prepare_bash(bridge, params).await { + Ok(prepared) => prepared, + Err(reply) => return reply, + }; + devo_core::tools::host_bash::start(ctx, input, retry, bridge.kernel_lifetime.clone()) } #[cfg(test)] @@ -105,6 +147,275 @@ mod tests { use pretty_assertions::assert_eq; use std::sync::Mutex; + async fn finished(bridge: &HostBridge, id: &Value) -> Value { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let result = devo_core::tools::host_bash::control( + bridge.session_id.as_str(), + "bash.poll", + id, + ); + if result["result"]["running"] == false { + return result; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("shell completion") + } + + #[tokio::test] + async fn lifecycle_authorizes_once_and_scopes_handles_across_bridges() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let workspace = tempfile::tempdir().unwrap(); + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.cwd = workspace.path().to_path_buf(); + bridge.sandbox_profile = Some("off".into()); + let count = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&count); + bridge.permission = PermissionChecker::new(move |_| { + seen.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(PermissionGrant::default()) }) + }); + let started = handle_bash_start(&bridge, &json!({"command":"echo HOST_LIFECYCLE"})).await; + let id = json!({"handle_id":started["result"]["handle_id"]}); + assert_eq!(started["result"]["running"], true); + assert_eq!( + devo_core::tools::host_bash::control("other-session", "bash.kill", &id), + json!({"status":"error", "error":"unknown bash handle"}) + ); + let fresh_bridge = HostBridge::for_tests(bridge.session_id); + let done = finished(&fresh_bridge, &id).await; + assert_eq!( + ( + done["result"]["exit_code"].clone(), + count.load(Ordering::SeqCst) + ), + (json!(0), 1) + ); + assert!( + done["result"]["output"] + .as_str() + .unwrap() + .contains("HOST_LIFECYCLE") + ); + assert_eq!( + devo_core::tools::host_bash::control(bridge.session_id.as_str(), "bash.forget", &id), + json!({"status":"ok", "result":{"forgotten":true}}) + ); + assert_eq!( + devo_core::tools::host_bash::control(bridge.session_id.as_str(), "bash.poll", &id), + json!({"status":"error", "error":"unknown bash handle"}) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn lifecycle_streams_and_preserves_origin_cancellation() { + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.sandbox_profile = Some("off".into()); + let started = + handle_bash_start(&bridge, &json!({"command":"printf streaming; sleep 30"})).await; + let id = json!({"handle_id":started["result"]["handle_id"]}); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let snapshot = devo_core::tools::host_bash::control( + bridge.session_id.as_str(), + "bash.poll", + &id, + ); + if snapshot["result"]["output"] + .as_str() + .unwrap() + .contains("streaming") + { + assert_eq!(snapshot["result"]["running"], true); + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + bridge.cancel_token.cancel(); + let done = finished(&bridge, &id).await; + assert!( + done["result"]["output"] + .as_str() + .unwrap() + .contains("cancelled") + ); + devo_core::tools::host_bash::cancel_session(bridge.session_id.as_str()); + } + + #[cfg(unix)] + #[tokio::test] + async fn background_default_has_no_deadline_but_explicit_timeout_expires() { + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.sandbox_profile = Some("off".into()); + let started = handle_bash_start( + &bridge, + &json!({"command":"printf ready; sleep 30", "login":false}), + ) + .await; + let id = json!({"handle_id":started["result"]["handle_id"]}); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let snapshot = devo_core::tools::host_bash::control( + bridge.session_id.as_str(), + "bash.poll", + &id, + ); + if snapshot["result"]["output"] + .as_str() + .unwrap() + .contains("ready") + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(121)).await; + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert_eq!( + devo_core::tools::host_bash::control(bridge.session_id.as_str(), "bash.poll", &id)["result"] + ["running"], + true + ); + tokio::time::resume(); + devo_core::tools::host_bash::control(bridge.session_id.as_str(), "bash.kill", &id); + let cancelled = finished(&bridge, &id).await; + assert_eq!(cancelled["result"]["exit_code"], Value::Null); + assert!(cancelled["result"]["error"].is_string()); + let started = handle_bash_start( + &bridge, + &json!({"command":"sleep 30", "timeout_ms":1, "login":false}), + ) + .await; + let id = json!({"handle_id":started["result"]["handle_id"]}); + let done = finished(&bridge, &id).await; + assert!( + done["result"]["output"] + .as_str() + .unwrap() + .contains("timed out"), + "{done}" + ); + devo_core::tools::host_bash::cancel_session(bridge.session_id.as_str()); + } + + #[cfg(unix)] + #[tokio::test] + async fn kernel_lifetime_cancels_without_cancelling_originating_turn() { + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.sandbox_profile = Some("off".into()); + let lifetime = CancellationToken::new(); + bridge.kernel_lifetime = Some(lifetime.clone()); + let started = handle_bash_start(&bridge, &json!({"command":"sleep 30"})).await; + let id = json!({"handle_id":started["result"]["handle_id"]}); + lifetime.cancel(); + let done = finished(&bridge, &id).await; + assert!(!bridge.cancel_token.is_cancelled()); + assert!(done["result"]["error"].is_string(), "{done}"); + devo_core::tools::host_bash::cancel_session(bridge.session_id.as_str()); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn lifecycle_workspace_sandbox_denies_outside_write() { + let workspace = tempfile::tempdir().unwrap(); + // The system temporary directory is intentionally writable by workspace + // profiles, so put this disposable probe under the user's home instead. + let home = std::env::var_os("HOME").expect("macOS home"); + let outside = tempfile::tempdir_in(home).unwrap(); + let forbidden = outside.path().join("sandbox-probe"); + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.cwd = workspace.path().to_path_buf(); + bridge.sandbox_profile = Some("workspace".into()); + let path = forbidden.to_string_lossy().replace('\'', "'\"'\"'"); + let command = format!("printf blocked > '{path}'"); + let started = handle_bash_start(&bridge, &json!({"command":command, "login":false})).await; + let id = json!({"handle_id":started["result"]["handle_id"]}); + let done = finished(&bridge, &id).await; + assert!( + !forbidden.exists(), + "workspace sandbox allowed outside write: {done}" + ); + assert!(done["result"]["error"].is_string(), "{done}"); + assert!( + done["result"]["output"] + .as_str() + .unwrap() + .contains("SANDBOX_DENIED"), + "{done}" + ); + devo_core::tools::host_bash::cancel_session(bridge.session_id.as_str()); + } + + #[tokio::test] + async fn completed_handles_do_not_block_sequential_commands() { + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.sandbox_profile = Some("off".into()); + for _ in 0..33 { + let started = + handle_bash_start(&bridge, &json!({"command":"echo quota", "login":false})).await; + assert_eq!(started["status"], "ok", "{started}"); + let id = json!({"handle_id":started["result"]["handle_id"]}); + assert_eq!(finished(&bridge, &id).await["result"]["exit_code"], 0); + } + devo_core::tools::host_bash::cancel_session(bridge.session_id.as_str()); + } + + #[tokio::test] + async fn cancelled_origin_releases_pending_authorization() { + let mut bridge = HostBridge::for_tests(SessionId::new()); + let entered = Arc::new(tokio::sync::Notify::new()); + let seen = Arc::clone(&entered); + let checker_cancel = bridge.cancel_token.clone(); + bridge.permission = PermissionChecker::new(move |_| { + let seen = Arc::clone(&seen); + let checker_cancel = checker_cancel.clone(); + Box::pin(async move { + seen.notify_one(); + checker_cancel.cancelled().await; + Err("originating turn cancelled".into()) + }) + }); + let cancel = bridge.cancel_token.clone(); + let input = json!({"command":"echo never"}); + let request = handle_bash_start(&bridge, &input); + let interrupt = async { + entered.notified().await; + cancel.cancel(); + }; + let (reply, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!(request, interrupt) + }) + .await + .expect("approval cancellation"); + assert_eq!( + reply, + json!({"status":"error", "error":"originating turn cancelled"}) + ); + } + + #[tokio::test] + async fn lifecycle_denial_does_not_allocate_handle() { + let mut bridge = HostBridge::for_tests(SessionId::new()); + bridge.permission = PermissionChecker::new(|_| Box::pin(async { Err("denied".into()) })); + assert_eq!( + handle_bash_start(&bridge, &json!({"command":"echo never"})).await, + json!({"status":"error", "error":"denied"}) + ); + } + #[tokio::test] async fn denied_escalation_never_executes_command() { let workspace = tempfile::tempdir().expect("workspace"); @@ -141,7 +452,11 @@ mod tests { *seen.lock().unwrap(), vec![( "shell_command".to_string(), - input, + { + let mut expected = input; + expected["workdir"] = json!(std::fs::canonicalize(workspace.path()).unwrap()); + expected + }, Some("May I run the shell probe?".to_string()), SandboxPermissionRequest::FullEscalation, Some("echo denied > should-not-exist.txt".to_string()), @@ -153,6 +468,42 @@ mod tests { assert!(!workspace.path().join("should-not-exist.txt").exists()); } + #[tokio::test] + async fn approval_uses_the_execution_directory() { + let workspace = tempfile::tempdir().expect("workspace"); + let child = workspace.path().join("child"); + std::fs::create_dir(&child).expect("child directory"); + let expected = std::fs::canonicalize(child).expect("canonical directory"); + let mut bridge = HostBridge::for_tests("ses_shell_workdir"); + bridge.cwd = workspace.path().to_path_buf(); + bridge.permission = PermissionChecker::new(move |request| { + let expected = expected.clone(); + Box::pin(async move { + assert_eq!( + (request.cwd, request.input["workdir"].clone()), + (expected.clone(), json!(expected)), + ); + Err("stop before execution".to_string()) + }) + }); + assert_eq!( + handle_bash(&bridge, &json!({"command":"echo probe", "workdir":"child"})).await, + json!({"status":"error", "error":"stop before execution"}), + ); + } + + #[tokio::test] + async fn malformed_directory_never_reaches_approval() { + let mut bridge = HostBridge::for_tests("ses_shell_bad_workdir"); + bridge.permission = PermissionChecker::new(|_| { + Box::pin(async { panic!("invalid workdir reached approval") }) + }); + assert_eq!( + handle_bash(&bridge, &json!({"command":"echo probe", "workdir":42})).await, + json!({"status":"error", "error":"bash workdir must be a string"}), + ); + } + #[tokio::test] async fn approved_escalation_runs_without_the_original_sandbox() { let workspace = tempfile::tempdir().expect("workspace"); @@ -185,10 +536,10 @@ mod tests { } #[tokio::test] - async fn invalid_permissions_and_plan_mode_never_authorize() { + async fn invalid_permissions_never_authorize() { let mut bridge = HostBridge::for_tests("ses_shell_invalid"); bridge.permission = PermissionChecker::new(|_| { - Box::pin(async { panic!("invalid or Plan request reached authorization") }) + Box::pin(async { panic!("invalid request reached authorization") }) }); assert_eq!( handle_bash( @@ -200,19 +551,74 @@ mod tests { .await, json!({"status":"error", "error":"invalid sandbox permission request: unsupported sandbox_permissions value: invalid"}) ); + } + + #[tokio::test] + async fn plan_mode_invokes_configured_policy_and_respects_denial() { + let mut bridge = HostBridge::for_tests("ses_shell_plan_denied"); bridge.collaboration_mode = CollaborationMode::Plan; + bridge.sandbox_profile = Some("off".to_string()); + let calls = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&calls); + bridge.permission = PermissionChecker::new(move |request| { + let seen = Arc::clone(&seen); + Box::pin(async move { + seen.lock() + .unwrap() + .push((request.tool_name, request.input["command"].clone())); + Err("denied by configured policy".to_string()) + }) + }); assert_eq!( - handle_bash( - &bridge, - &json!({ - "command":"echo invalid", "sandbox_permissions":"require_escalated" - }) - ) - .await, - json!({"status":"error", "error":"denied: bash blocked in Plan mode"}) + handle_bash_start(&bridge, &json!({"command":"echo should-not-run"})).await, + json!({"status":"error", "error":"denied by configured policy"}) + ); + assert_eq!( + *calls.lock().unwrap(), + vec![("shell_command".to_string(), json!("echo should-not-run"))] ); } + #[tokio::test] + async fn plan_mode_allow_starts_normal_host_execution() { + let mut bridge = HostBridge::for_tests("ses_shell_plan_allow"); + bridge.collaboration_mode = CollaborationMode::Plan; + bridge.sandbox_profile = Some("off".to_string()); + bridge.permission = PermissionChecker::always_allow(); + let started = handle_bash_start(&bridge, &json!({"command":"echo PLAN_ALLOW_OK"})).await; + assert_eq!(started["status"], json!("ok"), "{started}"); + let handle_id = json!({"handle_id": started["result"]["handle_id"]}); + let done = finished(&bridge, &handle_id).await; + assert_eq!(done["result"]["exit_code"], json!(0), "{done}"); + assert!( + done["result"]["output"] + .as_str() + .unwrap_or_default() + .contains("PLAN_ALLOW_OK"), + "{done}" + ); + assert_eq!( + devo_core::tools::host_bash::control( + bridge.session_id.as_str(), + "bash.forget", + &handle_id + ), + json!({"status":"ok", "result":{"forgotten":true}}) + ); + } + + #[tokio::test] + async fn plan_mode_auto_allow_keeps_configured_sandbox_profile() { + let mut bridge = HostBridge::for_tests("ses_shell_plan_sandbox"); + bridge.collaboration_mode = CollaborationMode::Plan; + bridge.sandbox_profile = Some("workspace".to_string()); + bridge.permission = PermissionChecker::always_allow(); + let (ctx, _input, _retry) = prepare_bash(&bridge, &json!({"command":"echo sandbox"})) + .await + .expect("prepared Plan shell"); + assert_eq!(ctx.sandbox_profile, Some("workspace".to_string())); + } + #[tokio::test] async fn narrow_permissions_reach_authorization_without_full_escalation() { let mut bridge = HostBridge::for_tests("ses_shell_narrow"); diff --git a/crates/server/src/runtime/kernel_idle_bash.rs b/crates/server/src/runtime/kernel_idle_bash.rs index dc92850f..0aefa1ab 100644 --- a/crates/server/src/runtime/kernel_idle_bash.rs +++ b/crates/server/src/runtime/kernel_idle_bash.rs @@ -9,7 +9,7 @@ use devo_protocol::native::ids::SessionId; use super::ServerRuntime; -/// Handles only `bash.completed` and PID-scoped `bash.consumed`; all other idle requests are denied. +/// Handles notices and session-scoped handle controls; idle process starts are denied. pub(crate) fn for_session( runtime: Weak, session_id: SessionId, @@ -37,13 +37,20 @@ pub(crate) fn for_session( } } "bash.consumed" => { - let Some(pid) = params.get("pid").and_then(Value::as_i64) else { + let Some(identity) = params + .get("handle_id") + .filter(|value| value.is_string()) + .or_else(|| params.get("pid").filter(|value| value.is_i64())) + else { return json!({ "status": "error", - "error": "bash.consumed requires a pid", + "error": "bash.consumed requires a handle_id or pid", }); }; - super::kernel_host::consume_bash_notice(&session_key, pid); + super::kernel_host::consume_bash_notice(&session_key, identity); + } + "bash.poll" | "bash.kill" | "bash.forget" => { + return devo_core::tools::host_bash::control(&session_key, &action, ¶ms); } _ => { return json!({ @@ -167,6 +174,41 @@ mod tests { assert!(take_bash_notices(&session_key).is_empty()); } + #[tokio::test] + async fn idle_controls_are_scoped_and_start_stays_denied() { + let session_id = SessionId::new(); + let handler = for_session(Weak::new(), session_id); + for action in ["bash.poll", "bash.kill", "bash.forget"] { + assert_eq!( + handler( + "control".into(), + json!({"type":action, "params":{"handle_id":"missing"}}) + ) + .await, + json!({"status":"error", "error":"unknown bash handle"}) + ); + } + assert_eq!( + handler( + "start".into(), + json!({"type":"bash.start", "params":{"command":"echo nope"}}) + ) + .await, + json!({"status":"error", "error":"idle host request denied: bash.start"}) + ); + handler( + "done".into(), + json!({"type":"bash.completed", "params":{"handle_id":"opaque"}}), + ) + .await; + handler( + "consumed".into(), + json!({"type":"bash.consumed", "params":{"handle_id":"opaque"}}), + ) + .await; + assert_eq!(take_bash_notices(session_id.as_str()), Vec::::new()); + } + #[tokio::test] async fn completion_notice_is_restored_when_follow_up_turn_cannot_start() { let data_root = TempDir::new().expect("tempdir"); diff --git a/crates/server/src/runtime/lifecycle.rs b/crates/server/src/runtime/lifecycle.rs index c532c208..14624b17 100644 --- a/crates/server/src/runtime/lifecycle.rs +++ b/crates/server/src/runtime/lifecycle.rs @@ -24,7 +24,12 @@ impl ServerRuntime { .ok(); let mut runtime_session = self .rollout_store - .load_session_from_rollout_with_history(rollout_path, &self.deps, history.as_deref()) + .load_session_from_rollout_for_session( + rollout_path, + session_id, + &self.deps, + history.as_deref(), + ) .await?; if runtime_session.summary.session_id() != session_id { anyhow::bail!( @@ -47,7 +52,7 @@ impl ServerRuntime { runtime_session: &mut RuntimeSession, ) -> anyhow::Result<()> { let history = match runtime_session.rollout_path.as_ref() { - Some(path) => devo_core::read_canonical_history(path).ok(), + Some(path) => devo_core::read_canonical_history_for_session(path, &session_id).ok(), None => None, }; self.apply_persisted_session_side_effects_with_history( @@ -367,14 +372,24 @@ impl ServerRuntime { Some(rollout_path) => { match crate::runtime::session_cache::SessionFileLock::acquire(rollout_path) { Ok(lock) => Some(lock), - Err(contention) => { + Err(crate::runtime::session_file_lock::SessionLockError::Contention { + holder_pid, + }) => { tracing::warn!( session_id = %session_id, - holder_pid = contention.holder_pid, + holder_pid, "skipping session locked by another Devo process during bulk restore" ); continue; } + Err(error) => { + tracing::warn!( + session_id = %session_id, + %error, + "skipping session whose journal lock could not be acquired during bulk restore" + ); + continue; + } } } None => None, diff --git a/crates/server/src/runtime/mcp.rs b/crates/server/src/runtime/mcp.rs index 425bc3ba..eb429609 100644 --- a/crates/server/src/runtime/mcp.rs +++ b/crates/server/src/runtime/mcp.rs @@ -357,7 +357,6 @@ mod tests { } } - /// Trace: L2-DES-MCP-001 /// Verifies: mcp/tools does not start a disabled server and does start idle enabled ones. #[test] fn mcp_tools_refresh_skips_disabled_and_filled_ready_servers() { diff --git a/crates/server/src/runtime/provider_api.rs b/crates/server/src/runtime/provider_api.rs index f673b32b..25c10729 100644 --- a/crates/server/src/runtime/provider_api.rs +++ b/crates/server/src/runtime/provider_api.rs @@ -1054,7 +1054,6 @@ mod tests { ); } - /// Trace: L2-DES-APP-005, L2-DES-MODEL-001 /// Verifies: provider validation applies provider custom header parsing before sending a validation request. #[tokio::test] async fn validate_provider_candidate_rejects_invalid_custom_headers() { diff --git a/crates/server/src/runtime/refine.rs b/crates/server/src/runtime/refine.rs index 16718424..25f0c764 100644 --- a/crates/server/src/runtime/refine.rs +++ b/crates/server/src/runtime/refine.rs @@ -544,9 +544,7 @@ impl ServerRuntime { }; let summary = handle.summary().await; let is_root = summary.as_ref().is_none_or(|s| s.agent_path.is_none()); - let session_dir = handle.rollout_path().await.flatten().and_then(|path| { - crate::persistence::RolloutStore::rlm_session_dir_for_rollout(&path) - }); + let session_dir = summary.as_ref().and_then(|s| s.session_dir.clone()); (is_root, session_dir) }; let result = @@ -643,7 +641,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-HARNESS-001 /// Verifies: child sessions cannot schedule refine.run. #[test] fn children_cannot_schedule() { @@ -658,7 +655,6 @@ mod tests { assert!(!result.scheduled); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: root schedule last-write-wins pending refine. #[test] fn root_schedule_is_pending() { @@ -678,7 +674,6 @@ mod tests { assert!(!taken.autonomous); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: should_auto_refine integration schedules on interval for roots. #[test] fn auto_interval_schedules_on_root() { @@ -702,7 +697,6 @@ mod tests { assert_eq!(taken.instructions.as_deref(), Some(AUTO_REFINE_INSTRUCTION)); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: children never auto-refine even at interval. #[test] fn auto_interval_skips_children() { @@ -718,7 +712,6 @@ mod tests { assert!(!peek_pending_refine(&id)); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: boundary apply writes refinements.jsonl (never mid-ipython). #[test] fn apply_at_boundary_persists_jsonl() { @@ -739,7 +732,6 @@ mod tests { assert!(!peek_pending_refine(&id)); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: Plan Mode holds autonomous pending without applying. #[test] fn plan_mode_defers_autonomous_apply() { @@ -778,7 +770,6 @@ mod tests { ); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: plan_refine_proposal with instructions yields a memory Create edit. #[test] fn plan_refine_proposal_with_instructions_emits_memory_create() { @@ -808,7 +799,6 @@ mod tests { assert!(proposal.expected_outcome.contains(&edit.id)); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: empty/missing instructions keep the no-op empty-edits proposal. #[test] fn plan_refine_proposal_without_instructions_is_noop() { @@ -832,7 +822,6 @@ mod tests { assert!(plan_refine_proposal(&pending_none, &path).edits.is_empty()); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: the auto-interval sentinel is a scheduling marker, not /// guidance — the heuristic must not echo it into a durable memory /// (bug #48), while the durable trigger keeps the sentinel label. @@ -910,7 +899,6 @@ mod tests { assert_eq!(turns_since_last_refinement(&journal[..4]), 2); } - /// Trace: bug #45 — global refine must target the global store. /// Verifies: heuristic planner entries and records follow the requested /// scope, and the Devo home resolves to the global harness layout. #[test] diff --git a/crates/server/src/runtime/refine_planner.rs b/crates/server/src/runtime/refine_planner.rs index 027147ea..ce0e39c8 100644 --- a/crates/server/src/runtime/refine_planner.rs +++ b/crates/server/src/runtime/refine_planner.rs @@ -345,7 +345,6 @@ mod tests { } } - /// Trace: L2-DES-HARNESS-001 /// Verifies: LLM JSON parses into a memory Create RefineProposal. #[test] fn parse_refine_proposal_text_memory_create() { @@ -377,7 +376,6 @@ mod tests { ); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: base_system_prompt edits are rejected. #[test] fn parse_rejects_immutable_base_prompt() { @@ -393,7 +391,6 @@ mod tests { assert!(parse_refine_proposal_text(&pending(Some("x")), raw).is_none()); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: fenced / noisy model output still extracts JSON. #[test] fn parse_extracts_json_object_from_noise() { @@ -402,7 +399,6 @@ mod tests { assert_eq!(proposal.summary, "ok"); assert!(proposal.edits.is_empty()); } - /// Trace: global refine scope must reach the applied entries. /// Verifies: LLM-emitted per-entry scope never overrides the requested /// target store (bug #45 — global refinements landed in the local store). #[test] @@ -429,7 +425,6 @@ mod tests { assert_eq!(entry.scope, Some(HarnessScope::Global)); } - /// Trace: L2-DES-HARNESS-001 /// Verifies: the auto-interval scheduling sentinel never reaches the /// planner prompt as instructions (the model used to store the sentinel /// itself as a durable memory), while real guidance passes verbatim and diff --git a/crates/server/src/runtime/session_actor/actor_loop.rs b/crates/server/src/runtime/session_actor/actor_loop.rs index 747578a5..0e3e397d 100644 --- a/crates/server/src/runtime/session_actor/actor_loop.rs +++ b/crates/server/src/runtime/session_actor/actor_loop.rs @@ -9,7 +9,7 @@ use devo_protocol::native::turn::TurnStatus; use tokio::sync::mpsc; use super::approval_scope::{ - apply_approval_scope_to_state, apply_path_scope_to_permission_profile, credential_delivery_root, + apply_approval_scope_to_state, apply_path_scope_to_permission_profile, }; use super::commands::{ApprovalCheckpointSnapshot, SessionCommand}; use super::snapshots::{ @@ -290,62 +290,6 @@ pub(super) async fn run_session_actor( &scope, &pending, ); - // P2 (design doc §8/§9): a PathPrefix write approval is not - // just a cache/profile entry — deliver it as a credential to - // the fenced kernel so native I/O works from now on, with no - // kernel restart. Scope selection lives in - // `credential_delivery_root` (tested); Session-scope - // exact-file approvals deliberately stay mediated. - // Windows: session-SID ACE. Unix: dirfd over SCM_RIGHTS. - if let Some((root, access)) = credential_delivery_root(&scope, &pending) - && let Some(kernel) = state.kernel.as_ref() - { - #[cfg(windows)] - if let Some(authority) = kernel.fence_credentials() { - let delivered = match access { - super::approval_scope::CredentialAccess::Write => { - authority.grant_write_root(&root) - } - super::approval_scope::CredentialAccess::Read => { - authority.grant_read_root(&root) - } - }; - match delivered { - Ok(_) => tracing::info!( - root = %root.display(), - "session credential delivered: ACE for the fenced kernel \ - (no restart)" - ), - Err(err) => tracing::warn!( - %err, - root = %root.display(), - "session credential ACE delivery failed; \ - the mediated fallback remains active" - ), - } - } - #[cfg(unix)] - if let Some(channel) = kernel.grant_channel() { - let access_str = match access { - super::approval_scope::CredentialAccess::Write => "write", - super::approval_scope::CredentialAccess::Read => "read", - }; - match channel.grant(&root, access_str) { - Ok(_) => tracing::info!( - root = %root.display(), - access = access_str, - "session credential delivered: dirfd for the fenced kernel \ - (no restart)" - ), - Err(err) => tracing::warn!( - %err, - root = %root.display(), - "session credential dirfd delivery failed; \ - the mediated fallback remains active" - ), - } - } - } } SessionCommand::UpdateSummary { summary } => { state.summary = summary; @@ -730,20 +674,6 @@ pub(super) async fn run_session_actor( let _ = reply.send(result); } SessionCommand::Shutdown { reply } => { - // Withdraw the session's delivered credentials (design doc - // §9): normal session end revokes every ACE this session was - // granted; crashed sessions are covered by the journal sweep. - #[cfg(windows)] - if let Some(kernel) = state.kernel.as_ref() - && let Some(authority) = kernel.fence_credentials() - && let Err(err) = authority.revoke_all() - { - tracing::warn!( - %err, - "session credential revocation on shutdown failed; \ - the startup journal sweep will retry" - ); - } let kernel = state.kernel.take(); let _ = reply.send(kernel); break; diff --git a/crates/server/src/runtime/session_actor/approval_scope.rs b/crates/server/src/runtime/session_actor/approval_scope.rs index 3ae16ba4..4509df5e 100644 --- a/crates/server/src/runtime/session_actor/approval_scope.rs +++ b/crates/server/src/runtime/session_actor/approval_scope.rs @@ -2,13 +2,6 @@ use std::path::{Component, Path, PathBuf}; use devo_protocol::ApprovalScopeValue; -/// Access class for credential delivery (design doc §9): Windows maps it to -/// the delivered ACE mask; the POSIX dirfd channel distinguishes the same two. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CredentialAccess { - Read, - Write, -} use devo_safety::RuntimePermissionProfile; use crate::execution::ApprovalGrantCache; @@ -175,37 +168,6 @@ pub(crate) fn path_prefix_grant_root(path: &Path) -> PathBuf { } } -/// Credential-delivery decision (design doc §8/§9, P2): which root and access -/// class, if any, a granted scope should deliver to the fenced kernel's -/// session SID as an ACE. -/// -/// Only PathPrefix approvals deliver — the user explicitly chose a directory -/// scope. Session-scope exact-file approvals deliberately do not (a directory -/// ACE would widen beyond what was approved; they stay mediated, with the -/// grant cache making repeat calls frictionless). Non-file resources deliver -/// nothing (no ACE shape exists for them). -pub(crate) fn credential_delivery_root( - scope: &ApprovalScopeValue, - pending: &PendingApproval, -) -> Option<(PathBuf, CredentialAccess)> { - if !matches!( - scope, - ApprovalScopeValue::PathPrefix | ApprovalScopeValue::PathPrefixPersist - ) { - return None; - } - let access = match pending.resource.as_ref() { - Some(devo_safety::ResourceKind::FileWrite) => CredentialAccess::Write, - Some(devo_safety::ResourceKind::FileRead) => CredentialAccess::Read, - Some(_) | None => return None, - }; - pending - .path - .as_deref() - .map(path_prefix_grant_root) - .map(|root| (root, access)) -} - /// Temporary Windows limitation: elevated sandbox tokens need both the /// sandbox account's normal access pass and the per-session restricting SID /// pass. A single SID ACE cannot deliver a native directory capability. @@ -242,7 +204,6 @@ mod tests { use super::apply_approval_scope_to_state; use super::apply_path_scope_to_permission_profile; - use super::credential_delivery_root; use super::normalize_permission_path; use super::path_prefix_grant_root; use super::windows_path_prefix_unsupported; @@ -778,41 +739,6 @@ mod tests { assert_eq!(profile.writable_roots, before_writable); } - #[test] - fn path_prefix_write_delivers_credential_root_session_scope_does_not() { - // P2 delivery decision (design doc §8/§9): an explicit PathPrefix - // approval delivers a directory ACE (write or read mask); Session - // exact-file and once stay on the mediated path. - let dir = abs_path(&["workspace", "src"]); - let file = dir.join("main.rs"); - - let prefix_write = file_pending_approval("write", ResourceKind::FileWrite, file.clone()); - assert_eq!( - credential_delivery_root(&ApprovalScopeValue::PathPrefix, &prefix_write), - Some((dir.clone(), super::CredentialAccess::Write)) - ); - - let prefix_read = file_pending_approval("read", ResourceKind::FileRead, file.clone()); - assert_eq!( - credential_delivery_root(&ApprovalScopeValue::PathPrefix, &prefix_read), - Some((dir.clone(), super::CredentialAccess::Read)), - "PathPrefix read approvals deliver a read-mask ACE" - ); - - let session_write = file_pending_approval("write", ResourceKind::FileWrite, file.clone()); - assert_eq!( - credential_delivery_root(&ApprovalScopeValue::Session, &session_write), - None, - "session exact-file approval must not deliver a widened directory ACE" - ); - - let once_write = file_pending_approval("write", ResourceKind::FileWrite, file); - assert_eq!( - credential_delivery_root(&ApprovalScopeValue::Once, &once_write), - None - ); - } - #[test] fn path_prefix_scope_allows_sibling_files_in_same_directory() { let dir = abs_path(&["workspace", "src"]); diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index 9659399a..dcf292f7 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -862,6 +862,7 @@ impl SessionHandle { } async fn shutdown_actor(&self) -> Option> { + devo_core::tools::host_bash::cancel_session(self.session_id.as_str()); let (reply_tx, reply_rx) = oneshot::channel(); if self .send(SessionCommand::Shutdown { reply: reply_tx }) diff --git a/crates/server/src/runtime/session_actor/state.rs b/crates/server/src/runtime/session_actor/state.rs index 08503fe8..9fb4a864 100644 --- a/crates/server/src/runtime/session_actor/state.rs +++ b/crates/server/src/runtime/session_actor/state.rs @@ -224,7 +224,8 @@ impl SessionActorState { leaf_epoch: 0, }; if let Some(path) = state.rollout_path.as_ref() - && let Ok((leaf_id, leaf_epoch)) = devo_core::read_rollout_session_leaf(path) + && let Ok((leaf_id, leaf_epoch)) = + devo_core::read_rollout_session_leaf_for_session(path, &state.summary.session_id()) { state.transcript_leaf_id = leaf_id; state.leaf_epoch = leaf_epoch; diff --git a/crates/server/src/runtime/session_cache.rs b/crates/server/src/runtime/session_cache.rs index 6fae1eaa..cc2b8abe 100644 --- a/crates/server/src/runtime/session_cache.rs +++ b/crates/server/src/runtime/session_cache.rs @@ -110,6 +110,15 @@ impl ServerRuntime { self: &Arc, session_id: SessionId, ) -> Result { + let _lifecycle_admission = + self.begin_lifecycle_operation(session_id) + .await + .map_err(|error| match error { + crate::runtime::session_deletion::LifecycleRootError::MissingEvidence { + .. + } => LoadSessionError::SessionNotFound, + other => LoadSessionError::RestoreFailed(other.to_string()), + })?; if let Some(handle) = self.session(session_id).await { self.touch_root_session_lru_if_needed(&handle).await; return Ok(handle); @@ -135,15 +144,31 @@ impl ServerRuntime { let is_subagent = index.session.agent_path.is_some(); let stored_rollout_path = index.rollout_path.clone(); - let rollout_path = match stored_rollout_path { - Some(ref path) if path.exists() => path.clone(), - Some(_) | None => self - .rollout_store - .find_rollout_by_session_id(&session_id) - .map_err(|error| LoadSessionError::RestoreFailed(error.to_string()))? - .filter(|path| path.exists()) - .ok_or(LoadSessionError::RolloutMissing)?, - }; + let rollout_path = self + .rollout_store + .resolve_session_journal( + &session_id, + stored_rollout_path.as_deref(), + crate::persistence::JournalVisibility::IncludeDeleted, + ) + .map_err(|error| LoadSessionError::RestoreFailed(error.to_string()))? + .or_else(|| { + self.rollout_store + .find_rollout_by_session_id(&session_id) + .ok() + .flatten() + }) + .filter(|path| path.exists()) + .ok_or(LoadSessionError::RolloutMissing)?; + + if self.rollout_store.journal_is_closed(&rollout_path) { + return Err(LoadSessionError::SessionNotFound); + } + if let Err(devo_core::HistoryReadError::SessionDeleted { .. }) = + devo_core::read_rollout_session_meta_for_session(&rollout_path, &session_id) + { + return Err(LoadSessionError::SessionNotFound); + } if stored_rollout_path.as_ref() != Some(&rollout_path) && let Err(error) = self @@ -154,18 +179,20 @@ impl ServerRuntime { tracing::warn!( session_id = %session_id, error = %error, - "failed to backfill rollout_path after suffix lookup" + "failed to backfill rollout_path after journal lookup" ); } // Cross-process exclusivity: refuse to hydrate a rollout another live // Devo process owns. Concurrent turns from two processes interleave // items/turn records into one corrupt history. - let session_lock = SessionFileLock::acquire(&rollout_path).map_err(|contention| { - LoadSessionError::SessionLocked { - holder_pid: contention.holder_pid, + let session_lock = match SessionFileLock::acquire(&rollout_path) { + Ok(lock) => lock, + Err(crate::runtime::session_file_lock::SessionLockError::Contention { holder_pid }) => { + return Err(LoadSessionError::SessionLocked { holder_pid }); } - })?; + Err(error) => return Err(LoadSessionError::RestoreFailed(error.to_string())), + }; let runtime_session = self .hydrate_runtime_session(session_id, &rollout_path) diff --git a/crates/server/src/runtime/session_deletion.rs b/crates/server/src/runtime/session_deletion.rs index 8e53e366..35a32aed 100644 --- a/crates/server/src/runtime/session_deletion.rs +++ b/crates/server/src/runtime/session_deletion.rs @@ -1,9 +1,72 @@ +use std::path::PathBuf; use std::sync::Arc; use devo_protocol::native::ids::SessionId; use super::{ServerRuntime, TerminalTurnSnapshot}; +#[derive(Debug, thiserror::Error)] +pub(crate) enum LifecycleRootError { + #[error("missing lifecycle evidence for {session_id} at {current}")] + MissingEvidence { + session_id: SessionId, + current: SessionId, + }, + #[error("cycle while resolving lifecycle root for {session_id}")] + Cycle { session_id: SessionId }, + #[error("storage error while resolving lifecycle root for {session_id}: {detail}")] + Storage { + session_id: SessionId, + detail: String, + }, + #[error("lifecycle admission error: {detail}")] + Admission { detail: String }, +} + +struct DeletePlan { + session_id: SessionId, + journal_path: Option, + session_dir: Option, + delete_root_tree: bool, +} + +#[derive(Default)] +pub(crate) struct LifecycleAdmission { + deleting: std::collections::HashSet, + active: std::collections::HashMap, +} + +pub(crate) struct LifecycleOperationGuard { + admission: Arc>, + root_session_id: SessionId, +} + +impl Drop for LifecycleOperationGuard { + fn drop(&mut self) { + if let Ok(mut admission) = self.admission.lock() + && let Some(count) = admission.active.get_mut(&self.root_session_id) + { + *count = count.saturating_sub(1); + if *count == 0 { + admission.active.remove(&self.root_session_id); + } + } + } +} + +pub(crate) struct DeleteAdmissionGuard { + admission: Arc>, + root_session_id: SessionId, +} + +impl Drop for DeleteAdmissionGuard { + fn drop(&mut self) { + if let Ok(mut admission) = self.admission.lock() { + admission.deleting.remove(&self.root_session_id); + } + } +} + impl ServerRuntime { async fn interrupt_session_for_delete(self: &Arc, session_id: SessionId) { self.command_exec_manager @@ -72,11 +135,388 @@ impl ServerRuntime { self.record_terminal_turn_status(turn_id, snapshot).await; } + /// Single authoritative lifecycle-root resolver. Uses the DB index first, + /// then raw persisted SessionMeta lineage (including tombstones/missing + /// index rows), and returns an error for unprovable chains or cycles. Spawn, + /// resume, admission, and delete all use this function. + pub(crate) async fn resolve_lifecycle_root( + &self, + session_id: SessionId, + fallback: Option<&crate::runtime_session_summary::RuntimeSessionSummary>, + ) -> Result { + let mut current = session_id; + let mut seen = std::collections::HashSet::new(); + loop { + if !seen.insert(current) { + return Err(LifecycleRootError::Cycle { session_id }); + } + match self.deps.db.get_session_index(¤t) { + Ok(Some(index)) => { + if index.session.agent_path.is_none() { + return Ok(current); + } + let Some(parent_session_id) = index.session.parent_session_id else { + return Err(LifecycleRootError::Storage { + session_id, + detail: format!("agent session {current} has no parent in the index"), + }); + }; + current = parent_session_id; + continue; + } + Ok(None) => {} + Err(error) => { + return Err(LifecycleRootError::Storage { + session_id, + detail: format!("read session index for {current}: {error}"), + }); + } + } + if current == session_id + && let Some(summary) = fallback + { + if summary.agent_path.is_none() { + return Ok(current); + } + if let Some(parent_session_id) = summary.parent_session_id() { + current = parent_session_id; + continue; + } + } + // Live runtime metadata covers ephemeral sessions that have no DB + // row and no journal, while keeping the same root key as durable + // ancestors. + if let Some(handle) = self.session(current).await + && let Some(summary) = handle.summary().await + { + if summary.agent_path.is_none() { + return Ok(current); + } + if let Some(parent_session_id) = summary.parent_session_id() { + current = parent_session_id; + continue; + } + } + match self + .rollout_store + .find_journal_containing_session_including_deleted(¤t) + { + Ok(Some(journal_path)) => match self + .rollout_store + .raw_session_parent(&journal_path, ¤t) + .map_err(|error| LifecycleRootError::Storage { + session_id, + detail: format!("read persisted lineage for {current}: {error}"), + })? { + Some(Some(parent_session_id)) => { + current = parent_session_id; + continue; + } + Some(None) => return Ok(current), + None => {} + }, + Ok(None) => {} + Err(error) => { + return Err(LifecycleRootError::Storage { + session_id, + detail: format!("scan journals for {current}: {error}"), + }); + } + } + return Err(LifecycleRootError::MissingEvidence { + session_id, + current, + }); + } + } + + async fn session_has_any_evidence(&self, session_id: SessionId) -> Result { + if self.session(session_id).await.is_some() { + return Ok(true); + } + if self.active_turns.active_turn_id(session_id).await.is_some() { + return Ok(true); + } + if self + .deps + .db + .get_session_index(&session_id) + .map_err(|error| format!("failed to read session index: {error}"))? + .is_some() + { + return Ok(true); + } + Ok(self + .rollout_store + .find_journal_containing_session_strict( + &session_id, + crate::persistence::JournalVisibility::IncludeDeleted, + ) + .map_err(|error| format!("failed to scan session journal: {error}"))? + .is_some()) + } + + /// Reserve the lifecycle root for a spawn/resume. The guard is synchronous + /// and drop-safe, so an aborted future cannot leave a stale marker. + pub(crate) fn begin_lifecycle_operation_for_root( + &self, + root_session_id: SessionId, + ) -> Result { + let mut admission = self + .lifecycle_admission + .lock() + .expect("lifecycle admission mutex poisoned"); + if admission.deleting.contains(&root_session_id) { + return Err(format!( + "session deletion is in progress for {root_session_id}" + )); + } + *admission.active.entry(root_session_id).or_default() += 1; + Ok(LifecycleOperationGuard { + admission: Arc::clone(&self.lifecycle_admission), + root_session_id, + }) + } + + pub(crate) async fn begin_lifecycle_operation( + &self, + session_id: SessionId, + ) -> Result { + let root_session_id = self.resolve_lifecycle_root(session_id, None).await?; + self.begin_lifecycle_operation_for_root(root_session_id) + .map_err(|detail| LifecycleRootError::Admission { detail }) + } + + fn begin_delete_admission_for_root( + &self, + root_session_id: SessionId, + ) -> Result { + let mut admission = self + .lifecycle_admission + .lock() + .expect("lifecycle admission mutex poisoned"); + if admission.deleting.contains(&root_session_id) { + return Err(format!( + "session deletion already in progress for {root_session_id}" + )); + } + if admission.active.get(&root_session_id).copied().unwrap_or(0) > 0 { + return Err(format!( + "spawn/resume is in progress for {root_session_id}; retry deletion" + )); + } + admission.deleting.insert(root_session_id); + Ok(DeleteAdmissionGuard { + admission: Arc::clone(&self.lifecycle_admission), + root_session_id, + }) + } + pub(crate) async fn delete_session_tree( self: &Arc, root_session_id: SessionId, ) -> Result, String> { - let session_ids = self.collect_session_delete_tree(root_session_id).await; + let session_ids = self.collect_session_delete_tree(root_session_id).await?; + let target_has_evidence = self.session_has_any_evidence(root_session_id).await?; + if !target_has_evidence && session_ids.len() <= 1 { + return Ok(Vec::new()); + } + let lifecycle_root = match self.resolve_lifecycle_root(root_session_id, None).await { + Ok(root) => root, + Err(LifecycleRootError::MissingEvidence { .. }) + if target_has_evidence || session_ids.len() > 1 => + { + // Delete-only fallback for an orphan target with proven DB/ + // runtime/journal or descendant evidence. Cycles/storage + // failures are never swallowed. + root_session_id + } + Err(error) => return Err(error.to_string()), + }; + let _delete_guard = self.begin_delete_admission_for_root(lifecycle_root)?; + // Recollect under the admission guard: a spawn/resume could have + // completed between the pre-check collection and this point. + let session_ids = self.collect_session_delete_tree(root_session_id).await?; + let deleting_root = lifecycle_root == root_session_id; + self.delete_session_tree_inner(root_session_id, deleting_root, session_ids) + .await + } + + async fn delete_session_tree_inner( + self: &Arc, + root_session_id: SessionId, + deleting_root: bool, + session_ids: Vec, + ) -> Result, String> { + // Lease every physical journal in the tree before any mutation. Sort by + // the canonical key so concurrent deletes acquire in one order. Missing + // journals are skipped; a real lock error aborts before interruption. + // Lease the initial target journals, then recollect under those leases. + // Cross-process spawn/resume must take the same journal lease; a daemon + // completing before our lease shows up in the recollected DB tree. + let initial_paths = self.session_journal_paths(&session_ids)?; + let mut _journal_leases = Vec::new(); + let mut leased_keys = std::collections::HashSet::new(); + for path in &initial_paths { + let lease = crate::runtime::session_file_lock::SessionFileLock::acquire(path) + .map_err(|error| format!("failed to lock journal before delete: {error}"))?; + _journal_leases.push(lease); + leased_keys.insert( + crate::journal_path::canonical_journal_key(path) + .map_err(|error| format!("canonical journal key: {error}"))?, + ); + } + let session_ids = self.collect_session_delete_tree(root_session_id).await?; + let added_paths = self.session_journal_paths(&session_ids)?; + for path in added_paths { + let key = crate::journal_path::canonical_journal_key(&path) + .map_err(|error| format!("canonical journal key: {error}"))?; + if leased_keys.insert(key) { + let lease = crate::runtime::session_file_lock::SessionFileLock::acquire(&path) + .map_err(|error| { + format!("failed to lock added journal before delete: {error}") + })?; + _journal_leases.push(lease); + } + } + // One final collection under all leases. No unbounded retry: if a new + // descendant journal appeared despite the leases, fail closed. + let session_ids = self.collect_session_delete_tree(root_session_id).await?; + let final_paths = self.session_journal_paths(&session_ids)?; + for path in &final_paths { + let key = crate::journal_path::canonical_journal_key(path) + .map_err(|error| format!("canonical journal key: {error}"))?; + if !leased_keys.contains(&key) { + return Err(format!( + "descendant journal set changed during lease acquisition: {}", + path.display() + )); + } + } + + let mut plans: Vec = Vec::new(); + for session_id in &session_ids { + let index = self + .deps + .db + .get_session_index(session_id) + .map_err(|error| format!("failed to read session index: {error}"))?; + let indexed_path = index.as_ref().and_then(|index| index.rollout_path.clone()); + let path = self + .rollout_store + .resolve_session_journal( + session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::IncludeDeleted, + ) + .map_err(|error| { + format!("failed to resolve session journal for {session_id}: {error}") + })?; + let resolved_root = self.resolve_lifecycle_root(*session_id, None).await; + let target_is_agent = match &resolved_root { + Ok(root) => *root != *session_id, + Err(LifecycleRootError::MissingEvidence { .. }) => { + index.as_ref().is_some_and(|index| { + index.session.agent_path.is_some() + && index.session.parent_session_id.is_some_and(|parent| { + self.deps + .db + .get_session_index(&parent) + .ok() + .flatten() + .is_none() + }) + }) + } + Err(error) => return Err(error.to_string()), + }; + let fallback_child_dir = match &resolved_root { + Ok(root) if *root != *session_id => { + Some(self.rollout_store.child_session_dir(root, session_id)) + } + Ok(_) | Err(_) => None, + }; + // A recorded index path is trusted only when it is this session's + // own expected filename and its legacy dir sits under the proven + // lifecycle root. Unknown ownership skips cleanup; never delete an + // unrelated descendant dir. + let recorded_session_dir = match (indexed_path.as_ref(), &resolved_root) { + (Some(path), Ok(root)) + if path.file_name().and_then(|name| name.to_str()) + == Some(format!("{session_id}.jsonl").as_str()) + && *root != *session_id => + { + let root_dir = self.rollout_store.root_session_dir(root); + let root_dir = std::fs::canonicalize(&root_dir).unwrap_or(root_dir); + let expected_child_dir = self.rollout_store.child_session_dir(root, session_id); + let expected_child_dir = + std::fs::canonicalize(&expected_child_dir).unwrap_or(expected_child_dir); + self.rollout_store + .session_dir_for(path, session_id) + .filter(|dir| { + if !self.rollout_store.is_child_artifact_dir(dir) { + return false; + } + if *dir == expected_child_dir { + return true; + } + let Ok(relative) = dir.strip_prefix(&root_dir) else { + return false; + }; + // Legacy ancestry: every segment below the root is + // a sub-xxxxxxxx directory. + let segments: Vec<_> = relative + .components() + .filter_map(|component| component.as_os_str().to_str()) + .collect(); + !segments.is_empty() + && segments.iter().all(|segment| segment.starts_with("sub-")) + }) + } + (Some(path), _) => { + tracing::warn!( + session_id = %session_id, + path = %path.display(), + "skipping unproven recorded artifact dir during delete" + ); + None + } + (None, _) => None, + }; + let session_dir = path + .as_ref() + .and_then(|journal_path| { + self.rollout_store.session_dir_for(journal_path, session_id) + }) + .or(recorded_session_dir) + .or(fallback_child_dir); + let delete_root_tree = + deleting_root && *session_id == root_session_id && !target_is_agent; + plans.push(DeletePlan { + session_id: *session_id, + journal_path: path, + session_dir, + delete_root_tree, + }); + } + // Persist deletion markers before any fallible teardown (interrupt, + // actor removal, close, unlink). A crash after this point cannot + // resurrect any target in the tree; retries can finish the delete. + for plan in &plans { + // No journal file means a prior delete already unlinked it; still + // continue DB/artifact cleanup idempotently. Lookup errors were + // already propagated by strict resolution above. + if let Some(path) = plan.journal_path.as_ref().filter(|path| path.exists()) { + self.rollout_store + .append_session_deleted_at(path, plan.session_id, chrono::Utc::now()) + .map_err(|error| { + format!( + "failed to persist session deletion marker for {}: {error}", + plan.session_id + ) + })?; + } + } // Interrupt the whole tree together; a blocked child must not delay // cancellation of its siblings or multiply the grace period. futures::future::join_all( @@ -97,8 +537,14 @@ impl ServerRuntime { (session_id, removed.is_some()) })) .await; - let mut deleted_session_ids = Vec::new(); + let mut removed_by_id = std::collections::HashMap::new(); for (session_id, removed) in removed_sessions { + removed_by_id.insert(session_id, removed); + } + let mut deleted_session_ids = Vec::new(); + for plan in plans { + let session_id = plan.session_id; + let removed = removed_by_id.get(&session_id).copied().unwrap_or(false); let persisted = self .deps .db @@ -106,8 +552,13 @@ impl ServerRuntime { .map_err(|error| format!("failed to inspect session before delete: {error}"))?; let deleted_rollout = self .rollout_store - .delete_session_rollouts(&session_id) - .map_err(|error| format!("failed to delete session rollout: {error}"))?; + .delete_session_journal( + &session_id, + plan.journal_path.as_deref(), + plan.session_dir.as_deref(), + plan.delete_root_tree, + ) + .map_err(|error| format!("failed to delete session journal: {error}"))?; if persisted.is_some() { self.deps .db @@ -129,9 +580,45 @@ impl ServerRuntime { Ok(deleted_session_ids) } - async fn collect_session_delete_tree(&self, root_session_id: SessionId) -> Vec { + fn session_journal_paths(&self, session_ids: &[SessionId]) -> Result, String> { + let mut keyed = Vec::new(); + for session_id in session_ids { + let index = self + .deps + .db + .get_session_index(session_id) + .map_err(|error| format!("failed to read session index: {error}"))?; + let indexed_path = index.as_ref().and_then(|index| index.rollout_path.clone()); + let path = self + .rollout_store + .resolve_session_journal( + session_id, + indexed_path.as_deref(), + crate::persistence::JournalVisibility::IncludeDeleted, + ) + .map_err(|error| { + format!("failed to resolve session journal for {session_id}: {error}") + })?; + if let Some(path) = path.filter(|path| path.exists()) { + let key = crate::journal_path::canonical_journal_key(&path).map_err(|error| { + format!("canonical journal key for {}: {error}", path.display()) + })?; + keyed.push((key, path)); + } + } + keyed.sort_by(|left, right| left.0.cmp(&right.0)); + keyed.dedup_by(|left, right| left.0 == right.0); + Ok(keyed.into_iter().map(|(_, path)| path).collect()) + } + + async fn collect_session_delete_tree( + &self, + root_session_id: SessionId, + ) -> Result, String> { // Cascade only sub-agent children (`parent_session_id` + agent markers). // User forks use `fork_from_id` and must remain after the source is deleted. + // Runtime actors cover live sessions; the SQLite index covers children + // that were never hydrated after a restart. let session_ids_in_runtime: Vec = { let sessions = self.sessions.lock().await; sessions.keys().cloned().collect() @@ -151,7 +638,28 @@ impl ServerRuntime { agent_children_by_parent.push((session_id, parent_id)); } } + let rows = self + .deps + .db + .list_sessions() + .map_err(|error| format!("failed to list session index: {error}"))?; + for row in rows { + if row.agent_path.is_some() + && let Some(parent_id) = row.parent_session_id + { + agent_children_by_parent.push((row.session_id, parent_id)); + } + } + // Retry after tombstone+DB removal: the index/runtime can no longer + // see descendants. Raw persisted SessionMeta parent edges keep the + // whole subtree addressable for cleanup. Damaged journals are fatal. + let persisted_edges = self + .rollout_store + .persisted_agent_edges() + .map_err(|error| format!("failed to read persisted agent lineage: {error}"))?; + agent_children_by_parent.extend(persisted_edges); agent_children_by_parent.sort_by_key(|(session_id, _parent_id)| session_id.to_string()); + agent_children_by_parent.dedup(); let mut seen = std::collections::HashSet::new(); let mut session_ids = Vec::new(); @@ -167,7 +675,7 @@ impl ServerRuntime { } index += 1; } - session_ids + Ok(session_ids) } pub(crate) async fn await_session_turn_interrupt_before_delete( diff --git a/crates/server/src/runtime/session_deletion_tests.rs b/crates/server/src/runtime/session_deletion_tests.rs index ae03d901..5d952d00 100644 --- a/crates/server/src/runtime/session_deletion_tests.rs +++ b/crates/server/src/runtime/session_deletion_tests.rs @@ -119,3 +119,86 @@ async fn concurrent_deletion_aborts_turns_that_ignore_cooperative_cancellation() assert!(expected_turns.is_empty()); assert!(notifications.try_recv().is_err()); } + +#[tokio::test] +async fn lifecycle_admission_guards_are_drop_safe_and_mutually_exclusive() { + let directory = tempfile::tempdir().unwrap(); + let runtime = TestRuntime::new(Arc::new(NoopProvider::default())).runtime(directory.path()); + let root_id = SessionId::new(); + + let operation = runtime + .begin_lifecycle_operation_for_root(root_id) + .expect("spawn/resume reservation"); + assert!( + runtime.begin_delete_admission_for_root(root_id).is_err(), + "delete must not start while spawn/resume reservation is active" + ); + drop(operation); + + let delete = runtime + .begin_delete_admission_for_root(root_id) + .expect("delete admission after reservation drop"); + assert!( + runtime.begin_lifecycle_operation_for_root(root_id).is_err(), + "spawn/resume must not start while delete marker is active" + ); + drop(delete); + + runtime + .begin_lifecycle_operation_for_root(root_id) + .expect("aborted/dropped delete must release admission"); +} + +#[tokio::test] +async fn lifecycle_root_cycle_fails_without_delete_mutation() { + use crate::db::SessionIndexRow; + use std::path::PathBuf; + + let directory = tempfile::tempdir().unwrap(); + let runtime = TestRuntime::new(Arc::new(NoopProvider::default())).runtime(directory.path()); + let first = SessionId::new(); + let second = SessionId::new(); + let now = chrono::Utc::now(); + let row = |id: SessionId, parent: SessionId| SessionIndexRow { + session_id: id, + cwd: PathBuf::from(directory.path()), + additional_directories: Vec::new(), + created_at: now, + updated_at: now, + last_activity_at: now, + title: Some("cycle".into()), + title_state: devo_core::SessionTitleState::Unset, + parent_session_id: Some(parent), + fork_from_id: None, + fork_at_turn_id: None, + agent_path: Some("subagent".into()), + ephemeral: false, + model: Some("test-model".into()), + reasoning_effort_selection: None, + }; + runtime + .deps + .db + .upsert_session(row(first, second), None) + .expect("first row"); + runtime + .deps + .db + .upsert_session(row(second, first), None) + .expect("second row"); + + let error = runtime + .resolve_lifecycle_root(first, None) + .await + .expect_err("cycle must fail"); + assert!( + matches!(error, super::LifecycleRootError::Cycle { .. }), + "unexpected error: {error}" + ); + let result = runtime.delete_session_tree(first).await; + assert!(result.is_err(), "cycle delete must fail closed"); + assert!( + runtime.deps.db.get_session_index(&first).unwrap().is_some(), + "failed delete must not mutate DB rows" + ); +} diff --git a/crates/server/src/runtime/session_file_lock.rs b/crates/server/src/runtime/session_file_lock.rs index c1bb4e0b..4160ddc6 100644 --- a/crates/server/src/runtime/session_file_lock.rs +++ b/crates/server/src/runtime/session_file_lock.rs @@ -1,8 +1,15 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::sync::{Arc, Mutex, OnceLock}; -static HELD_LOCKS: OnceLock>>> = OnceLock::new(); +use crate::journal_path::canonical_journal_key; + +struct HeldLock { + file: Arc, + owners: usize, +} + +static HELD_LOCKS: OnceLock>> = OnceLock::new(); /// Exclusive cross-process lock on a session rollout. /// @@ -15,7 +22,59 @@ static HELD_LOCKS: OnceLock>>> = Once /// releases the flock when the holding process dies, so a crashed Devo never /// leaves a stale lock behind. pub(crate) struct SessionFileLock { - _file: Arc, + file: Option>, + /// Canonical lock-file key in [`HELD_LOCKS`]. + key: PathBuf, +} + +impl Drop for SessionFileLock { + fn drop(&mut self) { + let Some(file) = self.file.take() else { + return; + }; + // Keep the registry entry strong while any owner remains and close the + // final File handle while holding the registry mutex. A concurrent + // acquire therefore either joins the live entry or opens only after + // the OS lock is released; it can never observe a half-destroyed entry. + let mut held = HELD_LOCKS + .get_or_init(Default::default) + .lock() + .expect("session file lock registry mutex should not be poisoned"); + match held.get_mut(&self.key) { + Some(entry) if entry.owners > 1 => { + entry.owners -= 1; + drop(file); + } + Some(entry) if entry.owners == 1 => { + // flock is tied to the open file description, which fork + // shares. Explicitly unlock before closing so an inherited + // child descriptor cannot keep the journal locked. + if let Err(error) = fs2::FileExt::unlock(entry.file.as_ref()) { + tracing::error!( + path = %self.key.display(), + %error, + "failed to unlock session journal before close" + ); + } + let entry = held + .remove(&self.key) + .expect("held lock entry disappeared during drop"); + // Close both the registry handle and this guard's handle while + // still holding the registry mutex. The next acquire cannot + // open a second File before this close completes. + drop(entry.file); + drop(file); + } + Some(_) => { + tracing::error!("session lock owner count underflow during drop"); + drop(file); + } + None => { + tracing::error!("session lock entry missing during drop"); + drop(file); + } + } + } } impl std::fmt::Debug for SessionFileLock { @@ -28,17 +87,14 @@ impl SessionFileLock { /// Acquire the exclusive lock for `rollout_path`. On contention, the /// current holder's pid (written into the lock file) is returned so the /// refusal can name the conflicting process. - pub(crate) fn acquire(rollout_path: &std::path::Path) -> Result { - use fs2::FileExt; - use std::io::Seek as _; - use std::io::Write as _; - - let lock_path = session_lock_path(rollout_path); - let key = lock_path - .parent() - .and_then(|parent| parent.canonicalize().ok()) - .map(|parent| parent.join(lock_path.file_name().expect("lock file name"))) - .unwrap_or_else(|| lock_path.clone()); + pub(crate) fn acquire(rollout_path: &std::path::Path) -> Result { + let canonical = + canonical_journal_key(rollout_path).map_err(|source| SessionLockError::Io { + path: rollout_path.to_path_buf(), + source, + })?; + let lock_path = session_lock_path(&canonical); + let key = lock_path.clone(); // Retain one locked handle until the last actor in this process releases it. // Windows denies reads through a second handle to a locked file, so a // pid-based contention bypass cannot work there. @@ -46,109 +102,138 @@ impl SessionFileLock { .get_or_init(Default::default) .lock() .expect("session file lock registry mutex should not be poisoned"); - held.retain(|_, file| file.strong_count() != 0); - if let Some(file) = held.get(&key).and_then(Weak::upgrade) { - return Ok(Self { _file: file }); + if let Some(entry) = held.get_mut(&key) { + entry.owners += 1; + return Ok(Self { + file: Some(Arc::clone(&entry.file)), + key, + }); } - let file = match std::fs::OpenOptions::new() + let file = std::fs::OpenOptions::new() .create(true) .read(true) .write(true) .truncate(false) .open(&lock_path) - { - Ok(file) => file, - Err(error) => { - // Without a lock file we cannot enforce exclusivity; refusing - // to open would brick sessions on read-only homes, so proceed - // unlocked (same behavior as before the lock existed). - tracing::warn!( - path = %lock_path.display(), - %error, - "cannot open session lock file; continuing without cross-process lock" - ); - return Ok(Self { - _file: Arc::new(open_sentinel_file()), - }); - } - }; - if let Err(error) = file.try_lock_exclusive() { - let mut holder_pid = String::new(); - { - let mut readable = &file; - let _ = readable.rewind(); - let _ = std::io::Read::read_to_string(&mut readable, &mut holder_pid); - } - let holder_pid = holder_pid.trim().parse::().unwrap_or(0); - #[cfg(windows)] - let holder_pid = std::fs::read_to_string(lock_path.with_extension("lock.owner")) - .ok() - .and_then(|value| value.trim().parse().ok()) - .unwrap_or(holder_pid); - tracing::warn!( - path = %lock_path.display(), - %error, - holder_pid, - "session rollout is locked by another Devo process" - ); - return Err(SessionLockContention { holder_pid }); + .map_err(|source| SessionLockError::Io { + path: lock_path.clone(), + source, + })?; + lock_exclusive(&file, &lock_path)?; + let file = Arc::new(file); + held.insert( + key.clone(), + HeldLock { + file: Arc::clone(&file), + owners: 1, + }, + ); + Ok(Self { + file: Some(file), + key, + }) + } +} + +fn lock_exclusive( + file: &std::fs::File, + lock_path: &std::path::Path, +) -> Result<(), SessionLockError> { + use fs2::FileExt; + use std::io::Seek as _; + use std::io::Write as _; + + if let Err(error) = file.try_lock_exclusive() { + let contended = error.kind() == std::io::ErrorKind::WouldBlock + || error.raw_os_error() == fs2::lock_contended_error().raw_os_error(); + if !contended { + return Err(SessionLockError::Io { + path: lock_path.to_path_buf(), + source: error, + }); } - // Record this pid for contention reports; best-effort. + let mut holder_pid = String::new(); { - let mut writable = &file; - let _ = writable.rewind(); - let _ = writable.set_len(0); - let _ = writeln!(writable, "{}", std::process::id()); + let mut readable = file; + let _ = readable.rewind(); + let _ = std::io::Read::read_to_string(&mut readable, &mut holder_pid); } - // LockFileEx prevents other handles from reading the locked bytes. - // Separate Windows diagnostic metadata is consulted only after an OS - // lock attempt fails; stale metadata cannot lock a session. + let holder_pid = holder_pid.trim().parse::().unwrap_or(0); #[cfg(windows)] - let _ = std::fs::write( - lock_path.with_extension("lock.owner"), - std::process::id().to_string(), + let holder_pid = std::fs::read_to_string(lock_path.with_extension("lock.owner")) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(holder_pid); + tracing::warn!( + path = %lock_path.display(), + %error, + holder_pid, + "session rollout is locked by another Devo process" ); - let file = Arc::new(file); - held.insert(key, Arc::downgrade(&file)); - Ok(Self { _file: file }) + return Err(SessionLockError::Contention { holder_pid }); } + { + let mut writable = file; + let _ = writable.rewind(); + let _ = writable.set_len(0); + let _ = writeln!(writable, "{}", std::process::id()); + } + #[cfg(windows)] + let _ = std::fs::write( + lock_path.with_extension("lock.owner"), + std::process::id().to_string(), + ); + Ok(()) } -/// A lock attempt lost to a live holder. -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct SessionLockContention { - pub(crate) holder_pid: u32, -} - -/// Stand-in file handle for environments where the lock file cannot be -/// created (keeps the struct shape uniform; grants no lock). -fn open_sentinel_file() -> std::fs::File { - std::fs::OpenOptions::new() - .read(true) - .open("/dev/null") - .unwrap_or_else(|_| { - // Windows fallback: an invalid handle never matters because the - // flock itself was already skipped. - std::fs::File::create(std::env::temp_dir().join(".devo-session-lock-sentinel")) - .unwrap_or_else(|_| unreachable!("temp dir must be writable")) - }) +/// A lock attempt failed either because another process holds it or because +/// the lock file itself could not be resolved/opened. +#[derive(Debug, thiserror::Error)] +pub(crate) enum SessionLockError { + #[error("session rollout is locked by another Devo process (pid {holder_pid})")] + Contention { holder_pid: u32 }, + #[error("session lock I/O error for {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, } -fn session_lock_path(rollout_path: &std::path::Path) -> std::path::PathBuf { - let mut name = rollout_path +fn session_lock_path(canonical_journal: &std::path::Path) -> std::path::PathBuf { + let mut name = canonical_journal .file_name() .map(std::ffi::OsString::from) .unwrap_or_else(|| std::ffi::OsString::from("session")); name.push(".lock"); - rollout_path.with_file_name(name) + canonical_journal.with_file_name(name) } #[cfg(test)] mod tests { use super::*; - use fs2::FileExt; use pretty_assertions::assert_eq; + #[cfg(unix)] + struct InheritedFdHelper { + child: Option, + sync: Option, + } + + #[cfg(unix)] + impl Drop for InheritedFdHelper { + fn drop(&mut self) { + use std::io::Write as _; + if let Some(mut sync) = self.sync.take() { + let _ = sync.write_all(&[2]); + } + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + #[test] fn session_lock_is_retained_until_last_same_process_owner_drops() { let dir = tempfile::tempdir().expect("tempdir"); @@ -156,41 +241,201 @@ mod tests { std::fs::write(&rollout, "").expect("rollout file"); let first = SessionFileLock::acquire(&rollout).expect("first acquire"); let second = SessionFileLock::acquire(&rollout).expect("same-process acquire"); - assert!(Arc::ptr_eq(&first._file, &second._file)); + assert!(Arc::ptr_eq( + first.file.as_ref().expect("first file"), + second.file.as_ref().expect("second file") + )); - let contender = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(session_lock_path(&rollout)) - .expect("contender handle"); - assert!(contender.try_lock_exclusive().is_err()); + run_lock_probe(&rollout, /*expect_free*/ false); drop(first); - assert!( - contender.try_lock_exclusive().is_err(), - "second owner must retain lock" - ); + run_lock_probe(&rollout, /*expect_free*/ false); + drop(second); + run_lock_probe(&rollout, /*expect_free*/ true); + SessionFileLock::acquire(&rollout).expect("re-acquire after drop"); + } - let probe = std::process::Command::new(std::env::current_exe().expect("test executable")) + fn run_lock_probe(rollout: &std::path::Path, expect_free: bool) { + let mut command = + std::process::Command::new(std::env::current_exe().expect("test executable")); + command .args([ "--exact", "runtime::session_file_lock::tests::external_process_cannot_acquire", ]) - .env("DEVO_LOCK_PROBE_PATH", &rollout) + .env("DEVO_LOCK_PROBE_PATH", rollout) .env("DEVO_LOCK_PROBE_HOLDER", std::process::id().to_string()) - .output() - .expect("cross-process lock probe"); + .env( + "DEVO_LOCK_PROBE_EXPECT", + if expect_free { "free" } else { "held" }, + ); + let probe = command.output().expect("cross-process lock probe"); assert!( probe.status.success(), "{}", String::from_utf8_lossy(&probe.stdout) ); - drop(second); - contender - .try_lock_exclusive() - .expect("last owner releases lock"); - FileExt::unlock(&contender).expect("unlock contender"); - drop(contender); - SessionFileLock::acquire(&rollout).expect("re-acquire after drop"); + } + + #[test] + fn registry_entry_and_fd_are_released_after_last_drop() { + let dir = tempfile::tempdir().expect("tempdir"); + let rollout = dir.path().join("ses_registry_release.jsonl"); + std::fs::write(&rollout, "").expect("rollout file"); + let key = session_lock_path(&canonical_journal_key(&rollout).expect("canonical journal")); + { + let guard = SessionFileLock::acquire(&rollout).expect("acquire"); + { + let held = HELD_LOCKS + .get_or_init(Default::default) + .lock() + .expect("registry"); + assert_eq!(held.get(&key).map(|entry| entry.owners), Some(1)); + } + drop(guard); + } + let held = HELD_LOCKS + .get_or_init(Default::default) + .lock() + .expect("registry"); + assert!( + !held.contains_key(&key), + "last drop must remove registry entry and close its fd" + ); + } + + #[test] + fn concurrent_acquire_drop_same_journal_never_reports_contention() { + let dir = tempfile::tempdir().expect("tempdir"); + let rollout = dir + .path() + .join(format!("ses_acquire_drop_{}.jsonl", uuid::Uuid::new_v4())); + std::fs::write(&rollout, "").expect("rollout file"); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let handles: Vec<_> = (0..8) + .map(|_| { + let rollout = rollout.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + for _ in 0..200 { + let guard = SessionFileLock::acquire(&rollout) + .expect("same-process acquire must not report contention"); + drop(guard); + } + }) + }) + .collect(); + for handle in handles { + handle.join().expect("acquire/drop worker"); + } + } + + #[cfg(unix)] + #[test] + fn last_drop_unlock_releases_lock_held_by_inherited_descriptor() { + use std::io::Read as _; + use std::io::Write as _; + use std::os::unix::io::AsRawFd; + use std::os::unix::net::UnixStream; + + let dir = tempfile::tempdir().expect("tempdir"); + let rollout = dir.path().join("ses_inherited_lock.jsonl"); + std::fs::write(&rollout, "").expect("rollout file"); + let guard = SessionFileLock::acquire(&rollout).expect("acquire"); + let lock_fd = guard.file.as_ref().expect("lock file").as_raw_fd(); + let (parent_sync, child_sync) = UnixStream::pair().expect("sync pair"); + let sync_fd = child_sync.as_raw_fd(); + let mut command = + std::process::Command::new(std::env::current_exe().expect("test executable")); + command + .args([ + "--exact", + "runtime::session_file_lock::tests::inherited_lock_descriptor_helper", + ]) + .env("DEVO_INHERITED_LOCK_FD", lock_fd.to_string()) + .env("DEVO_INHERITED_SYNC_FD", sync_fd.to_string()); + // Only the child clears CLOEXEC, in pre_exec. Parent fds stay CLOEXEC; + // unrelated parallel spawns never see these descriptors. + unsafe { + use std::os::unix::process::CommandExt; + command.pre_exec(move || { + for fd in [lock_fd, sync_fd] { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } + let child = command.spawn().expect("spawn inherited-fd helper"); + drop(child_sync); + let mut helper = InheritedFdHelper { + child: Some(child), + sync: Some(parent_sync), + }; + let mut ready = [0u8; 1]; + helper + .sync + .as_mut() + .expect("parent sync") + .read_exact(&mut ready) + .expect("child inherited-fd ready"); + assert_eq!(ready, [1]); + + // The child still owns an inherited descriptor for the locked OFD. + // Last-owner Drop must unlock the shared OFD before closing. + drop(guard); + let reacquired = SessionFileLock::acquire(&rollout) + .expect("parent unlock must release the shared OFD lock"); + drop(reacquired); + + helper + .sync + .as_mut() + .expect("parent sync") + .write_all(&[2]) + .expect("release helper"); + let status = helper + .child + .as_mut() + .expect("child") + .wait() + .expect("inherited-fd helper joins"); + helper.child = None; + assert!(status.success(), "inherited-fd helper failed: {status}"); + } + + #[cfg(unix)] + #[test] + fn inherited_lock_descriptor_helper() { + use std::io::Read as _; + use std::io::Write as _; + use std::os::unix::io::FromRawFd; + + let Some(lock_fd) = std::env::var("DEVO_INHERITED_LOCK_FD") + .ok() + .and_then(|value| value.parse::().ok()) + else { + return; + }; + let sync_fd = std::env::var("DEVO_INHERITED_SYNC_FD") + .expect("sync fd") + .parse::() + .expect("numeric sync fd"); + assert!( + unsafe { libc::fcntl(lock_fd, libc::F_GETFD) } >= 0, + "inherited lock fd must still be open" + ); + let mut sync = unsafe { std::os::unix::net::UnixStream::from_raw_fd(sync_fd) }; + sync.write_all(&[1]).expect("helper ready"); + let mut release = [0u8; 1]; + sync.read_exact(&mut release).expect("helper release"); + assert_eq!(release, [2]); + // Keep the inherited lock fd open until process exit. } #[test] @@ -198,14 +443,21 @@ mod tests { let Some(path) = std::env::var_os("DEVO_LOCK_PROBE_PATH") else { return; }; - let holder_pid = std::env::var("DEVO_LOCK_PROBE_HOLDER") + let expect = std::env::var("DEVO_LOCK_PROBE_EXPECT").unwrap_or_else(|_| "held".into()); + if expect == "free" { + SessionFileLock::acquire(std::path::Path::new(&path)) + .expect("probe expected a free lock"); + return; + } + let holder_pid: u32 = std::env::var("DEVO_LOCK_PROBE_HOLDER") .expect("holder pid") .parse() .expect("numeric holder pid"); - assert_eq!( - SessionFileLock::acquire(std::path::Path::new(&path)) - .expect_err("another process holds lock"), - SessionLockContention { holder_pid }, - ); + match SessionFileLock::acquire(std::path::Path::new(&path)) + .expect_err("another process holds lock") + { + SessionLockError::Contention { holder_pid: actual } => assert_eq!(actual, holder_pid), + other => panic!("expected contention, got {other:?}"), + } } } diff --git a/crates/server/src/runtime/turn_exec/approval_resume.rs b/crates/server/src/runtime/turn_exec/approval_resume.rs index 7f9a051f..bf848870 100644 --- a/crates/server/src/runtime/turn_exec/approval_resume.rs +++ b/crates/server/src/runtime/turn_exec/approval_resume.rs @@ -169,7 +169,8 @@ impl ServerRuntime { ) -> Option { let handle = self.session(owner_session_id).await?; let rollout_path = handle.rollout_path().await??; - let history = devo_core::read_canonical_history(&rollout_path).ok()?; + let history = + devo_core::read_canonical_history_for_session(&rollout_path, &owner_session_id).ok()?; let checkpoints = &history.approval_checkpoints; let recovered = super::super::interaction_items::latest_waiting_approvals(&history.items, checkpoints) @@ -189,8 +190,11 @@ impl ServerRuntime { ) -> Option { let handle = self.session(session_id).await?; let rollout_path = handle.rollout_path().await??; - super::super::approval_checkpoint::latest_approval_checkpoints_for_rollout(&rollout_path) - .remove(approval_id) + super::super::approval_checkpoint::latest_approval_checkpoints_for_rollout( + &rollout_path, + &session_id, + ) + .remove(approval_id) } /// Handles an approval decision from a restored or live interactive lane. @@ -623,7 +627,8 @@ impl ServerRuntime { Some(Some(path)) => path, _ => return false, }; - let Ok(history) = devo_core::read_canonical_history(&rollout_path) else { + let Ok(history) = devo_core::read_canonical_history_for_session(&rollout_path, &session_id) + else { return false; }; let turn_id = turn_id.to_string(); @@ -668,10 +673,7 @@ impl ServerRuntime { } }) }); - let session_dir = - working.state.rollout_path.as_ref().and_then(|path| { - crate::persistence::RolloutStore::rlm_session_dir_for_rollout(path) - }); + let session_dir = working.state.summary.session_dir.clone(); let kernel = match ensure_kernel_with_restore_state( &working.state.kernel, &working.state.core.cwd, @@ -737,6 +739,7 @@ impl ServerRuntime { file_read_ledger: Arc::clone(&working.state.file_read_ledger), sandbox_profile: working.state.core.config.sandbox_profile.clone(), cancel_token: host_cancel_token.clone(), + kernel_lifetime: Some(kernel.lifetime_token()), mcp_manager: Some(Arc::clone(&working.state.runtime_context.mcp_manager)), agent_scope, local_web_search: match &turn_config.web_search { diff --git a/crates/server/src/runtime/turn_exec/journal.rs b/crates/server/src/runtime/turn_exec/journal.rs index d4df4d29..a851547a 100644 --- a/crates/server/src/runtime/turn_exec/journal.rs +++ b/crates/server/src/runtime/turn_exec/journal.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use async_trait::async_trait; use devo_core::durable_execution::{ - ExecutionRecord, ExecutionReplay, ToolIntentJournal, read_execution_replay, + ExecutionRecord, ExecutionReplay, ToolIntentJournal, read_execution_replay_for_session, }; use devo_core::{InternalRecord, RolloutLine}; use devo_protocol::native::ids::{SessionId, TurnId}; @@ -43,7 +43,11 @@ impl ToolIntentJournal for RolloutToolJournal { async fn replay(&self) -> anyhow::Result { let path = self.path.clone(); let turn_id = self.turn_id; - tokio::task::spawn_blocking(move || read_execution_replay(&path, &turn_id)).await? + let session_id = self.session_id; + tokio::task::spawn_blocking(move || { + read_execution_replay_for_session(&path, &turn_id, &session_id) + }) + .await? } async fn commit(&self, record: ExecutionRecord) -> anyhow::Result<()> { @@ -51,9 +55,12 @@ impl ToolIntentJournal for RolloutToolJournal { if committed.is_none() { let path = self.path.clone(); let turn_id = self.turn_id; + let session_id = self.session_id; *committed = Some( - tokio::task::spawn_blocking(move || read_execution_replay(&path, &turn_id)) - .await??, + tokio::task::spawn_blocking(move || { + read_execution_replay_for_session(&path, &turn_id, &session_id) + }) + .await??, ); } let previous = committed.as_ref().expect("journal initialized"); diff --git a/crates/server/src/runtime/turn_exec/mod.rs b/crates/server/src/runtime/turn_exec/mod.rs index fc14efe4..6886a9e4 100644 --- a/crates/server/src/runtime/turn_exec/mod.rs +++ b/crates/server/src/runtime/turn_exec/mod.rs @@ -94,10 +94,10 @@ pub(crate) fn spawn_post_turn_scheduling( .collaboration_mode() .await .is_some_and(|m| m == devo_protocol::CollaborationMode::Plan); - let session_dir = - handle.rollout_path().await.flatten().and_then(|path| { - crate::persistence::RolloutStore::rlm_session_dir_for_rollout(&path) - }); + let session_dir = handle + .summary() + .await + .and_then(|summary| summary.session_dir); let turn_succeeded = handle .resume_snapshot() .await @@ -122,16 +122,17 @@ pub(crate) fn spawn_post_turn_scheduling( .persist_applied_refinement(session_id, &applied) .await; } - let auto_settings = runtime - .native_session_snapshot(session_id) - .await - .map(|session| { - super::refine::auto_refine_settings_from_session( - session.settings.auto_refine_enabled, - session.settings.auto_refine_turn_interval, - ) - }) - .unwrap_or_default(); + let auto_settings = match runtime.native_session_snapshot(session_id).await { + Ok(Some(session)) => super::refine::auto_refine_settings_from_session( + session.settings.auto_refine_enabled, + session.settings.auto_refine_turn_interval, + ), + Ok(None) => Default::default(), + Err(error) => { + tracing::warn!(session_id = %session_id, %error, "snapshot read failed"); + Default::default() + } + }; let _ = super::refine::maybe_schedule_auto_refine( &native_session_id, &auto_settings, diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index 0ed93ef0..b0c781ed 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -172,17 +172,14 @@ impl ServerRuntime { session_id.to_string(), )) }); - let session_dir = state - .rollout_path - .as_ref() - .and_then(|path| crate::persistence::RolloutStore::rlm_session_dir_for_rollout(path)); + let session_dir = state.summary.session_dir.clone(); let harness_digest = session_dir.as_deref().and_then(|dir| { // Cold-boundary inject: after skills (prefix), before goal in query. // HarnessDigestInjector fails closed on corrupt JSON (empty omit). - // Must resolve through `rlm_session_dir_for_rollout` (the same dir - // refine applies write to): a root rollout's plain parent is the - // shared `sessions/` dir, whose harness state nothing writes, so - // persisted memories would never surface in the digest. + // Must use the per-session artifact dir: a child sharing the + // root journal has `sessions/` as its journal parent, whose harness + // state nothing writes, so persisted memories would never surface + // in the digest. // The digest merges the global harness store (`/harness/`) // so memories refined with `global_=True` reach every session. let global_dir = devo_util_paths::find_devo_home().ok(); @@ -204,8 +201,9 @@ impl ServerRuntime { }); if let (Some(store), Some(path)) = (&output_store, &state.rollout_path) { let path = path.clone(); + let session_id = state.summary.session_id(); match tokio::task::spawn_blocking(move || { - devo_core::output_replay::read_output_references(&path) + devo_core::output_replay::read_output_references_for_session(&path, &session_id) }) .await { @@ -243,69 +241,6 @@ impl ServerRuntime { .await { Ok((arc, namespace_state)) => { - // Fresh kernel spawn: record the OS-fence outcome in the - // rollout (design doc §5.3) — downgraded-unfenced kernels - // must leave an audit trail, fenced ones keep it complete. - if let Some(rollout_path) = state.rollout_path.as_ref() { - let label = match arc.fence_state() { - devo_kernel::FenceState::Fenced => "fenced", - devo_kernel::FenceState::DowngradedUnfenced => "downgradedUnfenced", - devo_kernel::FenceState::NotRequested => "notRequested", - }; - if let Err(err) = self.rollout_store.append_kernel_fence_state( - rollout_path, - session_id, - label, - ) { - tracing::warn!( - %err, - "kernel fence state could not be recorded in rollout" - ); - } - } - // Explicit downgrade surfaces as a conversation-visible - // Warning item (design doc §5.3): persisted + broadcast to - // both clients; fenced/not-requested spawns stay silent. - // `[permission] warn_unfenced_kernel = false` ("don't - // remind") suppresses the visible warning — the rollout - // kernelFence audit event is recorded regardless. - let warn_unfenced = runtime_context - .config_store - .lock() - .expect("app config store mutex should not be poisoned") - .effective_config() - .permission - .warn_unfenced_kernel; - if warn_unfenced - && matches!( - arc.fence_state(), - devo_kernel::FenceState::DowngradedUnfenced - ) - { - // Remediation is platform-specific: Windows needs the - // sandbox setup completed; unix needs the Landlock/ - // seccomp enforcement plan to resolve (kernel support). - let remediation = if cfg!(windows) { - "Run the Windows sandbox setup to enable it" - } else { - "The Landlock/seccomp enforcement plan could not be \ - resolved (check kernel Landlock support and the \ - sandbox profile configuration)" - }; - self.emit_turn_native_item( - session_id, - turn_id, - devo_protocol::native::item::Item::Warning { - code: "rlmKernelUnfenced".to_string(), - message: format!( - "Python is running with full user permissions because \ - the OS sandbox could not be enabled. {remediation}." - ), - retryable: false, - }, - ) - .await; - } // Persist the actual restore outcome so the TUI can show the // same state notice live and when the history is reopened. if let Some(namespace_state) = namespace_state { @@ -372,6 +307,7 @@ impl ServerRuntime { file_read_ledger: Arc::clone(&state.file_read_ledger), sandbox_profile: state.core.config.sandbox_profile.clone(), cancel_token: turn_cancel_token.clone(), + kernel_lifetime: Some(kernel.lifetime_token()), mcp_manager: Some(Arc::clone(&runtime_context.mcp_manager)), agent_scope, local_web_search: match &turn_config.web_search { diff --git a/crates/server/src/runtime/turn_exec/recovery.rs b/crates/server/src/runtime/turn_exec/recovery.rs index ea08c814..f533aee7 100644 --- a/crates/server/src/runtime/turn_exec/recovery.rs +++ b/crates/server/src/runtime/turn_exec/recovery.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::{Context, bail, ensure}; use devo_core::durable_execution::{ ExecutionRecord, ExecutionReplay, RecoveryDisposition, RecoveryState, ToolIntentJournal, - read_execution_replay, + read_execution_replay_for_session, }; use devo_protocol::native::rpc_turn::{ TurnRecovery, TurnRecoveryReadParams, TurnRecoveryReadResult, TurnResumeParams, @@ -63,9 +63,10 @@ impl ServerRuntime { }; let turn_id = turn.turn_id(); let path_for_read = path.clone(); - let execution = - tokio::task::spawn_blocking(move || read_execution_replay(&path_for_read, &turn_id)) - .await??; + let execution = tokio::task::spawn_blocking(move || { + read_execution_replay_for_session(&path_for_read, &turn_id, &session_id) + }) + .await??; if execution .recovery .as_ref() @@ -216,9 +217,10 @@ impl ServerRuntime { }; let journal_path = path.clone(); let turn_id_for_read = turn_id; - let replay = - tokio::task::spawn_blocking(move || read_execution_replay(&path, &turn_id_for_read)) - .await??; + let replay = tokio::task::spawn_blocking(move || { + read_execution_replay_for_session(&path, &turn_id_for_read, &session_id) + }) + .await??; let previous = replay.recovery; if previous .as_ref() diff --git a/crates/server/src/runtime/worktrees.rs b/crates/server/src/runtime/worktrees.rs index 72e9189c..ca0e60c4 100644 --- a/crates/server/src/runtime/worktrees.rs +++ b/crates/server/src/runtime/worktrees.rs @@ -300,9 +300,19 @@ mod tests { ) .await?; let listed = execute(home.path(), json!({"cwd": cwd}), WorktreeOperation::List).await?; - // Git prints native Windows paths with forward slashes. + // Git prints native Windows paths with forward slashes. On Unix, + // canonicalize so a tempdir under a symlinked root (for example + // /var -> /private/var on macOS) matches `git worktree list`. + let created_directory = created["directory"].as_str().unwrap(); + #[cfg(unix)] + let created_directory = std::fs::canonicalize(created_directory) + .expect("canonicalize worktree directory") + .to_string_lossy() + .into_owned(); + #[cfg(windows)] + let created_directory = created_directory.replace('\\', "/"); let expected = WorktreeInfo { - directory: created["directory"].as_str().unwrap().replace('\\', "/"), + directory: created_directory, name: created["name"].as_str().unwrap().to_string(), branch: created["branch"].as_str().unwrap().to_string(), }; diff --git a/crates/server/src/runtime_session_summary.rs b/crates/server/src/runtime_session_summary.rs index 5c9ca1ad..fda78743 100644 --- a/crates/server/src/runtime_session_summary.rs +++ b/crates/server/src/runtime_session_summary.rs @@ -15,6 +15,10 @@ pub(crate) struct RuntimeSessionSummary { pub(crate) agent_path: Option, pub(crate) agent_nickname: Option, pub(crate) agent_role: Option, + /// Independent artifact/kernel/harness directory for this logical session. + /// Never derive this from `rollout_path.parent()` once a child shares the + /// root journal: that parent is the shared `sessions/` directory. + pub(crate) session_dir: Option, pub(crate) prompt_token_estimate: usize, pub(crate) last_query_usage: Option, pub(crate) last_query_total_tokens: usize, @@ -34,6 +38,7 @@ impl RuntimeSessionSummary { agent_path: None, agent_nickname: None, agent_role: None, + session_dir: None, prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, diff --git a/crates/server/src/schedule_store.rs b/crates/server/src/schedule_store.rs index 6381bc1e..cc2db47d 100644 --- a/crates/server/src/schedule_store.rs +++ b/crates/server/src/schedule_store.rs @@ -259,9 +259,9 @@ impl ScheduleStore { if job.status != ScheduleJobStatus::Active || job.last_run_at != claimed.last_run_at || job.updated_at != claimed.updated_at - || !job + || job .next_run_at - .is_none_or(|next_run_at| next_run_at > retry_at) + .is_some_and(|next_run_at| next_run_at <= retry_at) { return Ok(()); } @@ -413,7 +413,6 @@ mod tests { use super::*; - /// Trace: L2-DES-APP-012 /// Verifies: schedule upsert/list/delete round-trips through schedules.json. #[test] fn upsert_list_delete_round_trip() { diff --git a/crates/server/src/titles.rs b/crates/server/src/titles.rs index b4dc033c..107147de 100644 --- a/crates/server/src/titles.rs +++ b/crates/server/src/titles.rs @@ -216,7 +216,6 @@ mod tests { ); } - /// Trace: L2-DES-SERVER-title-generation /// Verifies: heuristic titles truncate and prefer the first line. #[test] fn heuristic_title_truncates_and_prefers_first_line() { diff --git a/crates/server/src/usage_ledger.rs b/crates/server/src/usage_ledger.rs index 802e291d..a36d1459 100644 --- a/crates/server/src/usage_ledger.rs +++ b/crates/server/src/usage_ledger.rs @@ -87,6 +87,19 @@ impl UsageLedger { estimated_cost: None, recorded_at: Utc::now(), }; + let _lease = + match crate::runtime::session_file_lock::SessionFileLock::acquire(&rollout_path) { + Ok(lease) => lease, + Err(error) => { + warn!( + session_id = %context.session_id, + purpose = ?context.call.purpose, + %error, + "failed to acquire usage journal lock" + ); + return; + } + }; if let Err(error) = self.rollout_store .append_usage_record(&rollout_path, context.session_id, record) diff --git a/crates/server/tests/command_exec.rs b/crates/server/tests/command_exec.rs index 5f6a31aa..37cf6dc6 100644 --- a/crates/server/tests/command_exec.rs +++ b/crates/server/tests/command_exec.rs @@ -49,7 +49,6 @@ impl ModelProviderSDK for UnusedProvider { } #[cfg(unix)] -/// Trace: L2-DES-APP-003 /// Verifies: sessionless command execution starts with explicit cwd, streams to the owning connection, and does not create a session. #[tokio::test] async fn sessionless_command_exec_streams_to_owner_without_session() -> Result<()> { @@ -168,7 +167,6 @@ async fn session_interrupt_stops_sessionless_command_without_a_turn() -> Result< Ok(()) } -/// Trace: L2-DES-APP-003 /// Verifies: sessionless command execution rejects requests that omit explicit cwd. #[tokio::test] async fn sessionless_command_exec_requires_explicit_cwd() -> Result<()> { @@ -207,7 +205,6 @@ async fn sessionless_command_exec_requires_explicit_cwd() -> Result<()> { } #[cfg(unix)] -/// Trace: L2-DES-APP-003 /// Verifies: session-bound command execution can resolve cwd from the existing session. #[tokio::test] async fn session_bound_command_exec_resolves_session_cwd() -> Result<()> { @@ -257,6 +254,47 @@ async fn session_bound_command_exec_resolves_session_cwd() -> Result<()> { Ok(()) } +#[cfg(unix)] +/// Verifies: an explicit but unknown session is rejected before cwd handling, +/// so a client-supplied cwd cannot bypass session resolution. +#[tokio::test] +async fn unknown_session_with_cwd_is_session_not_found() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path())?; + let (connection_id, _notifications_rx) = + initialize_connection(&runtime, "command-exec-unknown-session").await?; + + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 25, + "method": "command/exec", + "params": { + "session_id": SessionId::from_string("ses_unknown_command_exec".to_string()), + "process_id": "unknown-session-1", + "cwd": data_root.path(), + "program": { + "type": "one_shot", + "command": "printf 'must-not-run\n'" + }, + "size": null + } + }), + ) + .await + .context("command/exec response")?; + let response: devo_server::ErrorResponse = serde_json::from_value(response)?; + + assert_eq!(response.error.code, ProtocolErrorCode::SessionNotFound); + assert_eq!( + response.error.message, + "session not found: ses_unknown_command_exec" + ); + + Ok(()) +} + fn build_runtime(data_root: &std::path::Path) -> Result> { Ok( devo_server::test_support::TestRuntime::new(Arc::new(UnusedProvider)) diff --git a/crates/server/tests/goal_continuation.rs b/crates/server/tests/goal_continuation.rs index a15384f2..11c82c97 100644 --- a/crates/server/tests/goal_continuation.rs +++ b/crates/server/tests/goal_continuation.rs @@ -38,7 +38,6 @@ use devo_protocol::native::rpc_session::GoalIfExists; #[tokio::test] async fn goal_token_budget_reached_after_turn_enters_budget_limited() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(UsageProvider { requests: std::sync::atomic::AtomicUsize::new(0), @@ -89,7 +88,6 @@ async fn goal_token_budget_reached_after_turn_enters_budget_limited() -> Result< #[tokio::test] async fn budget_limited_goal_pause_interrupts_pending_wrapup_turn() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(BudgetWrapupPendingProvider { requests: std::sync::atomic::AtomicUsize::new(0), @@ -178,7 +176,6 @@ async fn budget_limited_goal_pause_interrupts_pending_wrapup_turn() -> Result<() #[tokio::test] async fn persisted_paused_goal_replays_without_continuation() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(PendingProvider::default()); let runtime = build_runtime(data_root.path(), provider.clone())?; @@ -225,7 +222,6 @@ async fn persisted_paused_goal_replays_without_continuation() -> Result<()> { #[tokio::test] async fn persisted_active_goal_pauses_on_restart_without_continuation() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(CapturingProvider::default()); let runtime = build_runtime(data_root.path(), provider.clone())?; @@ -287,7 +283,6 @@ async fn persisted_active_goal_pauses_on_restart_without_continuation() -> Resul #[tokio::test] async fn goal_pause_interrupts_active_hidden_continuation_turn() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(PendingProvider::default()); let runtime = build_runtime(data_root.path(), provider.clone())?; @@ -344,7 +339,6 @@ async fn goal_pause_interrupts_active_hidden_continuation_turn() -> Result<()> { #[tokio::test] async fn provider_400_tool_call_adjacency_failure_pauses_goal_without_looping() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(FailingProvider { requests: std::sync::atomic::AtomicUsize::new(0), @@ -379,7 +373,6 @@ async fn provider_400_tool_call_adjacency_failure_pauses_goal_without_looping() #[tokio::test] async fn goal_set_starts_hidden_continuation_turn() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(CapturingProvider::default()); let runtime = build_runtime(data_root.path(), provider.clone())?; diff --git a/crates/server/tests/goal_continuation_guards.rs b/crates/server/tests/goal_continuation_guards.rs index b890ad1a..b8b2399d 100644 --- a/crates/server/tests/goal_continuation_guards.rs +++ b/crates/server/tests/goal_continuation_guards.rs @@ -38,7 +38,6 @@ use support::wait_for_notification; #[tokio::test] async fn goal_set_does_not_start_continuation_in_plan_mode() -> Result<()> { - // Trace: L2-DES-GOAL-001 let data_root = TempDir::new()?; let provider = Arc::new(CapturingProvider::default()); let runtime = build_runtime(data_root.path(), provider.clone())?; diff --git a/crates/server/tests/goal_title_generation.rs b/crates/server/tests/goal_title_generation.rs index 94a3cbf0..7273ac0d 100644 --- a/crates/server/tests/goal_title_generation.rs +++ b/crates/server/tests/goal_title_generation.rs @@ -72,7 +72,6 @@ impl ModelProviderSDK for GoalTitleProvider { } } -/// Trace: L2-DES-SERVER-title-generation /// Verifies: goal/set applies a heuristic title immediately, then optional LLM polish. #[tokio::test] async fn goal_set_objective_generates_session_title_for_new_session() -> Result<()> { diff --git a/crates/server/tests/model_selection_persistence.rs b/crates/server/tests/model_selection_persistence.rs index 9859a876..b825a8a1 100644 --- a/crates/server/tests/model_selection_persistence.rs +++ b/crates/server/tests/model_selection_persistence.rs @@ -360,7 +360,6 @@ async fn wait_for_turn_completed( .context("timed out waiting for turn/completed")? } -/// Trace: L2-DES-CONV-002 /// Verifies: a slug-only model update during a running turn clears the old /// binding through MergeTurn so the following turn uses the new model. #[tokio::test] @@ -433,7 +432,6 @@ async fn model_update_during_turn_applies_to_next_turn() -> Result<()> { Ok(()) } -/// Trace: L2-DES-CONV-002 /// Verifies: a cold session persists a slug-only model update, then resume /// and the next turn resolve the new model instead of the stale binding. #[tokio::test] @@ -518,7 +516,6 @@ async fn cold_session_model_update_survives_resume_and_turn() -> Result<()> { Ok(()) } -/// Trace: L2-DES-CONV-002 DD-10 /// Verifies: the settings snapshot round-trips raw reasoning-effort /// selections — including toggle keywords the `ReasoningEffort` enum cannot /// express — through metadata/update responses, resume, read, and the cold @@ -638,7 +635,6 @@ async fn effort_selection_round_trips_through_all_reads( Ok(()) } -/// Trace: L2-DES-CONV-002 DD-10 /// Verifies: toggle-keyword selections normalize against the selected model /// and the canonical value survives every read path after restart. #[tokio::test] @@ -646,14 +642,12 @@ async fn metadata_update_toggle_selection_round_trips_through_all_reads() -> Res effort_selection_round_trips_through_all_reads("on", "off").await } -/// Trace: L2-DES-CONV-002 DD-10 /// Verifies: unsupported typed levels clamp and survive the same read paths. #[tokio::test] async fn metadata_update_levels_selection_round_trips_through_all_reads() -> Result<()> { effort_selection_round_trips_through_all_reads("xhigh", "off").await } -/// Trace: L2-DES-CONV-002 DD-4 /// Verifies: patching the same effort twice is a no-op the second time — no /// new settings field line, no version bump. The old comparison ran the /// persisted value through the `ReasoningEffort` enum, so a stored toggle diff --git a/crates/server/tests/protocol_contract.rs b/crates/server/tests/protocol_contract.rs index 2f4b291c..6ef237ad 100644 --- a/crates/server/tests/protocol_contract.rs +++ b/crates/server/tests/protocol_contract.rs @@ -257,7 +257,6 @@ fn session_compaction_notifications_serialize_expected_methods() { ); } -/// Trace: L2-DES-APP-009 /// Verifies: compaction lifecycle notifications wire as canonical /// context/compactionStarted and context/compactionCompleted. #[test] @@ -288,7 +287,6 @@ fn compaction_lifecycle_events_project_to_native_notifications() { assert_eq!(value["itemId"].as_str(), Some(item_id.to_string().as_str())); } -/// Trace: L2-DES-APP-008, L2-DES-CONV-002 /// Verifies: the canonical session/metadata/update contract shape (patch /// payload with SessionSettings + expectedVersion) round-trips on the wire. #[test] @@ -332,7 +330,6 @@ fn native_session_metadata_update_params_roundtrip() { assert_eq!(minimal.effective_context_window, None); } -/// Trace: L2-DES-CONV-002, L2-DES-APP-008 /// Verifies: the settings patch is partial (only present fields change) and /// `expectedVersion: 0` is the documented no-precondition escape. #[test] @@ -354,7 +351,6 @@ fn native_session_settings_patch_is_partial() { ); } -/// Trace: L2-DES-APP-008 /// Verifies: the canonical task domain wire shapes (task/start kind-tagged /// params, task verb params) serialize as specified by DD-7. #[test] @@ -392,7 +388,6 @@ fn native_task_start_params_kind_tagged_wire_shape() { assert!(matches!(agent, TaskStartParams::Agent { .. })); } -/// Trace: L2-DES-APP-008 /// Verifies: the canonical goal domain wire shapes (goal/set with ifExists, /// goal transition params) round-trip. #[test] @@ -427,7 +422,6 @@ fn native_goal_params_wire_shapes() { ); } -/// Trace: L2-DES-APP-010 /// Verifies: first-party Native turn lifecycle uses identity `turn/started` /// wire (session/event projector deleted). #[test] @@ -461,7 +455,6 @@ fn native_turn_started_identity_wire() { assert!(params.get("turn").is_some()); } -/// Trace: L2-DES-AUTH-001 /// Verifies: provider/authStale notification serializes with camelCase providerId. #[test] fn provider_auth_stale_notification_wire() { diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index 98e012be..ff2ad00c 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -490,7 +490,6 @@ fn model_response(text: &str) -> ModelResponse { } } -/// Trace: L2-DES-CONTEXT-004. Recovery preserves the request and turn identity. /// Real wall-clock timing — same reasoning as the exhausted-retries test. #[tokio::test] async fn continue_failed_turn_reuses_context_and_is_idempotent() -> Result<()> { diff --git a/crates/server/tests/settings_mid_turn.rs b/crates/server/tests/settings_mid_turn.rs index 69643600..19c44941 100644 --- a/crates/server/tests/settings_mid_turn.rs +++ b/crates/server/tests/settings_mid_turn.rs @@ -278,7 +278,6 @@ async fn initialize_connection( Ok((connection_id, notifications_rx)) } -/// Trace: L2-DES-CONV-002 /// Verifies: tightening the permission preset mid-turn changes the next /// tool-call authorization outcome — auto-run under fullAccess, interactive /// approval required after switching to default (Phase 3 behavior level). @@ -452,7 +451,6 @@ async fn mid_turn_tighten_to_default_triggers_approval_for_ipython_permission_re Ok(()) } -/// Trace: L2-DES-CONV-002 /// Verifies: switching model and reasoning selection mid-turn makes the next /// model request use the model-aware normalized selection. #[tokio::test] diff --git a/crates/server/tests/skills_integration.rs b/crates/server/tests/skills_integration.rs index 8d319476..315df409 100644 --- a/crates/server/tests/skills_integration.rs +++ b/crates/server/tests/skills_integration.rs @@ -1160,7 +1160,6 @@ async fn auto_review_approval_executes_mutating_tool_without_user_prompt() -> Re Ok(()) } -/// Trace: L2-DES-SAFETY-002 #[tokio::test] async fn auto_review_executes_or_falls_back_by_risk() -> Result<()> { struct Case { diff --git a/crates/server/tests/subagent_lifecycle.rs b/crates/server/tests/subagent_lifecycle.rs index 28228a1b..6ea6b367 100644 --- a/crates/server/tests/subagent_lifecycle.rs +++ b/crates/server/tests/subagent_lifecycle.rs @@ -114,7 +114,6 @@ async fn spawn_agent_generates_unique_child_name() -> Result<()> { Ok(()) } -/// Trace: L2-DES-RLM-001 /// Verifies: kernel `rlm.spawn` nickname contract — requested names are honored /// verbatim (they are the child's address for messaging/observation/delete) /// and duplicates among siblings are rejected. diff --git a/crates/server/tests/subagent_persistence.rs b/crates/server/tests/subagent_persistence.rs new file mode 100644 index 00000000..3305909e --- /dev/null +++ b/crates/server/tests/subagent_persistence.rs @@ -0,0 +1,2140 @@ +//! End-to-end regression coverage for one root JSONL per subagent tree. +//! +//! These tests exercise the public runtime, DB index, filesystem layout, and +//! Native RPC surfaces. Store-level writer/index unit tests stay in +//! `crates/server/src/persistence_tests.rs`; lifecycle consumer tests stay in +//! the lifecycle-owned suites. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::ffi::OsStr; +use std::path::Path; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use devo_core::InternalRecord; +use devo_core::RolloutLine; +use devo_core::SessionPersistenceExtras; +use devo_core::parse_rollout_line; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::ResponseContent; +use devo_protocol::ResponseMetadata; +use devo_protocol::SessionId; +use devo_protocol::StopReason; +use devo_protocol::StreamEvent; +use devo_protocol::Usage; +use devo_protocol::native::ids::ItemId; +use devo_protocol::native::item::ItemEnvelope; +use devo_protocol::native::page::Page; +use devo_protocol::native::rpc_schedule::SessionExportResult; +use devo_protocol::native::rpc_session::RestorePlan; +use devo_protocol::native::rpc_session::SessionForkResult; +use devo_protocol::native::rpc_session::SessionNewResult; +use devo_protocol::native::rpc_session::SessionResumeResult; +use devo_protocol::native::session::Session; +use devo_protocol::native::session::SessionParent; +use devo_provider::ModelProviderSDK; +use devo_server::ErrorResponse; +use devo_server::ProtocolErrorCode; +use devo_server::ServerRuntime; +use devo_server::db::Database; +use devo_server::db::SessionIndexRow; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::sync::Barrier; +use tokio::sync::mpsc; +use tokio::time::timeout; + +#[path = "support/subagent_lifecycle.rs"] +mod support; + +use support::ScriptedProvider; +use support::build_runtime; +use support::initialize_connection; +use support::request_agent_list; +use support::spawn_child_named; +use support::start_parent_session; +use support::start_turn; +use support::wait_for_child_turn_started; +use support::wait_for_session_notification; + +const DB_FILE: &str = "subagent_lifecycle.db"; + +/// A provider that holds every turn's stream until all expected turns have +/// arrived. This makes the concurrent-append test deterministic without sleeps +/// or polling. +struct BarrierProvider { + barrier: Arc, +} + +impl BarrierProvider { + fn new(expected_turns: usize) -> Self { + Self { + barrier: Arc::new(Barrier::new(expected_turns)), + } + } +} + +#[async_trait] +impl ModelProviderSDK for BarrierProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + Ok(text_response("barrier title")) + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + self.barrier.wait().await; + let events = vec![ + StreamEvent::TextDelta { + index: 0, + text: "concurrent reply".to_string(), + }, + StreamEvent::MessageDone { + response: text_response("concurrent reply"), + }, + ]; + Ok(Box::pin(futures::stream::iter( + events.into_iter().map(Ok).collect::>(), + ))) + } + + fn name(&self) -> &str { + "barrier-provider" + } +} + +fn text_response(text: &str) -> ModelResponse { + ModelResponse { + id: "test-response".to_string(), + content: vec![ResponseContent::Text(text.to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + } +} + +async fn rpc( + runtime: &Arc, + connection_id: u64, + id: u64, + method: &str, + params: Value, +) -> Result { + runtime + .handle_incoming( + connection_id, + json!({ "id": id, "method": method, "params": params }), + ) + .await + .with_context(|| format!("{method} returned no response")) +} + +fn ensure_success(response: &Value) -> Result<()> { + if let Some(error) = response.get("error") { + anyhow::bail!("unexpected RPC error: {error}"); + } + Ok(()) +} + +async fn list_sessions( + runtime: &Arc, + connection_id: u64, + include_children: bool, +) -> Result> { + let response = rpc( + runtime, + connection_id, + 100, + "session/list", + json!({ "includeChildren": include_children }), + ) + .await?; + ensure_success(&response)?; + let page: Page = serde_json::from_value(response["result"].clone())?; + Ok(page.data) +} + +async fn list_session_ids( + runtime: &Arc, + connection_id: u64, + include_children: bool, +) -> Result> { + Ok(list_sessions(runtime, connection_id, include_children) + .await? + .into_iter() + .map(|session| session.id) + .collect()) +} + +async fn list_items( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, +) -> Result> { + let response = rpc( + runtime, + connection_id, + 101, + "session/items/list", + json!({ "sessionId": session_id }), + ) + .await?; + ensure_success(&response)?; + let page: Page = serde_json::from_value(response["result"].clone())?; + Ok(page.data) +} + +async fn resume_session( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, +) -> Result { + let response = rpc( + runtime, + connection_id, + 102, + "session/resume", + json!({ "sessionId": session_id }), + ) + .await?; + ensure_success(&response)?; + let result: SessionResumeResult = serde_json::from_value(response["result"].clone())?; + Ok(result.session) +} + +/// Subscribe the connection to a child session so notifications from its +/// direct children (grandchildren of the original root) are delivered. Native +/// session resume installs the legacy session subscription with +/// `include_child_agents` enabled; a root subscription only covers direct +/// children. +async fn subscribe_to_child_notifications( + runtime: &Arc, + connection_id: u64, + child_session_id: SessionId, +) -> Result<()> { + let _ = resume_session(runtime, connection_id, child_session_id).await?; + Ok(()) +} + +async fn create_root_session( + runtime: &Arc, + connection_id: u64, + cwd: &Path, + idempotency_key: &str, +) -> Result { + let response = rpc( + runtime, + connection_id, + 111, + "session/new", + json!({ "cwd": cwd, "idempotencyKey": idempotency_key }), + ) + .await?; + ensure_success(&response)?; + let result: SessionNewResult = serde_json::from_value(response["result"].clone())?; + Ok(result.session.id) +} + +async fn fork_session( + runtime: &Arc, + connection_id: u64, + source_session_id: SessionId, +) -> Result { + let response = rpc( + runtime, + connection_id, + 112, + "session/fork", + json!({ "sessionId": source_session_id }), + ) + .await?; + ensure_success(&response)?; + let result: SessionForkResult = serde_json::from_value(response["result"].clone())?; + Ok(result.session.id) +} + +async fn delete_session( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, +) -> Result<()> { + let response = rpc( + runtime, + connection_id, + 103, + "session/delete", + json!({ "sessionId": session_id }), + ) + .await?; + ensure_success(&response) +} + +async fn export_session( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, + path: &Path, +) -> Result { + let response = rpc( + runtime, + connection_id, + 104, + "session/export", + json!({ + "sessionId": session_id, + "format": "jsonl", + "path": path, + }), + ) + .await?; + ensure_success(&response)?; + let result: SessionExportResult = serde_json::from_value(response["result"].clone())?; + Ok(result.path) +} + +fn parent_session_id(session: &Session) -> Option { + session.parent.as_ref().map(|parent| { + let SessionParent::Agent { session_id, .. } = parent; + *session_id + }) +} + +fn root_journal(data_root: &Path, root_session_id: SessionId) -> PathBuf { + data_root + .join("sessions") + .join(format!("{root_session_id}.jsonl")) +} + +fn expected_child_dir( + data_root: &Path, + root_session_id: SessionId, + child_id: SessionId, +) -> PathBuf { + data_root + .join("session-artifacts") + .join(root_session_id.to_string()) + .join("agents") + .join(child_id.to_string()) +} + +fn journal_files(data_root: &Path) -> Result> { + let sessions_dir = data_root.join("sessions"); + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + let mut files = Vec::new(); + for entry in std::fs::read_dir(&sessions_dir)? { + let path = entry?.path(); + if path.extension() == Some(OsStr::new("jsonl")) { + files.push(path); + } + } + files.sort(); + Ok(files) +} + +fn read_journal(path: &Path) -> Result> { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read journal {}", path.display()))?; + let mut lines = Vec::new(); + for raw in text.lines() { + if raw.trim().is_empty() { + continue; + } + lines.push(parse_rollout_line(raw)?); + } + Ok(lines) +} + +fn line_session_ids(lines: &[RolloutLine]) -> HashSet { + lines.iter().filter_map(RolloutLine::session_id).collect() +} + +fn item_contains_text(item: &ItemEnvelope, needle: &str) -> bool { + serde_json::to_string(&item.item).is_ok_and(|serialized| serialized.contains(needle)) +} + +fn session_lines(lines: &[RolloutLine], session_id: SessionId) -> Vec { + lines + .iter() + .filter(|line| line.session_id() == Some(session_id)) + .cloned() + .collect() +} + +fn collect_files(dir: &Path, files: &mut Vec) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + collect_files(&path, files)?; + } else { + files.push(path); + } + } + Ok(()) +} + +fn assert_no_child_journal(data_root: &Path, child_ids: &[SessionId]) -> Result<()> { + let mut files = Vec::new(); + collect_files(&data_root.join("session-artifacts"), &mut files)?; + let child_journals: HashSet = child_ids + .iter() + .map(|child_id| format!("{child_id}.jsonl")) + .collect(); + assert!( + !files.iter().any(|path| path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| child_journals.contains(name))), + "child rollouts must not exist; files: {files:?}" + ); + Ok(()) +} + +async fn wait_for_turn_completions( + notifications: &mut mpsc::Receiver, + expected: &[SessionId], +) -> Result<()> { + let mut remaining: HashSet = expected.iter().copied().collect(); + timeout(Duration::from_secs(15), async { + while !remaining.is_empty() { + let event = notifications + .recv() + .await + .context("notification channel closed before all turns completed")?; + if event.get("method") != Some(&json!("turn/completed")) { + continue; + } + let session_id = event["params"]["turn"]["sessionId"] + .as_str() + .or_else(|| event["params"]["sessionId"].as_str()) + .or_else(|| event["params"]["session_id"].as_str()); + if let Some(session_id) = session_id { + remaining.remove(&SessionId::from(session_id.to_string())); + } + } + Ok::<(), anyhow::Error>(()) + }) + .await + .context("timed out waiting for turn completions")??; + Ok(()) +} + +fn indexed_rollout_path(db: &Database, session_id: SessionId) -> Result> { + Ok(db + .get_session_index(&session_id)? + .and_then(|index| index.rollout_path)) +} + +fn stale_index_row( + data_root: &Path, + session_id: SessionId, + parent_session_id: SessionId, +) -> SessionIndexRow { + let now = Utc::now(); + SessionIndexRow { + session_id, + cwd: data_root.to_path_buf(), + additional_directories: Vec::new(), + created_at: now, + updated_at: now, + last_activity_at: now, + title: Some("stale child".to_string()), + title_state: devo_core::SessionTitleState::Unset, + parent_session_id: Some(parent_session_id), + fork_from_id: None, + fork_at_turn_id: None, + agent_path: Some("root/stale".to_string()), + ephemeral: false, + model: None, + reasoning_effort_selection: None, + } +} + +fn clear_index_rollout_path(data_root: &Path, session_id: SessionId) -> Result<()> { + set_index_rollout_path(data_root, session_id, None) +} + +fn set_index_rollout_path( + data_root: &Path, + session_id: SessionId, + path: Option<&Path>, +) -> Result<()> { + let conn = rusqlite::Connection::open(data_root.join(DB_FILE))?; + let value = path.map(|path| path.to_string_lossy().into_owned()); + let updated = conn.execute( + "UPDATE sessions SET rollout_path = ?1 WHERE id = ?2", + rusqlite::params![value, session_id.to_string()], + )?; + assert_eq!(updated, 1, "expected one indexed row for {session_id}"); + Ok(()) +} + +#[tokio::test] +async fn persistent_descendants_share_one_journal_and_keep_separate_artifact_dirs() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(ScriptedProvider::new([ + ScriptedProvider::completed("child reply"), + ScriptedProvider::completed("grandchild reply"), + ScriptedProvider::completed("root reply"), + ])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + subscribe_to_child_notifications(&runtime, connection_id, child.child_session_id).await?; + let grandchild = spawn_child_named( + &runtime, + child.child_session_id, + "grandchild task", + Some("none"), + Some("grandchild"), + ) + .await?; + wait_for_session_notification( + &mut notifications, + "turn/completed", + grandchild.child_session_id, + ) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + assert_eq!(journal_files(data_root.path())?, vec![journal.clone()]); + assert!(journal.is_file(), "root journal must exist"); + + let child_artifact_dir = + expected_child_dir(data_root.path(), root_session_id, child.child_session_id); + let grandchild_artifact_dir = expected_child_dir( + data_root.path(), + root_session_id, + grandchild.child_session_id, + ); + assert_eq!( + child.session_dir.as_deref().map(PathBuf::from), + Some(child_artifact_dir.clone()) + ); + assert_eq!( + grandchild.session_dir.as_deref().map(PathBuf::from), + Some(grandchild_artifact_dir.clone()) + ); + assert!(child_artifact_dir.is_dir()); + assert!(grandchild_artifact_dir.is_dir()); + assert_ne!(child_artifact_dir, grandchild_artifact_dir); + assert!(!child_artifact_dir.starts_with(data_root.path().join("sessions"))); + assert!(!grandchild_artifact_dir.starts_with(data_root.path().join("sessions"))); + + assert_no_child_journal( + data_root.path(), + &[child.child_session_id, grandchild.child_session_id], + )?; + + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, root_session_id)?, + Some(journal.clone()) + ); + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + Some(journal.clone()) + ); + assert_eq!( + indexed_rollout_path(&db, grandchild.child_session_id)?, + Some(journal.clone()) + ); + + let child_index = db + .get_session_index(&child.child_session_id)? + .expect("child index"); + assert_eq!(child_index.session.parent_session_id, Some(root_session_id)); + assert_eq!( + child_index.session.agent_path.as_deref(), + Some("root/child") + ); + let grandchild_index = db + .get_session_index(&grandchild.child_session_id)? + .expect("grandchild index"); + assert_eq!( + grandchild_index.session.parent_session_id, + Some(child.child_session_id) + ); + assert_eq!( + grandchild_index.session.agent_path.as_deref(), + Some("root/child/grandchild") + ); + + let lines = read_journal(&journal)?; + let expected_ids: HashSet = [ + root_session_id, + child.child_session_id, + grandchild.child_session_id, + ] + .into_iter() + .collect(); + assert_eq!(line_session_ids(&lines), expected_ids); + assert!(lines.iter().all(|line| line.session_id().is_some())); + + let meta_ids: HashSet = lines + .iter() + .filter_map(|line| match line { + RolloutLine::SessionMeta { session, .. } => Some(session.id), + _ => None, + }) + .collect(); + assert_eq!(meta_ids, expected_ids); + + assert_eq!( + list_session_ids(&runtime, connection_id, /*include_children*/ true).await?, + expected_ids + ); + let agents = request_agent_list(&runtime, connection_id, root_session_id).await?; + let agent_ids: HashSet = + agents.agents.iter().map(|agent| agent.session_id).collect(); + assert_eq!( + agent_ids, + [child.child_session_id].into_iter().collect(), + "root lists direct children only" + ); + let grandchild_agents = + request_agent_list(&runtime, connection_id, child.child_session_id).await?; + assert_eq!( + grandchild_agents + .agents + .iter() + .map(|agent| agent.session_id) + .collect::>(), + [grandchild.child_session_id].into_iter().collect() + ); + + Ok(()) +} + +#[tokio::test] +async fn legacy_child_journal_remains_readable_and_unmodified() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (connection_id, _) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let root_session = resume_session(&runtime, connection_id, root_session_id).await?; + + let child_id = SessionId::new(); + let now = Utc::now(); + let mut child_session = root_session.clone(); + child_session.id = child_id; + child_session.version = 1; + child_session.parent = Some(SessionParent::Agent { + session_id: root_session_id, + role: Some("default".to_string()), + }); + child_session.fork_from_id = None; + child_session.at_turn_id = None; + child_session.ephemeral = false; + child_session.created_at = now; + child_session.status = devo_protocol::native::session::SessionStatus::Idle; + child_session.flags.clear(); + child_session.archived = false; + child_session.activity = devo_protocol::native::session::SessionActivity::Idle; + child_session.active_turn_id = None; + child_session.queued_count = 0; + child_session.title = Some("legacy child".to_string()); + child_session.title_state = + devo_core::SessionTitleState::Final(devo_core::SessionTitleFinalSource::ExplicitCreate); + child_session.last_activity_at = now; + child_session.transcript_size_bytes = None; + child_session.message_count = None; + child_session.summary = None; + child_session.task_state = None; + + let extras = SessionPersistenceExtras { + session_context: None, + cli_version: "test".to_string(), + source: "cli".to_string(), + collaboration_mode: None, + permission_preset: None, + agent_path: Some("root/legacy".to_string()), + agent_nickname: Some("legacy".to_string()), + kernel_snapshot_path: None, + }; + let legacy_dir = data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()) + .join("sub-legacy"); + std::fs::create_dir_all(&legacy_dir)?; + let legacy_journal = legacy_dir.join(format!("{child_id}.jsonl")); + let line = devo_core::session_line(child_session, Some(extras), now); + std::fs::write( + &legacy_journal, + format!( + "{} +", + serde_json::to_string(&line)? + ), + )?; + let before = std::fs::read(&legacy_journal)?; + + runtime.refresh_session_index()?; + let resumed = resume_session(&runtime, connection_id, child_id).await?; + assert_eq!(resumed.id, child_id); + assert_eq!(parent_session_id(&resumed), Some(root_session_id)); + assert_eq!(resumed.title.as_deref(), Some("legacy child")); + + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child_id)?, + Some(legacy_journal.clone()) + ); + let index = db + .get_session_index(&child_id)? + .expect("legacy child index"); + assert_eq!(index.session.agent_path.as_deref(), Some("root/legacy")); + + assert_eq!( + std::fs::read(&legacy_journal)?, + before, + "legacy journal must not be rewritten by indexing or resume" + ); + + let root_journal_path = root_journal(data_root.path(), root_session_id); + let root_before = std::fs::read(&root_journal_path)?; + delete_session(&runtime, connection_id, child_id).await?; + assert!( + !legacy_journal.exists(), + "legacy child delete must unlink its own journal" + ); + assert!( + !legacy_dir.exists(), + "legacy child artifact dir must be removed" + ); + assert!(root_journal_path.is_file(), "root journal must remain"); + assert_eq!( + std::fs::read(&root_journal_path)?, + root_before, + "legacy child delete must not rewrite the root journal" + ); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!(indexed_rollout_path(&db, child_id)?, None); + assert_eq!( + indexed_rollout_path(&db, root_session_id)?, + Some(root_journal_path.clone()) + ); + runtime.refresh_session_index()?; + let child_resume = rpc( + &runtime, + connection_id, + 110, + "session/resume", + json!({ "sessionId": child_id }), + ) + .await?; + let error: ErrorResponse = serde_json::from_value(child_resume)?; + assert_eq!(error.error.code, ProtocolErrorCode::SessionNotFound); + assert_eq!( + list_session_ids(&runtime, connection_id, /*include_children*/ true).await?, + [root_session_id].into_iter().collect() + ); + Ok(()) +} + +#[tokio::test] +async fn concurrent_turns_keep_shared_journal_attributed_and_sequenced() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(BarrierProvider::new(3)); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + + let child = spawn_child_named( + &runtime, + root_session_id, + "concurrent child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_child_turn_started(&mut notifications, child.child_session_id).await?; + + subscribe_to_child_notifications(&runtime, connection_id, child.child_session_id).await?; + let grandchild = spawn_child_named( + &runtime, + child.child_session_id, + "concurrent grandchild task", + Some("none"), + Some("grandchild"), + ) + .await?; + wait_for_child_turn_started(&mut notifications, grandchild.child_session_id).await?; + + start_turn( + &runtime, + connection_id, + root_session_id, + "concurrent root task", + ) + .await?; + wait_for_turn_completions( + &mut notifications, + &[ + root_session_id, + child.child_session_id, + grandchild.child_session_id, + ], + ) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + let lines = read_journal(&journal)?; + let expected_ids: HashSet = [ + root_session_id, + child.child_session_id, + grandchild.child_session_id, + ] + .into_iter() + .collect(); + assert_eq!(line_session_ids(&lines), expected_ids); + assert!(lines.iter().all(|line| line.session_id().is_some())); + + let mut item_seqs: HashMap> = HashMap::new(); + let mut item_ids: HashMap> = HashMap::new(); + let mut seq_by_item: HashMap<(SessionId, ItemId), u64> = HashMap::new(); + for line in &lines { + if let RolloutLine::Item { item, .. } = line { + item_seqs.entry(item.session_id).or_default().push(item.seq); + item_ids.entry(item.session_id).or_default().insert(item.id); + if let Some(previous_seq) = seq_by_item.insert((item.session_id, item.id), item.seq) { + assert_eq!( + previous_seq, item.seq, + "one item id must keep one sequence in session {}", + item.session_id + ); + } + } + } + assert_eq!( + item_seqs.keys().copied().collect::>(), + expected_ids + ); + for session_id in &expected_ids { + let item_count = item_ids + .get(session_id) + .map(HashSet::len) + .unwrap_or_default(); + assert_eq!( + item_count, 3, + "each concurrent text turn persists user + warning + assistant items in {session_id}" + ); + let mut unique = item_seqs + .get(session_id) + .cloned() + .expect("item seqs for session"); + unique.sort_unstable(); + unique.dedup(); + assert_eq!( + unique, + vec![1, 2, 3], + "each session must have three unique item seqs, not just parseable lines" + ); + } + assert_eq!( + seq_by_item.len(), + expected_ids.len() * 3, + "full expected item-id/seq count" + ); + + Ok(()) +} + +#[tokio::test] +async fn restart_resumes_each_session_from_shared_journal_with_isolated_history() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(ScriptedProvider::new([ + ScriptedProvider::completed("reply-alpha"), + ScriptedProvider::completed("reply-beta"), + ScriptedProvider::completed("reply-gamma"), + ])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + subscribe_to_child_notifications(&runtime, connection_id, child.child_session_id).await?; + let grandchild = spawn_child_named( + &runtime, + child.child_session_id, + "grandchild task", + Some("none"), + Some("grandchild"), + ) + .await?; + wait_for_session_notification( + &mut notifications, + "turn/completed", + grandchild.child_session_id, + ) + .await?; + start_turn(&runtime, connection_id, root_session_id, "root task").await?; + wait_for_session_notification(&mut notifications, "turn/completed", root_session_id).await?; + + runtime.shutdown().await; + + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + rebuilt.refresh_session_index()?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + + let markers = ["reply-alpha", "reply-beta", "reply-gamma"]; + for (session_id, expected_parent, marker) in [ + (root_session_id, None, "reply-gamma"), + (child.child_session_id, Some(root_session_id), "reply-alpha"), + ( + grandchild.child_session_id, + Some(child.child_session_id), + "reply-beta", + ), + ] { + let session = resume_session(&rebuilt, rebuilt_connection, session_id).await?; + assert_eq!(session.id, session_id); + assert_eq!(parent_session_id(&session), expected_parent); + let items = list_items(&rebuilt, rebuilt_connection, session_id).await?; + assert!(!items.is_empty(), "resumed session must have history"); + assert!( + items.iter().all(|item| item.session_id == session_id), + "resumed history for {session_id} must not contain sibling items" + ); + assert!( + items.iter().any(|item| item_contains_text(item, marker)), + "resumed history for {session_id} must contain its distinct marker {marker}" + ); + for other in markers.iter().filter(|other| **other != marker) { + assert!( + !items.iter().any(|item| item_contains_text(item, other)), + "resumed history for {session_id} must not contain {other}" + ); + } + } + + Ok(()) +} + +#[tokio::test] +async fn deleted_child_is_tombstoned_and_not_resurrected_after_reindex() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(ScriptedProvider::new([ + ScriptedProvider::completed("child reply"), + ScriptedProvider::completed("sibling reply"), + ScriptedProvider::completed("grandchild reply"), + ])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + let sibling = spawn_child_named( + &runtime, + root_session_id, + "sibling task", + Some("none"), + Some("sibling"), + ) + .await?; + wait_for_session_notification( + &mut notifications, + "turn/completed", + sibling.child_session_id, + ) + .await?; + subscribe_to_child_notifications(&runtime, connection_id, child.child_session_id).await?; + let grandchild = spawn_child_named( + &runtime, + child.child_session_id, + "grandchild task", + Some("none"), + Some("grandchild"), + ) + .await?; + wait_for_session_notification( + &mut notifications, + "turn/completed", + grandchild.child_session_id, + ) + .await?; + start_turn(&runtime, connection_id, root_session_id, "root task").await?; + wait_for_session_notification(&mut notifications, "turn/completed", root_session_id).await?; + + let journal = root_journal(data_root.path(), root_session_id); + let child_dir = expected_child_dir(data_root.path(), root_session_id, child.child_session_id); + let sibling_dir = + expected_child_dir(data_root.path(), root_session_id, sibling.child_session_id); + let grandchild_dir = expected_child_dir( + data_root.path(), + root_session_id, + grandchild.child_session_id, + ); + assert!(journal.is_file()); + assert!(child_dir.is_dir()); + assert!(sibling_dir.is_dir()); + assert!(grandchild_dir.is_dir()); + let before = read_journal(&journal)?; + let root_before = session_lines(&before, root_session_id); + let sibling_before = session_lines(&before, sibling.child_session_id); + assert!(!root_before.is_empty()); + assert!(!sibling_before.is_empty()); + delete_session(&runtime, connection_id, child.child_session_id).await?; + + assert!( + journal.is_file(), + "child delete must retain the root journal" + ); + assert!(!child_dir.exists(), "child artifact dir must be removed"); + assert!( + !grandchild_dir.exists(), + "descendant artifact dir must be removed" + ); + assert!(sibling_dir.is_dir(), "sibling artifact dir must remain"); + let after = read_journal(&journal)?; + assert_eq!( + session_lines(&after, root_session_id), + root_before, + "child delete must not rewrite root session lines" + ); + assert_eq!( + session_lines(&after, sibling.child_session_id), + sibling_before, + "child delete must not rewrite sibling session lines" + ); + assert!( + data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()) + .is_dir(), + "root artifact tree must remain after child delete" + ); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "deleted child row" + ); + assert_eq!( + indexed_rollout_path(&db, grandchild.child_session_id)?, + None, + "deleted grandchild row" + ); + assert_eq!( + indexed_rollout_path(&db, root_session_id)?, + Some(journal.clone()) + ); + assert_eq!( + indexed_rollout_path(&db, sibling.child_session_id)?, + Some(journal.clone()), + "sibling row must remain" + ); + + let lines = read_journal(&journal)?; + let tombstones: HashSet = lines + .iter() + .filter_map(|line| match line { + RolloutLine::Internal { + session_id, + entry: InternalRecord::SessionDeleted { .. }, + .. + } => Some(*session_id), + _ => None, + }) + .collect(); + assert_eq!( + tombstones, + [child.child_session_id, grandchild.child_session_id] + .into_iter() + .collect() + ); + + assert_eq!( + list_session_ids(&runtime, connection_id, /*include_children*/ true).await?, + [root_session_id, sibling.child_session_id] + .into_iter() + .collect() + ); + let remaining_agents = request_agent_list(&runtime, connection_id, root_session_id).await?; + assert_eq!( + remaining_agents + .agents + .iter() + .map(|agent| agent.session_id) + .collect::>(), + [sibling.child_session_id].into_iter().collect(), + "deleting one child must not remove the sibling" + ); + + let resume_response = rpc( + &runtime, + connection_id, + 105, + "session/resume", + json!({ "sessionId": child.child_session_id }), + ) + .await?; + let error: ErrorResponse = serde_json::from_value(resume_response)?; + assert_eq!(error.error.code, ProtocolErrorCode::SessionNotFound); + + runtime.shutdown().await; + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + rebuilt.refresh_session_index()?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "reindex must not resurrect a tombstoned child" + ); + assert_eq!( + indexed_rollout_path(&db, grandchild.child_session_id)?, + None, + "reindex must not resurrect a tombstoned grandchild" + ); + assert_eq!( + list_session_ids(&rebuilt, rebuilt_connection, /*include_children*/ true).await?, + [root_session_id, sibling.child_session_id] + .into_iter() + .collect() + ); + let session = resume_session(&rebuilt, rebuilt_connection, root_session_id).await?; + assert_eq!(session.id, root_session_id); + let sibling_session = + resume_session(&rebuilt, rebuilt_connection, sibling.child_session_id).await?; + assert_eq!(sibling_session.id, sibling.child_session_id); + assert_eq!(parent_session_id(&sibling_session), Some(root_session_id)); + + let resume_response = rpc( + &rebuilt, + rebuilt_connection, + 106, + "session/resume", + json!({ "sessionId": child.child_session_id }), + ) + .await?; + let error: ErrorResponse = serde_json::from_value(resume_response)?; + assert_eq!(error.error.code, ProtocolErrorCode::SessionNotFound); + + Ok(()) +} + +#[tokio::test] +async fn root_delete_removes_unhydrated_descendants_and_the_journal() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(ScriptedProvider::new([ + ScriptedProvider::completed("child reply"), + ScriptedProvider::completed("grandchild reply"), + ])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + subscribe_to_child_notifications(&runtime, connection_id, child.child_session_id).await?; + let grandchild = spawn_child_named( + &runtime, + child.child_session_id, + "grandchild task", + Some("none"), + Some("grandchild"), + ) + .await?; + wait_for_session_notification( + &mut notifications, + "turn/completed", + grandchild.child_session_id, + ) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + let artifact_dir = data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()); + assert!(journal.is_file()); + assert!(artifact_dir.is_dir()); + + runtime.shutdown().await; + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + rebuilt.refresh_session_index()?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + delete_session(&rebuilt, rebuilt_connection, root_session_id).await?; + + assert!(!journal.exists(), "root journal must be removed"); + assert!(!artifact_dir.exists(), "root artifact tree must be removed"); + assert_eq!( + list_session_ids(&rebuilt, rebuilt_connection, /*include_children*/ true).await?, + HashSet::new() + ); + + let db = Database::open(data_root.path().join(DB_FILE))?; + for session_id in [ + root_session_id, + child.child_session_id, + grandchild.child_session_id, + ] { + assert_eq!( + indexed_rollout_path(&db, session_id)?, + None, + "root delete must remove DB row for {session_id}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn session_export_filters_to_the_target_session() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(ScriptedProvider::new([ + ScriptedProvider::completed("child reply"), + ScriptedProvider::completed("grandchild reply"), + ])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + subscribe_to_child_notifications(&runtime, connection_id, child.child_session_id).await?; + let grandchild = spawn_child_named( + &runtime, + child.child_session_id, + "grandchild task", + Some("none"), + Some("grandchild"), + ) + .await?; + wait_for_session_notification( + &mut notifications, + "turn/completed", + grandchild.child_session_id, + ) + .await?; + + let export_dir = data_root.path().join("exports"); + std::fs::create_dir_all(&export_dir)?; + for (session_id, file_name) in [ + (root_session_id, "root.jsonl"), + (child.child_session_id, "child.jsonl"), + (grandchild.child_session_id, "grandchild.jsonl"), + ] { + let export_path = export_dir.join(file_name); + let written_path = + export_session(&runtime, connection_id, session_id, &export_path).await?; + assert_eq!(written_path, export_path); + let lines = read_journal(&written_path)?; + assert_eq!( + line_session_ids(&lines), + [session_id].into_iter().collect(), + "export for {session_id} must contain only that session" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn stale_missing_index_child_resolves_shared_journal() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + runtime.shutdown().await; + clear_index_rollout_path(data_root.path(), child.child_session_id)?; + + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + // Deliberately do not refresh the index: resume must use the journal-content + // fallback for a shared child whose indexed rollout_path is missing. + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + let resumed = resume_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + assert_eq!(resumed.id, child.child_session_id); + assert_eq!(parent_session_id(&resumed), Some(root_session_id)); + assert!( + !list_items(&rebuilt, rebuilt_connection, child.child_session_id) + .await? + .is_empty() + ); + + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + Some(journal.clone()), + "resume must backfill the shared journal path" + ); + + // Same fallback must work for a stale path that exists lexically but points + // at a missing file, not only for a NULL index value. + rebuilt.shutdown().await; + set_index_rollout_path( + data_root.path(), + child.child_session_id, + Some(&data_root.path().join("missing-child.jsonl")), + )?; + let stale_runtime = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (stale_connection, _) = initialize_connection(&stale_runtime).await?; + let resumed = resume_session(&stale_runtime, stale_connection, child.child_session_id).await?; + assert_eq!(resumed.id, child.child_session_id); + assert_eq!(parent_session_id(&resumed), Some(root_session_id)); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + Some(journal), + "stale-path resume must backfill the shared journal path" + ); + Ok(()) +} + +#[tokio::test] +async fn reindex_removes_stale_db_row_for_tombstoned_child() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + delete_session(&runtime, connection_id, child.child_session_id).await?; + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!(indexed_rollout_path(&db, child.child_session_id)?, None); + // Simulate the crash window: tombstone is durable, but a stale DB row remains. + db.upsert_session( + stale_index_row(data_root.path(), child.child_session_id, root_session_id), + Some(journal.as_path()), + )?; + assert!(db.get_session_index(&child.child_session_id)?.is_some()); + + runtime.refresh_session_index()?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "reindex must delete a tombstoned session's stale row" + ); + assert_eq!( + list_session_ids(&runtime, connection_id, /*include_children*/ true).await?, + [root_session_id].into_iter().collect() + ); + Ok(()) +} + +#[tokio::test] +async fn child_delete_with_missing_index_still_persists_tombstone() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + runtime.shutdown().await; + let db = Database::open(data_root.path().join(DB_FILE))?; + db.delete_session(&child.child_session_id)?; + assert_eq!(indexed_rollout_path(&db, child.child_session_id)?, None); + + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + delete_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + assert!( + journal.is_file(), + "child delete must retain the root journal" + ); + let lines = read_journal(&journal)?; + let tombstones: HashSet = lines + .iter() + .filter_map(|line| match line { + RolloutLine::Internal { + session_id, + entry: InternalRecord::SessionDeleted { .. }, + .. + } => Some(*session_id), + _ => None, + }) + .collect(); + assert_eq!( + tombstones, + [child.child_session_id].into_iter().collect(), + "delete must tombstone even when the index row is missing" + ); + + rebuilt.refresh_session_index()?; + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "missing-index delete must not resurrect after reindex" + ); + assert_eq!( + list_session_ids(&rebuilt, rebuilt_connection, /*include_children*/ true).await?, + [root_session_id].into_iter().collect() + ); + Ok(()) +} + +#[tokio::test] +async fn child_cwd_update_preserves_attribution_and_shared_size_is_not_child_size() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let listed = list_sessions(&runtime, connection_id, /*include_children*/ true).await?; + let child_listed = listed + .iter() + .find(|session| session.id == child.child_session_id) + .expect("child listed"); + // G8 contract: the root reports the whole shared journal size; a shared + // child has no session-owned file, so the optional field is absent. `None` + // is protocol-legal (`Option` with skip-none). + assert_eq!( + child_listed.transcript_size_bytes, None, + "a shared child must not report the whole root journal size" + ); + let root_listed = listed + .iter() + .find(|session| session.id == root_session_id) + .expect("root listed"); + assert!( + root_listed + .transcript_size_bytes + .is_some_and(|size| size > 0), + "root may report its journal size" + ); + + let changed_cwd = data_root.path().join("changed-cwd"); + std::fs::create_dir_all(&changed_cwd)?; + let response = rpc( + &runtime, + connection_id, + 107, + "session/cwd/change", + json!({ "sessionId": child.child_session_id, "cwd": changed_cwd }), + ) + .await?; + ensure_success(&response)?; + + runtime.shutdown().await; + let db = Database::open(data_root.path().join(DB_FILE))?; + db.delete_session(&child.child_session_id)?; + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + rebuilt.refresh_session_index()?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + + let db = Database::open(data_root.path().join(DB_FILE))?; + let child_index = db + .get_session_index(&child.child_session_id)? + .expect("child index after reindex"); + assert_eq!(child_index.session.parent_session_id, Some(root_session_id)); + assert_eq!( + child_index.session.agent_path.as_deref(), + Some("root/child"), + "cwd SessionMeta must preserve child attribution extras" + ); + let default_list = list_sessions( + &rebuilt, + rebuilt_connection, + /*include_children*/ false, + ) + .await?; + assert_eq!( + default_list + .iter() + .map(|session| session.id) + .collect::>(), + [root_session_id].into_iter().collect(), + "a child must not become a root after cwd reindex" + ); + let resumed = resume_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + assert_eq!(resumed.id, child.child_session_id); + assert_eq!(parent_session_id(&resumed), Some(root_session_id)); + let agents = request_agent_list(&rebuilt, rebuilt_connection, root_session_id).await?; + let child_agent = agents + .agents + .iter() + .find(|agent| agent.session_id == child.child_session_id) + .expect("child agent after resume"); + assert_eq!(child_agent.agent_path, "root/child"); + assert_eq!(child_agent.agent_nickname, "child"); + Ok(()) +} + +#[tokio::test] +async fn child_rollback_preview_counts_only_child_turns() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = Arc::new(ScriptedProvider::new([ + ScriptedProvider::completed("child first"), + ScriptedProvider::completed("child second"), + ScriptedProvider::completed("root reply"), + ])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child first", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + start_turn( + &runtime, + connection_id, + child.child_session_id, + "child second", + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + start_turn(&runtime, connection_id, root_session_id, "root task").await?; + wait_for_session_notification(&mut notifications, "turn/completed", root_session_id).await?; + + let response = rpc( + &runtime, + connection_id, + 108, + "session/rollback/preview", + json!({ + "sessionId": child.child_session_id, + "userTurnIndex": 0, + "mode": "beforeUserTurn", + }), + ) + .await?; + ensure_success(&response)?; + let plan: RestorePlan = serde_json::from_value(response["result"].clone())?; + assert_eq!( + plan.dropped_turn_count, 2, + "child rollback must count only the child's two turns, not the root turn" + ); + Ok(()) +} + +#[tokio::test] +async fn live_root_delete_removes_shared_journal_and_reindex_does_not_resurrect() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + let artifact_dir = data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()); + assert!(journal.is_file()); + assert!(artifact_dir.is_dir()); + + // Root and child actors are both loaded here. This is the live-root delete + // path that must physically unlink the shared journal, not only remove DB + // rows. + delete_session(&runtime, connection_id, root_session_id).await?; + assert!( + !journal.exists(), + "live root delete must unlink the journal" + ); + assert!(journal_files(data_root.path())?.is_empty()); + assert!(!artifact_dir.exists()); + + runtime.shutdown().await; + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + rebuilt.refresh_session_index()?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + + assert!( + list_sessions(&rebuilt, rebuilt_connection, /*include_children*/ true) + .await? + .is_empty(), + "deleted root must not resurrect from reindex" + ); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!(indexed_rollout_path(&db, root_session_id)?, None); + assert_eq!(indexed_rollout_path(&db, child.child_session_id)?, None); + let resume_response = rpc( + &rebuilt, + rebuilt_connection, + 109, + "session/resume", + json!({ "sessionId": root_session_id }), + ) + .await?; + let error: ErrorResponse = serde_json::from_value(resume_response)?; + assert_eq!(error.error.code, ProtocolErrorCode::SessionNotFound); + assert!( + journal_files(data_root.path())?.is_empty(), + "reindex/resume must not recreate the deleted root journal" + ); + + // Repeated delete is safe and must not leave a closed-but-present journal. + delete_session(&rebuilt, rebuilt_connection, root_session_id).await?; + assert!(!journal.exists()); + assert!(journal_files(data_root.path())?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn wrong_existing_index_path_delete_and_resume_resolve_true_journal() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let wrong_root_id = create_root_session( + &runtime, + connection_id, + data_root.path(), + "wrong-index-root", + ) + .await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let true_journal = root_journal(data_root.path(), root_session_id); + let wrong_journal = root_journal(data_root.path(), wrong_root_id); + assert!(true_journal.is_file()); + assert!(wrong_journal.is_file()); + let wrong_before = std::fs::read(&wrong_journal)?; + + // Resume with a wrong path that exists: membership must win and the index + // must be repaired to the true root journal. + runtime.shutdown().await; + set_index_rollout_path( + data_root.path(), + child.child_session_id, + Some(&wrong_journal), + )?; + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + let resumed = resume_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + assert_eq!(resumed.id, child.child_session_id); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + Some(true_journal.clone()), + "resume must backfill the true shared journal, not the wrong existing one" + ); + + // Delete with the wrong existing path again. The tombstone must land in the + // true root journal; the wrong journal must be byte-identical. + set_index_rollout_path( + data_root.path(), + child.child_session_id, + Some(&wrong_journal), + )?; + delete_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + let true_lines = read_journal(&true_journal)?; + let tombstones: HashSet = true_lines + .iter() + .filter_map(|line| match line { + RolloutLine::Internal { + session_id, + entry: InternalRecord::SessionDeleted { .. }, + .. + } => Some(*session_id), + _ => None, + }) + .collect(); + assert_eq!(tombstones, [child.child_session_id].into_iter().collect()); + assert_eq!( + std::fs::read(&wrong_journal)?, + wrong_before, + "wrong existing journal must be untouched" + ); + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "deleted child row" + ); + Ok(()) +} + +#[tokio::test] +async fn retry_root_delete_cleans_orphan_child_row_and_artifact() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + let journal = root_journal(data_root.path(), root_session_id); + let child_dir = expected_child_dir(data_root.path(), root_session_id, child.child_session_id); + assert!(journal.is_file()); + assert!(child_dir.is_dir()); + + delete_session(&runtime, connection_id, root_session_id).await?; + assert!(!journal.exists()); + + // Simulate a crash after root unlink but before child DB/artifact cleanup. + let db = Database::open(data_root.path().join(DB_FILE))?; + db.upsert_session( + stale_index_row(data_root.path(), child.child_session_id, root_session_id), + Some(journal.as_path()), + )?; + std::fs::create_dir_all(&child_dir)?; + assert!(db.get_session_index(&child.child_session_id)?.is_some()); + assert!(child_dir.is_dir()); + + // Retry root delete: there is no root DB row/journal, but orphan child + // evidence remains. It must finish cleanup instead of returning Ok(empty). + delete_session(&runtime, connection_id, root_session_id).await?; + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "retry must delete the orphan child row" + ); + assert!( + !child_dir.exists(), + "retry must delete orphan child artifact dir" + ); + assert!( + !journal.exists(), + "retry must not recreate the root journal" + ); + assert_eq!( + list_session_ids(&runtime, connection_id, /*include_children*/ true).await?, + HashSet::new() + ); + Ok(()) +} + +#[tokio::test] +async fn retry_delete_with_missing_target_journal_cleans_db_and_artifact() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + let child_dir = expected_child_dir(data_root.path(), root_session_id, child.child_session_id); + let root_artifact_dir = data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()); + runtime.shutdown().await; + std::fs::remove_file(&journal)?; + + // Child DB row and artifact remain, but the target journal is gone. A retry + // must finish cleanup instead of failing with "journal path is unknown". + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + delete_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "retry must delete the child DB row" + ); + assert!( + !child_dir.exists(), + "retry must delete the child artifact dir" + ); + assert!( + root_artifact_dir.is_dir(), + "child retry must not remove the root artifact tree" + ); + assert!( + !journal.exists(), + "retry must not recreate the root journal" + ); + Ok(()) +} + +#[tokio::test] +async fn delete_with_damaged_journal_fails_closed() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + let journal = root_journal(data_root.path(), root_session_id); + let root_artifact_dir = data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()); + runtime.shutdown().await; + let damaged = b"not-json-damaged-journal\n"; + std::fs::write(&journal, damaged)?; + + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + let response = rpc( + &rebuilt, + rebuilt_connection, + 113, + "session/delete", + json!({ "sessionId": root_session_id }), + ) + .await?; + assert!( + response.get("error").is_some(), + "deleting an unreadable journal must fail closed, got: {response}" + ); + + let db = Database::open(data_root.path().join(DB_FILE))?; + assert!( + db.get_session_index(&root_session_id)?.is_some(), + "failed delete must leave the root DB row" + ); + assert!( + db.get_session_index(&child.child_session_id)?.is_some(), + "failed delete must leave the child DB row" + ); + assert!( + root_artifact_dir.is_dir(), + "failed delete must keep artifacts" + ); + assert_eq!( + std::fs::read(&journal)?, + damaged, + "failed delete must not append a tombstone to a damaged journal" + ); + Ok(()) +} + +#[tokio::test] +async fn user_fork_is_not_an_agent_child_and_owns_its_child_tree() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "fork child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let source_root_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let fork_id = fork_session(&runtime, connection_id, source_root_id).await?; + let fork_journal = root_journal(data_root.path(), fork_id); + let source_journal = root_journal(data_root.path(), source_root_id); + assert!(fork_journal.is_file()); + assert!(source_journal.is_file()); + + let child = spawn_child_named( + &runtime, + fork_id, + "fork child task", + Some("none"), + Some("fork-child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + let child_dir = expected_child_dir(data_root.path(), fork_id, child.child_session_id); + assert!(child_dir.is_dir()); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + Some(fork_journal.clone()), + "agent child under a user fork must use the fork as its lifecycle root" + ); + let fork_index = db.get_session_index(&fork_id)?.expect("fork index"); + assert_eq!(fork_index.session.fork_from_id, Some(source_root_id)); + + // Deleting the source root must not traverse fork_from_id as an agent edge. + delete_session(&runtime, connection_id, source_root_id).await?; + assert!( + !source_journal.exists(), + "source root journal must be deleted" + ); + assert!(fork_journal.is_file(), "user fork journal must remain"); + assert!(child_dir.is_dir(), "fork child artifact must remain"); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, fork_id)?, + Some(fork_journal.clone()) + ); + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + Some(fork_journal.clone()) + ); + + // Deleting the fork root cleans its own agent child and journal only. + delete_session(&runtime, connection_id, fork_id).await?; + assert!(!fork_journal.exists(), "fork journal must be deleted"); + assert!(!child_dir.exists(), "fork child artifact must be deleted"); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!(indexed_rollout_path(&db, fork_id)?, None); + assert_eq!(indexed_rollout_path(&db, child.child_session_id)?, None); + Ok(()) +} + +#[tokio::test] +async fn wrong_index_missing_journal_preserves_unrelated_artifact_sentinel() -> Result<()> { + let data_root = TempDir::new()?; + let provider: Arc = + Arc::new(ScriptedProvider::new([ScriptedProvider::completed( + "child reply", + )])); + let runtime = build_runtime(data_root.path(), provider)?; + let (connection_id, mut notifications) = initialize_connection(&runtime).await?; + let root_session_id = start_parent_session(&runtime, connection_id, data_root.path()).await?; + let child = spawn_child_named( + &runtime, + root_session_id, + "child task", + Some("none"), + Some("child"), + ) + .await?; + wait_for_session_notification(&mut notifications, "turn/completed", child.child_session_id) + .await?; + + let journal = root_journal(data_root.path(), root_session_id); + let child_dir = expected_child_dir(data_root.path(), root_session_id, child.child_session_id); + let unrelated_dir = data_root + .path() + .join("session-artifacts") + .join(root_session_id.to_string()) + .join("agents") + .join("unrelated-sentinel"); + std::fs::create_dir_all(&unrelated_dir)?; + let sentinel = unrelated_dir.join("keep.txt"); + std::fs::write(&sentinel, b"keep")?; + let wrong_missing_path = unrelated_dir.join(format!("{}.jsonl", child.child_session_id)); + assert!(!wrong_missing_path.exists()); + + runtime.shutdown().await; + std::fs::remove_file(&journal)?; + set_index_rollout_path( + data_root.path(), + child.child_session_id, + Some(&wrong_missing_path), + )?; + let rebuilt = build_runtime( + data_root.path(), + Arc::new(ScriptedProvider::new([])) as Arc, + )?; + let (rebuilt_connection, _) = initialize_connection(&rebuilt).await?; + delete_session(&rebuilt, rebuilt_connection, child.child_session_id).await?; + + assert!( + sentinel.is_file(), + "unrelated artifact sentinel must remain after child delete" + ); + assert!( + !child_dir.exists(), + "proven child artifact dir should be removed when lifecycle root is proven" + ); + let db = Database::open(data_root.path().join(DB_FILE))?; + assert_eq!( + indexed_rollout_path(&db, child.child_session_id)?, + None, + "child DB row must be removed" + ); + Ok(()) +} diff --git a/crates/server/tests/support/acp_permission_support.rs b/crates/server/tests/support/acp_permission_support.rs index b8a5bb02..4fbcf2c4 100644 --- a/crates/server/tests/support/acp_permission_support.rs +++ b/crates/server/tests/support/acp_permission_support.rs @@ -340,10 +340,7 @@ async fn wait_for_prompt_completion( assert_eq!(response.result.stop_reason, AcpStopReason::EndTurn); saw_prompt_response = true; } - if saw_prompt_response - && (expect_success && saw_in_progress && saw_terminal - || !expect_success && saw_terminal) - { + if saw_prompt_response && saw_terminal && (!expect_success || saw_in_progress) { return Ok(()); } } diff --git a/crates/server/tests/turn_start_persistence.rs b/crates/server/tests/turn_start_persistence.rs index 0651ab8f..63f8844d 100644 --- a/crates/server/tests/turn_start_persistence.rs +++ b/crates/server/tests/turn_start_persistence.rs @@ -131,6 +131,7 @@ async fn turn_start_append_failure_does_not_launch_model_turn_or_leave_session_a let (connection_id, _notifications_rx) = initialize_connection(&runtime).await?; let session = start_session(&runtime, connection_id, data_root.path()).await?; let rollout_path = rollout_path_for_session(data_root.path(), &session); + let original_journal = std::fs::read(&rollout_path)?; std::fs::remove_file(&rollout_path)?; std::fs::create_dir(&rollout_path)?; @@ -164,6 +165,10 @@ async fn turn_start_append_failure_does_not_launch_model_turn_or_leave_session_a ); std::fs::remove_dir(&rollout_path)?; + // ExistingOnly intentionally refuses to recreate a missing established + // journal. Restore the exact valid journal to model a transient write + // failure, then retry. + std::fs::write(&rollout_path, &original_journal)?; let successful_start = runtime .handle_incoming( connection_id, @@ -196,7 +201,6 @@ async fn turn_start_append_failure_does_not_launch_model_turn_or_leave_session_a Ok(()) } -/// Trace: L2-DES-SERVER-title-generation /// Verifies: turn/start returns with a heuristic title before LLM polish; /// polish waits until after the turn merges and may park on a slow provider. #[tokio::test] @@ -379,7 +383,6 @@ async fn message_edit_previous_accepts_skip_restore_and_replaces_prompt_branch() Ok(()) } -/// Trace: L2-DES-APP-003, L1-REQ-CONV-005 /// Verifies: omitted workspace_restore_policy uses default safe restore and emits restore lifecycle records/events. #[tokio::test] async fn message_edit_previous_default_safe_restore_records_and_broadcasts() -> Result<()> { diff --git a/crates/skills/assets/bundled/agent-message/src/agent_message/__init__.py b/crates/skills/assets/bundled/agent-message/src/agent_message/__init__.py index f8d37e3c..2b970591 100644 --- a/crates/skills/assets/bundled/agent-message/src/agent_message/__init__.py +++ b/crates/skills/assets/bundled/agent-message/src/agent_message/__init__.py @@ -11,7 +11,7 @@ from rlm import host_request ReceiverRole = Literal["parent", "sibling", "child"] -_MESSAGE_DISPLAY_MIME = "application/vnd.devo.agent-message+json" +_MESSAGE_DISPLAY_MIME = "application/vnd.agent-message+json" async def send( diff --git a/crates/skills/assets/bundled/attach-image/src/attach_image/attach_image.py b/crates/skills/assets/bundled/attach-image/src/attach_image/attach_image.py index b59fb309..d45fb0bc 100644 --- a/crates/skills/assets/bundled/attach-image/src/attach_image/attach_image.py +++ b/crates/skills/assets/bundled/attach-image/src/attach_image/attach_image.py @@ -6,8 +6,8 @@ import io from pathlib import Path -# Keep in sync with ATTACHMENT_DISPLAY_MIME in src/core/kernel/index.ts. -_ATTACHMENT_DISPLAY_MIME = "application/vnd.devo.attachment+json" +# Keep in sync with ATTACHMENT_DISPLAY_MIME in crates/kernel/src/session.rs. +_ATTACHMENT_DISPLAY_MIME = "application/vnd.attachment+json" # Keep emitted attachments small enough that daemon clients can render and replay # image-heavy sessions without compressing megabytes of base64 on every update. diff --git a/crates/skills/assets/bundled/edit/src/edit/__init__.py b/crates/skills/assets/bundled/edit/src/edit/__init__.py index bab6fe46..3096f1a9 100644 --- a/crates/skills/assets/bundled/edit/src/edit/__init__.py +++ b/crates/skills/assets/bundled/edit/src/edit/__init__.py @@ -45,8 +45,8 @@ async def run(path: str, old_str: str, new_str: str) -> str: return f"Edited {resolved_path}" -# Keep in sync with DIFF_DISPLAY_MIME in src/core/kernel/index.ts. -_DIFF_DISPLAY_MIME = "application/vnd.devo.diff+json" +# Keep in sync with DIFF_DISPLAY_MIME in crates/kernel/src/session.rs. +_DIFF_DISPLAY_MIME = "application/vnd.diff+json" def _emit_diff(path: str, old_str: str, new_str: str, start_line: int) -> None: diff --git a/crates/tools/src/output_store.rs b/crates/tools/src/output_store.rs index 0889da4a..59ce83db 100644 --- a/crates/tools/src/output_store.rs +++ b/crates/tools/src/output_store.rs @@ -309,7 +309,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - /// Trace: L2-DES-CONTEXT-004 #[test] fn saved_output_survives_reopening_and_rejects_replacement() { let directory = tempfile::tempdir().unwrap(); @@ -332,7 +331,6 @@ mod tests { ); } - /// Trace: L2-DES-CONTEXT-004 #[test] fn long_line_has_bounded_continuation() { let directory = tempfile::tempdir().unwrap(); @@ -355,7 +353,6 @@ mod tests { None ); } - /// Trace: L2-DES-CONTEXT-004. Polls retain one file and UTF-8 pagination advances. #[test] fn growing_capture_and_tiny_unicode_pages() { let directory = tempfile::tempdir().unwrap(); @@ -385,7 +382,6 @@ mod tests { ); } - /// Trace: L2-DES-CONTEXT-004. Equal contents do not authorize another file. #[test] fn identical_replacement_is_rejected_by_identity() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/utils/process/src/pty/pipe.rs b/crates/utils/process/src/pty/pipe.rs index 3c9eec20..8cb5fde9 100644 --- a/crates/utils/process/src/pty/pipe.rs +++ b/crates/utils/process/src/pty/pipe.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::io; use std::io::ErrorKind; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -102,6 +102,7 @@ async fn spawn_process_with_stdin_mode( program: &str, args: &[String], cwd: &Path, + sandbox_workspace: &Path, env: &HashMap, arg0: &Option, stdin_mode: PipeStdinMode, @@ -122,7 +123,7 @@ async fn spawn_process_with_stdin_mode( #[cfg(unix)] let sandbox_wrap = devo_sandbox::wrap_command_for_profile_with_overlay( sandbox_profile.as_deref(), - cwd, + sandbox_workspace, devo_sandbox::WrapMode::PipeComposed, &devo_sandbox::SandboxLogger::new(), sandbox_overlay.as_ref(), @@ -154,12 +155,10 @@ async fn spawn_process_with_stdin_mode( #[cfg(unix)] let inherited_fds = inherited_fds.to_vec(); #[cfg(unix)] - let sandbox_workspace = cwd.to_path_buf(); - #[cfg(unix)] let sandbox_plan = if sandbox_wrap.requires_child_apply() { crate::sandbox::resolve_profile_for_spawn_with_overlay( sandbox_profile.as_deref(), - &sandbox_workspace, + sandbox_workspace, sandbox_overlay.as_ref(), )? } else { @@ -177,7 +176,7 @@ async fn spawn_process_with_stdin_mode( }); } #[cfg(not(unix))] - let _ = (arg0, sandbox_profile, sandbox_overlay); + let _ = (arg0, sandbox_profile, sandbox_overlay, sandbox_workspace); command.current_dir(cwd); command.env_clear(); for (key, value) in env { @@ -186,7 +185,8 @@ async fn spawn_process_with_stdin_mode( // Restricted profiles get ProxyOnly capability via wrap, but the child // still needs HTTP(S)_PROXY pointing at the managed localhost proxy. #[cfg(unix)] - for (key, value) in devo_sandbox::proxy_env_for_sandbox_profile(sandbox_profile.as_deref(), cwd) + for (key, value) in + devo_sandbox::proxy_env_for_sandbox_profile(sandbox_profile.as_deref(), sandbox_workspace) { command.env(key, value); } @@ -325,6 +325,7 @@ pub async fn spawn_process( program, args, cwd, + cwd, env, arg0, PipeStdinMode::Piped, @@ -349,6 +350,7 @@ pub async fn spawn_process_sandboxed( program, args, cwd, + cwd, env, arg0, PipeStdinMode::Piped, @@ -384,6 +386,7 @@ pub async fn spawn_process_no_stdin_with_inherited_fds( program, args, cwd, + cwd, env, arg0, PipeStdinMode::Null, @@ -416,17 +419,23 @@ pub async fn spawn_process_no_stdin_sandboxed( SandboxProcessOptions { sandbox_profile, sandbox_overlay: None, + sandbox_workspace: cwd.to_path_buf(), }, ) .await } -/// Optional sandbox settings for a pipe-backed process. +/// Sandbox settings for a pipe-backed process. pub struct SandboxProcessOptions { /// Named sandbox profile applied before the child process starts. pub sandbox_profile: Option, /// Per-invocation permissions merged into the named profile. pub sandbox_overlay: Option, + /// Trusted workspace used for profile, overlay, pre_exec, and proxy + /// resolution. The child still starts in `cwd`. Legacy wrappers that do not + /// know a session workspace pass `cwd`; model-facing callers must pass the + /// trusted session root. + pub sandbox_workspace: PathBuf, } /// Spawn a pipe process with a named sandbox profile and per-invocation overlay. @@ -443,6 +452,7 @@ pub async fn spawn_process_no_stdin_sandboxed_with_overlay( program, args, cwd, + &options.sandbox_workspace, env, arg0, PipeStdinMode::Null, diff --git a/crates/utils/process/src/pty/process_group.rs b/crates/utils/process/src/pty/process_group.rs index f8056acc..4b6497bd 100644 --- a/crates/utils/process/src/pty/process_group.rs +++ b/crates/utils/process/src/pty/process_group.rs @@ -167,6 +167,45 @@ pub fn kill_process_group(_process_group_id: u32) -> io::Result<()> { Ok(()) } +#[cfg(unix)] +/// Observe a child's exit without reaping it. +/// +/// Returns `Ok(true)` after `waitid(P_PID, pid, WEXITED | WNOWAIT)` reports the +/// exit, leaving the child in a waitable zombie state. While that state holds, +/// the child's pid/pgid is reserved and cannot be reused, so the caller may +/// safely send a final signal to the process group before the ordinary +/// `Child::wait` reaps it. +/// +/// The caller must have exclusive ownership of this child's wait status: no +/// other thread/future may poll `Child::wait`/`try_wait` or reap the pid +/// concurrently. `EINTR` is retried. `ECHILD` (already reaped) returns +/// `Ok(false)`; any other error is returned. In both non-`Ok(true)` cases the +/// caller must skip the final group signal because the pid may be reusable. +pub fn wait_exit_without_reaping(pid: u32) -> io::Result { + let mut info = std::mem::MaybeUninit::::zeroed(); + loop { + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + info.as_mut_ptr(), + libc::WEXITED | libc::WNOWAIT, + ) + }; + if result == 0 { + return Ok(true); + } + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + if error.raw_os_error() == Some(libc::ECHILD) { + return Ok(false); + } + return Err(error); + } +} + #[cfg(unix)] /// Kill the process group for a tokio child (best-effort). pub fn kill_child_process_group(child: &mut Child) -> io::Result<()> { @@ -182,3 +221,30 @@ pub fn kill_child_process_group(child: &mut Child) -> io::Result<()> { pub fn kill_child_process_group(_child: &mut Child) -> io::Result<()> { Ok(()) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn wait_exit_without_reaping_leaves_child_waitable() { + let mut child = Command::new("true").spawn().expect("spawn true"); + let pid = child.id(); + assert!( + wait_exit_without_reaping(pid).expect("waitid observation"), + "the child must be observed as an unreaped exit" + ); + assert!( + child + .wait() + .expect("ordinary wait reaps observed child") + .success(), + "child should exit successfully" + ); + assert!( + !wait_exit_without_reaping(pid).expect("second observation"), + "an already-reaped child must return ECHILD -> false" + ); + } +} diff --git a/crates/windows-sandbox/DEVO_PATCHES.md b/crates/windows-sandbox/DEVO_PATCHES.md index 7367e251..c1a4ca18 100644 --- a/crates/windows-sandbox/DEVO_PATCHES.md +++ b/crates/windows-sandbox/DEVO_PATCHES.md @@ -25,31 +25,14 @@ done - devo-added integration files (no upstream counterpart): `launch.rs`, `windows_impl.rs`, `request_adapter.rs`, `path_util.rs`, `string_util.rs`, `capture_stub.rs`, `otel_stub.rs`, `protocol/`, `unified_exec/`, `conpty/`, - `pty/`, `elevated/`, and `credential_delivery.rs`. -- `credential_delivery.rs` is the one devo-owned *security* module (goal rule - 5): codex's per-exec model never delivers credentials to a long-lived - process. Per-session capability SID + inheritable allow-ACE grant/revoke - (write and read masks) + crash-recovery journal (`credential_journal.json`) + - orphan sweep. Upstream cannot host it by design. + `pty/`, and `elevated/`. -## Credential-delivery plumbing (devo-owned hunks in upstream files, P2) +## Kernel credential delivery removed -All of these exist only to carry the per-session credential SID to the -sandboxed long-lived kernel; codex has no equivalent need: - -- `wrapper.rs`: `--session-credential-sid` flag (builder param, wrapper-request - field, parse arm). -- `token.rs`: `create_readonly_token_with_caps_user_and_additional_restrictions_from` - and `create_workspace_write_token_with_caps_and_additional_restrictions_from` - (identity markers; deliberately excluded from the default DACL) + - `additional_restricting_tests`. -- `WindowsSandboxSessionRequest` / `WindowsSandboxRequest`: `session_credential_sid` - field, threaded through `unified_exec/mod.rs` → `backends/legacy.rs` → - `spawn_prep.rs` (`prepare_legacy_session_security` param). -- `launch.rs`: `prepare_direct_argv_launch` + `prepare_windows_sandbox_launch_for_argv` - (direct-argv launcher for non-shell callers). -- Callers pass `None` everywhere except the RLM kernel fence path - (`crates/kernel/src/fence.rs`). +The Python kernel is trusted orchestration. Host-side shell execution retains +ordinary Windows sandbox restrictions. The kernel-only credential authority, +credential journal, session SID wrapper flag, and token-injection APIs have +been removed; there is no longer a long-lived sandboxed kernel identity. ## Ports applied 2026-09-19 (from upstream v0.150.1) diff --git a/crates/windows-sandbox/src/credential_delivery.rs b/crates/windows-sandbox/src/credential_delivery.rs deleted file mode 100644 index f75130c0..00000000 --- a/crates/windows-sandbox/src/credential_delivery.rs +++ /dev/null @@ -1,535 +0,0 @@ -//! Per-session credential authority. Devo-owned: codex's per-exec model never -//! needs to deliver credentials to a long-lived sandboxed process, so this -//! layer cannot live upstream (see `DEVO_PATCHES.md`). -//! -//! A session-scoped capability SID is minted once per kernel session and, when -//! the kernel is fenced, travels in its restricted token. Granting a path = -//! adding an inheritable allow ACE for that SID (raw `open()` works -//! immediately, no kernel restart); revoking = removing the ACE (new opens are -//! refused; already-open handles keep working — a known residual). -//! Every grant/revoke is journaled to `/.sandbox/credential_journal.json` -//! so crash recovery can re-derive deliveries. The journal is the authority -//! for recovery — not just an audit trail. -//! -//! Startup orphan cleanup is not called automatically: this journal has no -//! cross-process liveness markers, and one server could otherwise revoke the -//! live credentials of an independently spawned kernel using the same home. -//! Elevated-account tokens also need access in the normal account SID pass, -//! not only this restricting SID pass; keep mediated approval fallback for -//! roots the sandbox account cannot otherwise access. - -use crate::acl::add_allow_ace; -use crate::acl::ensure_allow_mask_aces_with_inheritance; -use crate::acl::revoke_ace_checked; -use crate::setup::sandbox_dir; -use crate::token::LocalSid; -use anyhow::Result; -use rand::RngCore; -use rand::SeedableRng; -use rand::rngs::SmallRng; -use serde::Deserialize; -use serde::Serialize; -use std::collections::BTreeMap; -use std::fs; -use std::path::Path; -use std::path::PathBuf; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; - -/// Inheritance flags (mirroring acl.rs's private constants): delivered ACEs -/// propagate to the granted subtree. -const CONTAINER_INHERIT_ACE: u32 = 0x2; -const OBJECT_INHERIT_ACE: u32 = 0x1; - -pub struct SessionCredentialAuthority { - sid_string: String, - journal_path: PathBuf, - session_id: String, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct CredentialJournal { - /// session_id → credential record. The SID is journaled alongside the - /// grants because revocation is impossible without it, and crash recovery - /// (not just audit) reads this file. - sessions: BTreeMap, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct SessionRecord { - sid: String, - #[serde(default)] - grants: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct GrantRecord { - root: PathBuf, - /// "read" | "write" — which mask the delivered ACE carries. - access: String, -} - -/// Access class of a delivered credential ACE. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CredentialAccess { - Read, - Write, -} - -impl CredentialAccess { - fn as_str(self) -> &'static str { - match self { - Self::Read => "read", - Self::Write => "write", - } - } -} - -/// Canonical read mask for delivered read credentials (mirrors -/// `FileSystemAccessMode::Read` in acl.rs). -const GENERIC_READ_MASK: u32 = 0x8000_0000; - -fn make_random_session_sid() -> String { - let mut rng = SmallRng::from_entropy(); - let a = rng.next_u32(); - let b = rng.next_u32(); - let c = rng.next_u32(); - let d = rng.next_u32(); - format!("S-1-5-21-{a}-{b}-{c}-{d}") -} - -fn journal_path_for(devo_home: &Path) -> PathBuf { - sandbox_dir(devo_home).join("credential_journal.json") -} - -fn load_journal(path: &Path) -> Result { - let bytes = match fs::read(path) { - Ok(bytes) => bytes, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(CredentialJournal::default()); - } - Err(err) => return Err(err.into()), - }; - // A truncated or unreadable journal must never be mistaken for an empty - // journal: that would discard the only record of previously delivered ACEs. - Ok(serde_json::from_slice(&bytes)?) -} - -fn persist_journal(path: &Path, journal: &CredentialJournal) -> Result<()> { - let dir = path - .parent() - .ok_or_else(|| anyhow::anyhow!("journal has no parent"))?; - fs::create_dir_all(dir)?; - // Never truncate the authority journal in place. A crash or full disk - // during serialization would otherwise erase all earlier ACE records. - let mut temporary = tempfile::NamedTempFile::new_in(dir)?; - serde_json::to_writer(&mut temporary, journal)?; - temporary.as_file().sync_all()?; - temporary.persist(path)?; - Ok(()) -} - -impl SessionCredentialAuthority { - /// Mint a session credential authority. Does not touch any DACL yet; the - /// journal gains its session row on the first grant. - pub fn new(session_id: &str, devo_home: &Path) -> Result { - let sid_string = make_random_session_sid(); - // Validate the SID shape eagerly; store only the string so this type - // stays Send+Sync (LocalSid wraps a raw pointer and is neither). - LocalSid::from_string(&sid_string)?; - Ok(Self { - sid_string, - journal_path: journal_path_for(devo_home), - session_id: session_id.to_string(), - }) - } - - /// Transient SID handle for one ACE operation. - fn local_sid(&self) -> Result { - LocalSid::from_string(&self.sid_string) - } - - pub fn sid(&self) -> &str { - &self.sid_string - } - - /// Grant write access to `root` for this session: inheritable allow ACE on - /// the root (Windows propagation covers descendants), journaled. Idempotent - /// — an already-granted root is a no-op. Returns `true` when a new ACE was - /// applied. - pub fn grant_write_root(&self, root: &Path) -> Result { - self.grant_root(root, CredentialAccess::Write) - } - - /// Grant read access to `root` (read-mask inheritable allow ACE), journaled - /// and idempotent, same contract as [`grant_write_root`]. - pub fn grant_read_root(&self, root: &Path) -> Result { - self.grant_root(root, CredentialAccess::Read) - } - - fn grant_root(&self, root: &Path, access: CredentialAccess) -> Result { - let mut journal = load_journal(&self.journal_path)?; - let record = journal - .sessions - .entry(self.session_id.clone()) - .or_insert_with(|| SessionRecord { - sid: self.sid_string.clone(), - grants: Vec::new(), - }); - // Journal before touching the ACL. If delivery fails or the process - // crashes between these steps, the row still allows a later retry or - // cleanup. Retrying an already-journaled entry rechecks the DACL. - if !record - .grants - .iter() - .any(|g| g.root == root && g.access == access.as_str()) - { - record.grants.push(GrantRecord { - root: root.to_path_buf(), - access: access.as_str().to_string(), - }); - persist_journal(&self.journal_path, &journal)?; - } - let sid = self.local_sid()?; - let applied = match access { - CredentialAccess::Write => unsafe { add_allow_ace(root, sid.as_ptr()) }?, - CredentialAccess::Read => unsafe { - ensure_allow_mask_aces_with_inheritance( - root, - &[sid.as_ptr()], - FILE_GENERIC_READ | GENERIC_READ_MASK, - CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, - )? - }, - }; - Ok(applied) - } - - /// Revoke one granted root. Keep its journal entry if the ACL operation - /// fails so the next cleanup can retry it. - pub fn revoke_write_root(&self, root: &Path) -> Result<()> { - let mut journal = load_journal(&self.journal_path)?; - let Some(record) = journal.sessions.get_mut(&self.session_id) else { - return Ok(()); - }; - let sid = self.local_sid()?; - let (_, errors) = revoke_record_grants( - record, - |grant| grant.root == root, - |path| unsafe { revoke_ace_checked(path, sid.as_ptr()) }, - ); - if record.grants.is_empty() { - journal.sessions.remove(&self.session_id); - } - persist_journal(&self.journal_path, &journal)?; - finish_revocations(errors) - } - - /// Revoke all delivered roots. A failed root stays in the journal for - /// retry; successfully revoked roots are removed even on partial failure. - pub fn revoke_all(&self) -> Result { - let mut journal = load_journal(&self.journal_path)?; - let Some(record) = journal.sessions.get_mut(&self.session_id) else { - return Ok(0); - }; - let sid = self.local_sid()?; - let (revoked, errors) = revoke_record_grants( - record, - |_| true, - |path| unsafe { revoke_ace_checked(path, sid.as_ptr()) }, - ); - if record.grants.is_empty() { - journal.sessions.remove(&self.session_id); - } - persist_journal(&self.journal_path, &journal)?; - finish_revocations(errors)?; - Ok(revoked) - } -} - -/// A failure keeps exactly the unresolved grant in the caller's journal. This -/// helper also permits deterministic failure-injection tests without changing -/// filesystem ACLs or requiring an elevated Windows test runner. -fn revoke_record_grants( - record: &mut SessionRecord, - should_revoke: impl Fn(&GrantRecord) -> bool, - mut revoke: impl FnMut(&Path) -> Result<()>, -) -> (usize, Vec) { - let mut revoked = 0; - let mut errors = Vec::new(); - record.grants.retain(|grant| { - if !should_revoke(grant) { - return true; - } - match revoke(&grant.root) { - Ok(()) => { - revoked += 1; - false - } - Err(err) => { - errors.push(err); - true - } - } - }); - (revoked, errors) -} - -fn finish_revocations(mut errors: Vec) -> Result<()> { - if errors.is_empty() { - Ok(()) - } else { - let count = errors.len(); - Err(anyhow::anyhow!( - "{count} sandbox credential ACL revocation(s) failed; grants remain journaled: {}", - errors.remove(0) - )) - } -} - -/// Explicitly sweep sessions known to be dead, after the caller has verified -/// liveness across *all* daemon processes using this home. Do not call with an -/// empty live set at startup unless exclusive ownership of the home is proven: -/// another daemon may have a live fenced kernel with a journaled session. -pub fn sweep_orphaned_sessions(devo_home: &Path, live_session_ids: &[&str]) -> Result { - let journal_path = journal_path_for(devo_home); - let mut journal = load_journal(&journal_path)?; - let dead: Vec = journal - .sessions - .keys() - .filter(|id| !live_session_ids.contains(&id.as_str())) - .cloned() - .collect(); - let mut swept = 0usize; - let mut errors = Vec::new(); - for id in &dead { - let Some(record) = journal.sessions.get_mut(id) else { - continue; - }; - let sid = match LocalSid::from_string(&record.sid) { - Ok(sid) => sid, - Err(err) => { - errors.push(err); - continue; - } - }; - let (revoked, failures) = revoke_record_grants( - record, - |_| true, - |path| unsafe { revoke_ace_checked(path, sid.as_ptr()) }, - ); - swept += revoked; - errors.extend(failures); - if record.grants.is_empty() { - journal.sessions.remove(id); - } - } - persist_journal(&journal_path, &journal)?; - finish_revocations(errors)?; - Ok(swept) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::acl::dacl_has_write_allow_for_sid; - use crate::acl::fetch_dacl_handle; - use pretty_assertions::assert_eq; - - #[test] - fn failed_acl_revoke_preserves_only_unresolved_journal_entries() { - let mut record = SessionRecord { - sid: "S-1-5-21-101-102-103-104".into(), - grants: ["revoke", "fail", "unrelated"] - .map(|name| GrantRecord { - root: PathBuf::from(name), - access: "write".into(), - }) - .into(), - }; - let (revoked, errors) = revoke_record_grants( - &mut record, - |grant| grant.root.as_path() != Path::new("unrelated"), - |path| { - if path == Path::new("fail") { - anyhow::bail!("injected ACL failure") - } - Ok(()) - }, - ); - assert_eq!(revoked, 1); - assert_eq!(errors.len(), 1); - assert_eq!( - record - .grants - .iter() - .map(|grant| grant.root.as_path()) - .collect::>(), - vec![Path::new("fail"), Path::new("unrelated")] - ); - assert!(finish_revocations(errors).is_err()); - let tmp = tempfile::tempdir().expect("tempdir"); - let path = journal_path_for(tmp.path()); - let journal = CredentialJournal { - sessions: BTreeMap::from([("ses_failed".into(), record)]), - }; - persist_journal(&path, &journal).expect("save retry state"); - let retried = load_journal(&path).expect("reload retry state"); - assert_eq!(retried.sessions["ses_failed"].grants.len(), 2); - assert_eq!( - retried.sessions["ses_failed"].grants[0].root, - PathBuf::from("fail") - ); - } - - #[test] - fn corrupted_journal_must_not_be_treated_as_empty() { - let tmp = tempfile::tempdir().expect("tempdir"); - let path = journal_path_for(tmp.path()); - fs::create_dir_all(path.parent().expect("journal parent")).expect("create parent"); - fs::write(&path, b"{truncated").expect("write journal"); - assert!(load_journal(&path).is_err()); - } - - #[test] - fn grant_and_revoke_write_root_round_trip() { - let tmp = tempfile::tempdir().expect("tempdir"); - let home = tempfile::tempdir().expect("home"); - let root = tmp.path().join("granted"); - fs::create_dir_all(&root).expect("root"); - - let authority = - SessionCredentialAuthority::new("ses_test_1", home.path()).expect("authority"); - // Bind the LocalSid: as_ptr() on a temporary would dangle after the - // statement ends. - let sid = LocalSid::from_string(authority.sid()).expect("sid"); - let sid_ptr = sid.as_ptr(); - - let (dacl, _sd) = unsafe { fetch_dacl_handle(&root).expect("dacl") }; - assert!( - !unsafe { dacl_has_write_allow_for_sid(dacl, sid_ptr) }, - "fresh root must not carry the session SID" - ); - - assert!(authority.grant_write_root(&root).expect("grant")); - // Idempotent: second grant is a journal no-op (Ok(false) or ACE-exists). - let again = authority.grant_write_root(&root).expect("grant again"); - assert!(!again, "second grant must be a no-op"); - - let (dacl, _sd) = unsafe { fetch_dacl_handle(&root).expect("dacl") }; - assert!( - unsafe { dacl_has_write_allow_for_sid(dacl, sid_ptr) }, - "granted root must carry the session SID allow ACE" - ); - - authority.revoke_write_root(&root).expect("revoke"); - let (dacl, _sd) = unsafe { fetch_dacl_handle(&root).expect("dacl") }; - assert!( - !unsafe { dacl_has_write_allow_for_sid(dacl, sid_ptr) }, - "revoked root must not carry the session SID ACE" - ); - } - - #[test] - fn revoke_all_clears_journal_row_and_aces() { - let tmp = tempfile::tempdir().expect("tempdir"); - let home = tempfile::tempdir().expect("home"); - let a = tmp.path().join("a"); - let b = tmp.path().join("b"); - fs::create_dir_all(&a).expect("a"); - fs::create_dir_all(&b).expect("b"); - - let authority = - SessionCredentialAuthority::new("ses_test_2", home.path()).expect("authority"); - authority.grant_write_root(&a).expect("grant a"); - authority.grant_write_root(&b).expect("grant b"); - - assert_eq!(authority.revoke_all().expect("revoke all"), 2); - assert_eq!(authority.revoke_all().expect("revoke all again"), 0); - - let journal = load_journal(&journal_path_for(home.path())).expect("journal"); - assert!(journal.sessions.is_empty(), "journal row must be gone"); - } - - #[test] - fn sweep_removes_only_dead_sessions() { - let tmp = tempfile::tempdir().expect("tempdir"); - let home = tempfile::tempdir().expect("home"); - let root = tmp.path().join("dead-owned"); - fs::create_dir_all(&root).expect("root"); - - let dead = SessionCredentialAuthority::new("ses_dead", home.path()).expect("dead"); - dead.grant_write_root(&root).expect("grant"); - let live = SessionCredentialAuthority::new("ses_live", home.path()).expect("live"); - - let swept = super::sweep_orphaned_sessions(home.path(), &["ses_live"]).expect("sweep"); - assert_eq!(swept, 1, "dead session root must be swept"); - - let (dacl, _sd) = unsafe { fetch_dacl_handle(&root).expect("dacl") }; - let dead_sid = LocalSid::from_string(dead.sid()).expect("sid"); - assert!( - !unsafe { dacl_has_write_allow_for_sid(dacl, dead_sid.as_ptr()) }, - "dead session ACE must be removed" - ); - let _ = live; // live authority untouched by the sweep - } - - /// Env-driven grant runner (operational tool, not an assertion): - /// `DEVO_GRANT_SID` + `DEVO_GRANT_ROOT` [+ `DEVO_GRANT_HOME`] delivers a - /// write ACE for an already-running fenced kernel's session SID. - /// Runs as a test so it shares the crate's token/ACL plumbing: - /// `cargo test -p devo-windows-sandbox --lib grant_for_running_session -- --nocapture` - #[test] - fn grant_for_running_session() { - let (Ok(sid), Ok(root), home) = ( - std::env::var("DEVO_GRANT_SID"), - std::env::var("DEVO_GRANT_ROOT"), - std::env::var("DEVO_GRANT_HOME").unwrap_or_else(|_| { - std::env::var_os("USERPROFILE") - .map(|p| format!("{}\\.devo", p.to_string_lossy())) - .unwrap_or_else(|| ".devo".to_string()) - }), - ) else { - eprintln!("skip: DEVO_GRANT_SID/DEVO_GRANT_ROOT not set"); - return; - }; - let _authority = - SessionCredentialAuthority::new(&format!("kernel-{sid}"), std::path::Path::new(&home)) - .expect("authority"); - // The authority mints a fresh SID; for an external grant the SID is - // fixed, so rebind via the journal path with the given SID. - let applied = unsafe { - crate::acl::add_allow_ace( - std::path::Path::new(&root), - LocalSid::from_string(&sid).expect("sid").as_ptr(), - ) - } - .expect("grant"); - eprintln!("granted write ACE root={root} applied={applied}"); - } - - #[test] - fn grant_read_root_delivers_read_mask_ace() { - let tmp = tempfile::tempdir().expect("tempdir"); - let home = tempfile::tempdir().expect("home"); - let root = tmp.path().join("readable"); - fs::create_dir_all(&root).expect("root"); - - let authority = - SessionCredentialAuthority::new("ses_test_read", home.path()).expect("authority"); - assert!(authority.grant_read_root(&root).expect("grant read")); - assert!(!authority.grant_read_root(&root).expect("grant read again")); - - let sid = LocalSid::from_string(authority.sid()).expect("sid"); - let read_mask = FILE_GENERIC_READ | GENERIC_READ_MASK; - assert!( - crate::acl::path_mask_allows( - &root, - &[sid.as_ptr()], - read_mask, - /*require_all_bits*/ false, - ) - .expect("mask check"), - "read grant must deliver a read-mask allow ACE" - ); - } -} diff --git a/crates/windows-sandbox/src/launch.rs b/crates/windows-sandbox/src/launch.rs index d124c9ca..d3396bba 100644 --- a/crates/windows-sandbox/src/launch.rs +++ b/crates/windows-sandbox/src/launch.rs @@ -16,25 +16,6 @@ pub(crate) fn prepare_launch(req: &WindowsSandboxRequest) -> anyhow::Result, -) -> anyhow::Result { - prepare_direct_argv_launch_with_env(req, inner_command, env::vars().collect()) -} - -/// A caller-supplied inherited environment is necessary for the kernel: its -/// credential filter must run BEFORE we serialize this map in wrapper argv. -pub(crate) fn prepare_direct_argv_launch_with_env( - req: &WindowsSandboxRequest, - inner_command: Vec, - inherited_env: Vec<(String, String)>, -) -> anyhow::Result { let permission_profile = permission_profile_from_request(req)?; let workspace_roots = workspace_roots_from_request(req)?; let command_cwd = workspace_roots @@ -44,7 +25,7 @@ pub(crate) fn prepare_direct_argv_launch_with_env( let deny_read_paths_override = deny_read_overrides(req)?; let devo_home = find_devo_home()?; - let env_map = compose_launch_env(inherited_env, &req.env_extra); + let env_map = compose_launch_env(env::vars().collect(), &req.env_extra); let current_exe = env::current_exe()?; // Library integration tests live in Cargo's `debug/deps` directory and do // not implement the CLI's sandbox early-dispatch hook. Use the adjacent @@ -62,11 +43,8 @@ pub(crate) fn prepare_direct_argv_launch_with_env( workspace_roots.as_slice(), &env_map, &permission_profile, - // Network-restricted fences need the elevated backend: the legacy - // restricted token carries no account-level firewall, so outbound - // stays open (verified: fenced kernel reached 1.1.1.1:443). The - // elevated backend runs the child as DevoSandboxOffline, whose - // firewall rules block non-loopback outbound. + // Network restriction requires the elevated backend's account-level + // firewall; a legacy restricted token alone does not block outbound. if req.restrict_network { WindowsSandboxLevel::Elevated } else { @@ -81,7 +59,6 @@ pub(crate) fn prepare_direct_argv_launch_with_env( deny_read_paths_override.as_slice(), &[], devo_home.as_path(), - req.session_credential_sid.as_deref(), ); Ok(WindowsSandboxLaunch { @@ -96,8 +73,7 @@ fn compose_launch_env( extra_env: &[(String, String)], ) -> HashMap { let mut env_map = inherited_env.into_iter().collect::>(); - // Explicit kernel overrides remain intentional, including values whose - // names resemble secrets. The kernel host filters only inherited values. + // Explicit child overrides take precedence over inherited values. for (key, value) in extra_env { env_map.insert(key.clone(), value.clone()); } @@ -107,19 +83,23 @@ fn compose_launch_env( #[cfg(test)] mod tests { use super::compose_launch_env; + use pretty_assertions::assert_eq; #[test] - fn filtered_kernel_env_is_not_restored_from_parent() { - // `OPENAI_API_KEY` was filtered out by the kernel before this boundary. + fn explicit_child_env_overrides_inherited_values() { let env_map = compose_launch_env( - vec![("PATH".into(), r"C:\Python".into())], - &[("PYTHONUTF8".into(), "1".into())], + vec![ + ("PATH".into(), r"C:\Python".into()), + ("MODE".into(), "old".into()), + ], + &[("MODE".into(), "new".into())], + ); + assert_eq!( + env_map, + std::collections::HashMap::from([ + ("PATH".into(), r"C:\Python".into()), + ("MODE".into(), "new".into()), + ]) ); - assert_eq!(env_map.get("PATH").map(String::as_str), Some(r"C:\Python")); - assert_eq!(env_map.get("PYTHONUTF8").map(String::as_str), Some("1")); - assert!(!env_map.contains_key("OPENAI_API_KEY")); - // The exact map is also serialized into wrapper --env-json argv. - let serialized = serde_json::to_string(&env_map).expect("env JSON"); - assert!(!serialized.contains("OPENAI_API_KEY")); } } diff --git a/crates/windows-sandbox/src/lib.rs b/crates/windows-sandbox/src/lib.rs index ae148258..117a5d6b 100644 --- a/crates/windows-sandbox/src/lib.rs +++ b/crates/windows-sandbox/src/lib.rs @@ -62,9 +62,6 @@ pub struct WindowsSandboxRequest { pub writable_roots: Vec, pub deny_read: Vec, pub restrict_network: bool, - /// Per-session credential SID (design doc §9, P2); `None` for plain - /// per-command sandboxing. See `credential_delivery`. - pub session_credential_sid: Option, /// Extra env the sandboxed child must receive. The wrapper builds the /// child's environment from its argv env-json snapshot, so callers cannot /// rely on setting `Command::env` after wrapping. @@ -97,8 +94,8 @@ pub fn prepare_windows_sandbox_launch( /// One-shot provisioning entry (`devo sandbox-setup`): request the elevated /// setup for workspace-write permissions around `cwd`. The UAC consent prompt -/// appears; after it completes, `sandbox_setup_is_complete` flips true and the -/// RLM kernel fence can be raised (design doc §5.3). +/// appears; after it completes, `sandbox_setup_is_complete` flips true and +/// host-side sandboxed commands can use the provisioned account. #[cfg(windows)] pub fn request_default_sandbox_setup(devo_home: &Path, cwd: &Path) -> anyhow::Result<()> { let profile = PermissionProfile::workspace_write(); @@ -121,29 +118,6 @@ pub fn request_default_sandbox_setup(devo_home: &Path, cwd: &Path) -> anyhow::Re ) } -/// Direct-argv sandbox launch for non-shell callers (e.g. the RLM kernel host -/// execs `python -m rlm.repl`). Shell-shaped fields of `req` are ignored; -/// `inner_command` is the exact argv executed inside the sandbox. -#[cfg(windows)] -pub fn prepare_windows_sandbox_launch_for_argv( - req: &WindowsSandboxRequest, - inner_command: Vec, -) -> anyhow::Result { - launch::prepare_direct_argv_launch(req, inner_command) -} - -/// Direct-argv launch with a caller-filtered inherited environment. Unlike -/// `Command::env_remove`, this filters the snapshot serialized into the -/// wrapper's command-line arguments BEFORE the wrapper process starts. -#[cfg(windows)] -pub fn prepare_windows_sandbox_launch_for_argv_with_env( - req: &WindowsSandboxRequest, - inner_command: Vec, - inherited_env: Vec<(String, String)>, -) -> anyhow::Result { - launch::prepare_direct_argv_launch_with_env(req, inner_command, inherited_env) -} - /// CLI early-dispatch hook: if argv requests the Windows sandbox wrapper, run it /// (never returns on success). Otherwise returns `Ok(false)`. pub fn run_as_windows_sandbox_if_requested(args: &[String]) -> anyhow::Result { @@ -193,8 +167,6 @@ mod capture_stub; #[cfg(windows)] mod conpty; #[cfg(windows)] -mod credential_delivery; -#[cfg(windows)] mod deny_read_acl; mod deny_read_resolver; #[cfg(windows)] @@ -307,10 +279,6 @@ pub use conpty::ConptyInstance; #[cfg(windows)] pub use conpty::spawn_conpty_process_as_user; #[cfg(windows)] -pub use credential_delivery::SessionCredentialAuthority; -#[cfg(windows)] -pub use credential_delivery::sweep_orphaned_sessions; -#[cfg(windows)] pub use deny_read_acl::apply_deny_read_acls; #[cfg(windows)] pub use deny_read_acl::plan_deny_read_acl_paths; @@ -450,8 +418,6 @@ pub use token::create_readonly_token_with_caps_and_user_from; #[cfg(windows)] pub use token::create_readonly_token_with_caps_from; #[cfg(windows)] -pub use token::create_readonly_token_with_caps_user_and_additional_restrictions_from; -#[cfg(windows)] pub use token::create_workspace_write_token_with_caps_and_user_from; #[cfg(windows)] pub use token::create_workspace_write_token_with_caps_from; @@ -513,7 +479,6 @@ mod tests { writable_roots: vec![], deny_read: vec![], restrict_network: false, - session_credential_sid: None, env_extra: vec![], }) .expect("prepare"); diff --git a/crates/windows-sandbox/src/protocol/permissions.rs b/crates/windows-sandbox/src/protocol/permissions.rs index 2c639f71..7fcc2661 100644 --- a/crates/windows-sandbox/src/protocol/permissions.rs +++ b/crates/windows-sandbox/src/protocol/permissions.rs @@ -1554,8 +1554,9 @@ mod tests { #[test] fn unix_symlinked_roots_preserve_runtime_paths() { let cwd = TempDir::new().expect("tempdir"); - let real_root = cwd.path().join("real"); - let link_root = cwd.path().join("link"); + let cwd_path = cwd.path().canonicalize().expect("canonicalize tempdir"); + let real_root = cwd_path.join("real"); + let link_root = cwd_path.join("link"); fs::create_dir_all(real_root.join("blocked")).expect("create blocked"); fs::create_dir_all(real_root.join(".codex")).expect("create .codex"); fs::create_dir_all(real_root.join(".agents")).expect("create .agents"); @@ -1568,12 +1569,12 @@ mod tests { path_entry(link_blocked.clone(), FileSystemAccessMode::Deny), ]); assert_eq!( - policy.get_unreadable_roots_with_cwd(cwd.path()), + policy.get_unreadable_roots_with_cwd(cwd_path.as_path()), vec![link_blocked.clone()] ); assert_single_writable_root( &policy, - cwd.path(), + cwd_path.as_path(), &link_root, &[ link_root.join(".agents"), @@ -1609,7 +1610,7 @@ mod tests { &[], ); - let root = cwd.path().join("root"); + let root = cwd_path.join("root"); let decoy = root.join("decoy-legacy-agent"); fs::create_dir_all(&decoy).expect("create decoy"); symlink_dir(&decoy, &root.join(".codex")).expect("create .codex symlink"); @@ -1624,7 +1625,7 @@ mod tests { root.clone(), FileSystemAccessMode::Write, )]), - cwd.path(), + cwd_path.as_path(), &root, &[expected_codex], &[abs(&decoy.canonicalize().expect("canonicalize decoy"))], @@ -1640,12 +1641,13 @@ mod tests { ("alias-root", true), ] { let cwd = TempDir::new().expect("tempdir"); - let real_root = cwd.path().join("real"); - let link_root = cwd.path().join("link"); + let cwd_path = cwd.path().canonicalize().expect("canonicalize tempdir"); + let real_root = cwd_path.join("real"); + let link_root = cwd_path.join("link"); let decoy = if symlink_under_root { real_root.join(decoy_parent) } else { - cwd.path().join(decoy_parent) + cwd_path.join(decoy_parent) }; let linked_private = real_root.join("linked-private"); if symlink_under_root && decoy_parent == "alias-root" { @@ -1686,7 +1688,7 @@ mod tests { }; assert_single_writable_root( &policy, - cwd.path(), + cwd_path.as_path(), &expected_root, std::slice::from_ref(&link_private), &excluded, diff --git a/crates/windows-sandbox/src/spawn_prep.rs b/crates/windows-sandbox/src/spawn_prep.rs index 9e8c8990..36e69944 100644 --- a/crates/windows-sandbox/src/spawn_prep.rs +++ b/crates/windows-sandbox/src/spawn_prep.rs @@ -25,7 +25,6 @@ use crate::sandbox_utils::inject_git_safe_directory; use crate::setup::effective_write_roots_for_permissions; use crate::token::LocalSid; use crate::token::create_readonly_token_with_cap; -use crate::token::create_workspace_write_token_with_caps_and_additional_restrictions_from; use crate::token::create_workspace_write_token_with_caps_from; use crate::token::get_current_token_for_restriction; use crate::token::get_logon_sid_bytes; @@ -150,7 +149,6 @@ pub(crate) fn prepare_legacy_session_security( devo_home: &Path, cwd: &Path, capability_roots: impl IntoIterator, - session_credential_sid: Option<&str>, ) -> Result { let caps = load_or_create_cap_sids(devo_home)?; let (h_token, readonly_sid, readonly_sid_str, write_root_sids) = unsafe { @@ -164,19 +162,7 @@ pub(crate) fn prepare_legacy_session_security( .iter() .map(|root| root.sid.as_ptr()) .collect(); - let h_token = match session_credential_sid - .map(crate::token::LocalSid::from_string) - .transpose()? - { - Some(session_sid) => { - create_workspace_write_token_with_caps_and_additional_restrictions_from( - base, - cap_ptrs.as_slice(), - &[session_sid.as_ptr()], - ) - } - None => create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()), - }; + let h_token = create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()); CloseHandle(base); let h_token = h_token?; (h_token, None, None, write_root_sids) diff --git a/crates/windows-sandbox/src/token.rs b/crates/windows-sandbox/src/token.rs index e74e3e10..ef0ff122 100644 --- a/crates/windows-sandbox/src/token.rs +++ b/crates/windows-sandbox/src/token.rs @@ -409,20 +409,6 @@ pub unsafe fn create_workspace_write_token_with_caps_from( create_token_with_caps_from(base_token, psid_capabilities, &[]) } -/// Same as [`create_workspace_write_token_with_caps_from`] plus additional -/// restricting SIDs (identity markers for the credential-delivery layer, -/// design doc §9 — deliberately excluded from the default DACL). -/// -/// # Safety -/// Caller must close the returned token handle; base_token must be a valid primary token. -pub unsafe fn create_workspace_write_token_with_caps_and_additional_restrictions_from( - base_token: HANDLE, - psid_capabilities: &[*mut c_void], - additional_restricting_sids: &[*mut c_void], -) -> Result { - create_token_with_caps_from(base_token, psid_capabilities, additional_restricting_sids) -} - /// Create a restricted token that includes all provided capability SIDs plus the token user SID. /// /// This is intended for the elevated sandbox backend, where the token user is the dedicated @@ -466,30 +452,6 @@ pub unsafe fn create_readonly_token_with_caps_and_user_from( create_token_with_caps_from(base_token, psid_capabilities, &[psid_user]) } -/// Create a restricted token that includes all provided capability SIDs, the -/// token user SID, and any additional restricting SIDs. -/// -/// Additional restricting SIDs are identity markers, not capabilities: the -/// credential-delivery layer (per-session capability SID, design doc §9) uses -/// one to attribute a sandboxed kernel session, and `create_token_with_caps_from` -/// deliberately keeps them out of the default DACL so possessing the identity -/// alone grants no object access. -/// -/// # Safety -/// Caller must close the returned token handle; base_token must be a valid primary token. -pub unsafe fn create_readonly_token_with_caps_user_and_additional_restrictions_from( - base_token: HANDLE, - psid_capabilities: &[*mut c_void], - additional_restricting_sids: &[*mut c_void], -) -> Result { - let mut user_sid_bytes = get_user_sid_bytes(base_token)?; - let psid_user = user_sid_bytes.as_mut_ptr() as *mut c_void; - let mut extra_restricting_sids = Vec::with_capacity(additional_restricting_sids.len() + 1); - extra_restricting_sids.push(psid_user); - extra_restricting_sids.extend_from_slice(additional_restricting_sids); - create_token_with_caps_from(base_token, psid_capabilities, &extra_restricting_sids) -} - unsafe fn create_token_with_caps_from( base_token: HANDLE, psid_capabilities: &[*mut c_void], @@ -547,34 +509,3 @@ unsafe fn create_token_with_caps_from( enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; Ok(new_token) } - -#[cfg(test)] -mod additional_restricting_tests { - use super::*; - use crate::token::LocalSid; - - /// Ported from upstream (codex v0.150.1 token.rs:435-446): a restricted - /// token must accept extra restricting SIDs alongside the user SID without - /// them entering the default DACL (identity, not capability). - #[test] - fn additional_restricting_sids_are_accepted() { - if !restricted_token_creation_available() { - return; - } - let base = unsafe { get_current_token_for_restriction() }.expect("base token"); - let cap = LocalSid::from_string("S-1-5-21-101-102-103-104").expect("cap sid"); - let extra = LocalSid::from_string("S-1-5-21-201-202-203-204").expect("extra sid"); - let token = unsafe { - create_readonly_token_with_caps_user_and_additional_restrictions_from( - base, - &[cap.as_ptr()], - &[extra.as_ptr()], - ) - } - .expect("restricted token with additional restricting sid"); - unsafe { - windows_sys::Win32::Foundation::CloseHandle(token); - windows_sys::Win32::Foundation::CloseHandle(base); - } - } -} diff --git a/crates/windows-sandbox/src/unified_exec/backends/legacy.rs b/crates/windows-sandbox/src/unified_exec/backends/legacy.rs index f2792e0e..f7d82e6d 100644 --- a/crates/windows-sandbox/src/unified_exec/backends/legacy.rs +++ b/crates/windows-sandbox/src/unified_exec/backends/legacy.rs @@ -281,7 +281,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( timeout_ms: Option, additional_deny_read_paths: &[AbsolutePathBuf], additional_deny_write_paths: &[AbsolutePathBuf], - session_credential_sid: Option<&str>, tty: bool, stdin_open: bool, use_private_desktop: bool, @@ -321,7 +320,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( devo_home, cwd, capability_roots, - session_credential_sid, )?; allow_null_device_for_workspace_write(common.uses_write_capabilities); diff --git a/crates/windows-sandbox/src/unified_exec/mod.rs b/crates/windows-sandbox/src/unified_exec/mod.rs index 868f5f90..327b771f 100644 --- a/crates/windows-sandbox/src/unified_exec/mod.rs +++ b/crates/windows-sandbox/src/unified_exec/mod.rs @@ -38,11 +38,6 @@ pub struct WindowsSandboxSessionRequest<'a> { pub write_roots_override: Option<&'a [PathBuf]>, pub deny_read_paths_override: &'a [AbsolutePathBuf], pub deny_write_paths_override: &'a [AbsolutePathBuf], - /// Per-session credential SID (design doc §9, P2): injected into the - /// sandboxed process's restricted token as an identity marker. ACEs granted - /// later via `SessionCredentialAuthority` make raw opens work for exactly - /// this session — without restarting it. - pub session_credential_sid: Option<&'a str>, pub tty: bool, pub stdin_open: bool, pub use_private_desktop: bool, @@ -95,7 +90,6 @@ pub async fn spawn_windows_sandbox_session_for_level( request.timeout_ms, request.deny_read_paths_override, request.deny_write_paths_override, - request.session_credential_sid, request.tty, request.stdin_open, request.use_private_desktop, @@ -115,7 +109,6 @@ pub async fn spawn_windows_sandbox_session_legacy( timeout_ms: Option, additional_deny_read_paths: &[AbsolutePathBuf], additional_deny_write_paths: &[AbsolutePathBuf], - session_credential_sid: Option<&str>, tty: bool, stdin_open: bool, use_private_desktop: bool, @@ -130,7 +123,6 @@ pub async fn spawn_windows_sandbox_session_legacy( timeout_ms, additional_deny_read_paths, additional_deny_write_paths, - session_credential_sid, tty, stdin_open, use_private_desktop, diff --git a/crates/windows-sandbox/src/unified_exec/tests.rs b/crates/windows-sandbox/src/unified_exec/tests.rs index 348dc81e..89688bf2 100644 --- a/crates/windows-sandbox/src/unified_exec/tests.rs +++ b/crates/windows-sandbox/src/unified_exec/tests.rs @@ -183,7 +183,6 @@ fn legacy_non_tty_cmd_emits_output() { Some(5_000), &[], &[], - /*session_credential_sid*/ None, /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, @@ -227,7 +226,6 @@ fn legacy_non_tty_cmd_rejects_deny_read_overrides() { Some(5_000), std::slice::from_ref(&secret_path), &[], - /*session_credential_sid*/ None, /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, @@ -271,7 +269,6 @@ fn legacy_non_tty_powershell_emits_output() { Some(5_000), &[], &[], - /*session_credential_sid*/ None, /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, @@ -560,7 +557,6 @@ fn legacy_workspace_write_delete_is_limited_to_writable_roots() { /*timeout_ms*/ Some(5_000), &[], &[], - /*session_credential_sid*/ None, /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, @@ -670,7 +666,6 @@ fn legacy_tty_powershell_emits_output_and_accepts_input() { Some(10_000), &[], &[], - /*session_credential_sid*/ None, /*tty*/ true, /*stdin_open*/ true, /*use_private_desktop*/ true, @@ -722,7 +717,6 @@ fn legacy_tty_cmd_emits_output_and_accepts_input() { Some(10_000), &[], &[], - /*session_credential_sid*/ None, /*tty*/ true, /*stdin_open*/ true, /*use_private_desktop*/ true, @@ -777,7 +771,6 @@ fn legacy_tty_cmd_default_desktop_emits_output_and_accepts_input() { Some(10_000), &[], &[], - /*session_credential_sid*/ None, /*tty*/ true, /*stdin_open*/ true, /*use_private_desktop*/ false, diff --git a/crates/windows-sandbox/src/windows_impl.rs b/crates/windows-sandbox/src/windows_impl.rs index e40f3d27..d9cf94ad 100644 --- a/crates/windows-sandbox/src/windows_impl.rs +++ b/crates/windows-sandbox/src/windows_impl.rs @@ -229,13 +229,8 @@ pub fn run_windows_sandbox_capture_with_filesystem_overrides( } let capability_roots = legacy_session_capability_roots(&permissions, ¤t_dir, &env_map, devo_home); - let security = prepare_legacy_session_security( - uses_write_capabilities, - devo_home, - cwd, - capability_roots, - /*session_credential_sid*/ None, - )?; + let security = + prepare_legacy_session_security(uses_write_capabilities, devo_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(uses_write_capabilities); apply_legacy_session_acl_rules( &permissions, diff --git a/crates/windows-sandbox/src/wrapper.rs b/crates/windows-sandbox/src/wrapper.rs index c931c5f9..5d82cb4a 100644 --- a/crates/windows-sandbox/src/wrapper.rs +++ b/crates/windows-sandbox/src/wrapper.rs @@ -31,7 +31,6 @@ const PROXY_ENFORCED_FLAG: &str = "--proxy-enforced"; const READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG: &str = "--read-roots-include-platform-defaults"; const READ_ROOTS_JSON_FLAG: &str = "--read-roots-json"; const SANDBOX_LEVEL_FLAG: &str = "--windows-sandbox-level"; -const SESSION_CREDENTIAL_SID_FLAG: &str = "--session-credential-sid"; const WRITE_ROOTS_JSON_FLAG: &str = "--write-roots-json"; const WORKSPACE_ROOT_FLAG: &str = "--workspace-root"; @@ -52,7 +51,6 @@ pub fn create_windows_sandbox_command_args_for_permission_profile( deny_read_paths_override: &[AbsolutePathBuf], deny_write_paths_override: &[AbsolutePathBuf], devo_home: &Path, - session_credential_sid: Option<&str>, ) -> Vec { let permission_profile_json = serde_json::to_string(permission_profile) .unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}")); @@ -112,10 +110,6 @@ pub fn create_windows_sandbox_command_args_for_permission_profile( &deny_write_paths_override, ); } - if let Some(sid) = session_credential_sid { - args.push(SESSION_CREDENTIAL_SID_FLAG.to_string()); - args.push(sid.to_string()); - } args.push("--".to_string()); args.extend(command); args @@ -175,7 +169,6 @@ struct WindowsSandboxWrapperRequest { write_roots_override: Option>, deny_read_paths_override: Vec, deny_write_paths_override: Vec, - session_credential_sid: Option, command: Vec, } @@ -200,7 +193,6 @@ async fn run_windows_sandbox_wrapper_request(request: WindowsSandboxWrapperReque write_roots_override: request.write_roots_override.as_deref(), deny_read_paths_override: request.deny_read_paths_override.as_slice(), deny_write_paths_override: request.deny_write_paths_override.as_slice(), - session_credential_sid: request.session_credential_sid.as_deref(), tty: false, stdin_open: true, use_private_desktop: request.windows_sandbox_private_desktop, @@ -226,7 +218,6 @@ fn parse_windows_sandbox_wrapper_args(args: Vec) -> Result) -> Result proxy_enforced = true, - SESSION_CREDENTIAL_SID_FLAG => { - session_credential_sid = Some(next_flag_value(&mut args, &arg)?); - } READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG => { read_roots_include_platform_defaults = true; } @@ -312,7 +300,6 @@ fn parse_windows_sandbox_wrapper_args(args: Vec) -> Result **Temporarily disabled.** OS sandboxing and the permission picker are -> disabled repo-wide: every process runs with full user permissions and new -> sessions default to full-access. Revert by flipping -> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), -> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), -> and the default-preset literals back to their asking defaults. - - -Use the existing Cargo workspace and check the component being edited first. -The Node TUI loads TypeScript source directly; UI edits do not require rebuilding -the Rust server. Keep one built server and use `DEVO_SERVER_BIN` when launching -`npm start` from `apps/tui`. - -```sh -# A targeted TUI regression; no Rust compilation. -cd apps/tui -node --import tsx --test src/native-to-pi/tools.test.ts -npm test - -# From the repository root: type-check one Rust crate without linking binaries. -cargo check --locked -p devo-harness - -# Compile/run only the relevant crate's unit tests during iteration. -cargo test --locked -p devo-harness --lib - -# Select an integration-test executable before applying a test-name filter. -cargo test --locked -p devo-server --test goal_continuation - -# Build the CLI only when the Rust runtime changes. -cargo build --locked -p devo-cli --bin devo -``` - -On Windows, also build the sandbox helper binaries when they change: -`cargo build --locked -p devo-windows-sandbox --bins`. -Do not overwrite a running Windows executable; stage a runtime copy as the -desktop development launcher does, or close only your own test session first. - -Avoid `cargo test --workspace` for every edit: a test-name filter alone still -builds the selected packages' test executables. Use `-p`, `--lib`, or `--test` -to bound that compilation. Broaden tests when shared contracts or dependencies -change. Full workspace validation remains necessary before merging Rust changes. - -Keep `RUSTFLAGS`, profiles, and the target directory stable to reuse Cargo's -incremental artifacts. An isolated worktree can reuse a dedicated QA target -directory through `CARGO_TARGET_DIR`; avoid concurrent builds against the same -target directory because Cargo serializes them. TypeScript and Python changes -can be tested without waiting for that Rust build lock. - -The server's stdio test helpers resolve CLI and MCP fixture binaries beside -the running test's `deps` directory. Their on-demand Cargo builds use that -same profile (`debug` maps to Cargo's `dev` name), so `dev-fast` tests cannot -silently select an older `target/debug/devo`. Cargo checks fixture freshness -once per test process; subsequent child launches reuse the checked binary. -The build pins the canonical target directory from Cargo's compiled test -metadata and preserves an explicit target-triple subdirectory. Relative -`CARGO_TARGET_DIR` values therefore retain the same cache when the fixture -build changes working directory. Missing or unexpected output fails visibly. An explicit `CARGO_BIN_EXE_devo` -fixture override remains supported. These helpers change test execution only. - -## Optional faster Rust iteration - -Use `dev-fast` when filename/line backtraces are enough for the edit: - -```sh -cargo build --locked --profile dev-fast -p devo-cli --bin devo -cargo test --locked --profile dev-fast -p devo-server --lib compact -``` - -This profile inherits `dev` and changes only debug information to -`line-tables-only`. Incremental compilation, debug assertions, overflow checks -and optimization settings still come from `dev`. It keeps filename/line -backtraces but omits variable and parameter debugger information. Use the -normal `dev` profile when inspecting those values in a debugger. Default builds, -tests and release packaging keep their existing profiles. - -Artifacts are under `target/dev-fast` (or the selected `CARGO_TARGET_DIR`). -Point `DEVO_SERVER_BIN` at its `devo` binary when running the development TUI. -On Windows, build the helper binaries in that profile before using the sandbox: - -```sh -cargo build --locked --profile dev-fast -p devo-windows-sandbox --bins -``` - -The first build warms a separate profile cache; keep using the same profile -and target directory during an edit cycle. Switching profiles is not a way to -avoid that one-time cost. - -Two small server log-string edits on one Windows machine, with both profiles -warm and the order reversed for the second edit, gave: - -| Edit | Normal dev | dev-fast | -| --- | ---: | ---: | -| A (dev first) | 25.53s | 12.03s | -| B (dev-fast first) | 21.90s | 10.50s | - -These are CLI rebuild wall times for a small source edit, not clean-build, -CI or arbitrary-change guarantees. Record `--timings` for the component being -changed before making broader compiler, crate-boundary or build-system changes. -Cargo documents the [debug information tradeoff](https://doc.rust-lang.org/cargo/reference/profiles.html#debug). - -## CI scope - -`scripts/ci_scope.py` selects the Rust, desktop, TUI, and Python-kernel jobs for -pull requests. A TUI-only PR runs TUI tests without the Rust workspace matrix; -a desktop-only PR runs the desktop checks (including protocol generation). -Rust crates and embedded Python/skill assets select every component. Unknown -paths, build/CI configuration, and unavailable diffs also select every component. -Renames include both the removed and added paths. Pushes to the main branches -always run full CI. The separate runtime-bundle workflow remains unchanged. - -## TUI tmux smoke in CI - -Two jobs drive the product TTY under tmux with the scenario runner -(`apps/tui/scripts/run-tmux-smoke.js`; scenarios described in the repository -AGENTS.md). Both build the debug CLI and boot the real TUI against a -per-run temporary Devo home; assertions read the Native protocol trace -(turn outcomes, RPC success), never exact model reply text. - -- **`tmux` (secret-free, every qualifying PR):** boots the TUI (T1), runs the - `/goal` and `/heartbeat` slash flows, and completes a real turn (T2) against - a local mock provider (`apps/tui/scripts/smoke-mock-provider.mjs`, an - OpenAI-completions SSE stub) seeded via `providers.json` + - `auth.json` in `DEVO_HOME`. Fork PRs run this job because it needs no - credentials. -- **`tmux-live` (repo-owned runs only):** completes real turns on all three - model wire APIs, on `ubuntu-latest` and `macos-latest` (macOS exercises the - seatbelt fence and the `python3` interpreter fallback): - - **Chat Completions** — DeepSeek at `https://api.deepseek.com` - (`openai_chat_completions`), using the `DEEPSEEK_API_KEY` repository - secret; this configuration also runs the interrupt scenario (T3). - - **Anthropic Messages** — DeepSeek's Anthropic-compatible endpoint - `https://api.deepseek.com/anthropic` (`anthropic_messages`), same secret. - - **Responses** — OpenAI at `https://api.openai.com/v1` - (`openai_responses`, `gpt-5-mini`), using the optional - `OPENAI_API_KEY` repository secret; the step is skipped when that secret - is absent. - The job is skipped when `DEEPSEEK_API_KEY` is absent — including fork PRs, - where secrets are unavailable. Add secrets under *Settings → Secrets and - variables → Actions*; spare low-quota keys are sufficient because each run - issues a handful of short prompts. - -The mock provider never validates credentials and speaks all three wires -(`--wire openai|anthropic|responses`), so the same seeded-home shape works -for local dry runs: - -```sh -node apps/tui/scripts/smoke-mock-provider.mjs 8801 --wire anthropic & -home=$(mktemp -d) -printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "http://127.0.0.1:8801", "wire_api": "anthropic_messages"}}}' > "$home/providers.json" -printf '%s\n' '{"deepseek": {"type": "api_key", "key": "ci-mock-key"}}' > "$home/auth.json" -DEVO_HOME="$home" node apps/tui/scripts/run-tmux-smoke.js T2 -``` - -Preview a committed change's scope without building: - -```sh -python scripts/ci_scope.py --base HEAD~1 --head HEAD -python -m unittest discover -s scripts -p test_ci_scope.py -v -``` - -This change reduces unrelated builds and tests; it does not claim a measured -reduction in rustc's per-crate compilation time. Before adopting another build -system or changing debug/linker settings, profile representative edits: - -```sh -cargo build --timings -p devo-cli --bin devo -``` - -Cargo documents [package and target selection](https://doc.rust-lang.org/cargo/commands/cargo-test.html) -and [build timing reports](https://doc.rust-lang.org/cargo/reference/timings.html). -A new build system is not required for component selection and would introduce -a second dependency graph to maintain. diff --git a/docs/devo-home-permissions.md b/docs/devo-home-permissions.md deleted file mode 100644 index 2db85fbc..00000000 --- a/docs/devo-home-permissions.md +++ /dev/null @@ -1,37 +0,0 @@ -# Devo home permissions - -> **Temporarily disabled.** OS sandboxing and the permission picker are -> disabled repo-wide: every process runs with full user permissions and new -> sessions default to full-access. Revert by flipping -> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), -> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), -> and the default-preset literals back to their asking defaults. - - -`DEVO_HOME` (default: `~/.devo`) is agent-owned state. Shell tools and the RLM -Python kernel can read and write it, including root-level files. The kernel -receives this grant in both Plan and Build modes; Plan still keeps the project -workspace read-only. - -On Windows, the sandbox backend retains the Devo home write root instead of -filtering it out. Its own `.sandbox`, `.sandbox-bin`, and `.sandbox-secrets` -control directories retain their existing locks and cannot be granted as -independent writable roots. - -Session permission changes continue to use `session/metadata/update`. -The TUI `/permissions` selector offers `default`, `autoReview`, and `fullAccess`. - -## Verification (Windows, 2026-10-15) - -Built with `cargo build -p devo-cli -p devo-windows-sandbox --bins`. -The 20 Windows setup tests, two kernel-fence tests, and the Plan/Build workspace -permission test passed. Rust formatting checks passed for the changed modules. - -Manually launched `target/debug/devo.exe` under psmux with an explicit repository -cwd. Verified all three `/permissions` profiles and used the selector to switch -from `fullAccess` back to `default`. In the same session, two real `ipython` turns -created unique root-level probe files in `C:\Users\lenovo\.devo`, wrote and read -back a marker, and deleted the probes. The second turn ran after switching back -to `default`; its tool result reported write/read and deletion success. Server -logs confirmed the kernel launched with `state=Fenced` and completed its handshake. -The temporary psmux session was removed after verification. diff --git a/docs/goal-resume-handoff.md b/docs/goal-resume-handoff.md deleted file mode 100644 index 954325b6..00000000 --- a/docs/goal-resume-handoff.md +++ /dev/null @@ -1,23 +0,0 @@ -# Goal activation while a turn is finishing - -Pausing a goal cancels its continuation and waits a bounded time for termination. -A Python cell can take longer to finish cancellation, event delivery and merge. -An explicit resume arriving in that interval must remain pending even though the -active-turn registry still owns the interrupted turn. - -Goal activation now records that request on the active execution. After merge, -cleanup removes the execution and consumes the request under the same registry -lock, then post-turn scheduling checks the current goal and queued user input. -A request arriving after cleanup takes the ordinary idle admission path. Normal -Escape interruption does not itself request another goal continuation. - -A pause response that finishes after a concurrent resume mirrors and returns the -latest host goal state, avoiding an older paused snapshot replacing that resume. -Turn execution and approval continuation use atomic full cleanup after merge; -the previous separate interrupt-handle cleanup is no longer needed there. - -The integration regression runs a real Python cell and a bounded slow subscriber -to hold terminal delivery open. Baseline interrupts the first turn but loses the -resume; the fix admits a second continuation, completes the goal from Python and -terminates normally. The test gates on the cell's filesystem marker rather than -an execution preview or an arbitrary race-establishing sleep. diff --git a/docs/live-goal-context.md b/docs/live-goal-context.md deleted file mode 100644 index 27241847..00000000 --- a/docs/live-goal-context.md +++ /dev/null @@ -1,20 +0,0 @@ -# Goal changes during a turn - -Goal state is owned by the host. A running query owns a checked-out conversation -copy, so updating the session actor alone does not update its prompt context. - -Host goal changes now publish an override through the active turn's shared -`live_goal` handle and await the short actor command. The query applies that -override before assembling its next model request. An unset override preserves -the turn-start goal; a cleared goal removes the active-goal reminder. This is -goal control state, independent of session settings and model selection. - -MergeTurn preserves the actor's goal mirror so an older conversation copy cannot -overwrite a goal changed during the turn. Python `goal.complete()` still returns -normally, allowing the model to report completion and usage before ending its -turn. It does not force an interrupt or change keyboard behavior. - -The goal completion integration test captures both model requests: the first -contains the active-goal reminder, and the request after successful completion -contains the tool result without that reminder. Existing lifecycle tests cover -host interruption, pause, resume and continuation admission. diff --git a/docs/native-request-stack.md b/docs/native-request-stack.md deleted file mode 100644 index 9656526e..00000000 --- a/docs/native-request-stack.md +++ /dev/null @@ -1,38 +0,0 @@ -# RPC dispatch stack budget - -The Native dispatcher awaits many different handlers. Rust's inline async -state must accommodate the largest branch, even when the client requests a -small operation such as initialization or ping. On Windows `dev-fast`, the -incoming request futures occupied 101,664 and 101,600 bytes in callers. Nested -debug test futures could overflow the default thread stack. Increasing the -test stack hid this dependency and made local validation less convenient. - -`handle_incoming_with_actions` heap-pins the selected async handler before -awaiting it. This keeps each handler's state out of the dispatch future. It -also separates handler polling from the dispatch state; boxing only the outer -dispatch future was insufficient in the observed Windows debug failure. -Request routing, awaiting order, response construction and ACP post-response -actions are unchanged. The public methods remain async functions. - -This costs one heap allocation for a selected handler, including ACP routing. -Cheap readiness checks, client response resolution and inline ping responses -remain inline. It does not create a task, change cancellation ownership, or -introduce parallel execution. - -`native_request_future_size` limits both public incoming futures to 2 KiB of -caller storage. It infers their types without constructing the oversized -baseline future on the test stack. The changed Windows futures measured -1,240 and 416 bytes. The test intentionally allows layout variation instead -of requiring those exact implementation sizes. - -The goal pause/resume regression now uses ordinary Tokio test threads instead -of a custom 8 MiB thread. Goal, persistence, queued-interruption and ACP tests -exercise the dispatch behavior as well as the size guard. Windows integration -tests that launch a real kernel need an already provisioned QA home; otherwise -first-run UAC setup can exceed their short timeouts. That setup limitation is -separate from the request future's storage budget. - -These measurements concern caller storage and local validation reliability. -They do not establish a runtime throughput or compilation-speed improvement. -Use the opt-in `dev-fast` profile and affected-package commands described in -[development checks](development-checks.md) for measured build-efficiency improvements. diff --git a/docs/queued-turn-cancellation.md b/docs/queued-turn-cancellation.md deleted file mode 100644 index 565410cc..00000000 --- a/docs/queued-turn-cancellation.md +++ /dev/null @@ -1,26 +0,0 @@ -# Queued-turn cancellation - -A drained follow-up is a normal in-flight turn. Before publishing its start, -queue admission registers its Native turn metadata and cancellation token in -the active-turn registry. Both queue-start paths run it on a dedicated task -whose abort handle belongs to that same registry. - -Admission installs the reservation snapshot before the active metadata, matching -ordinary turn admission. Immediate queue reads after restore can then resolve -the admitted turn before its execution task begins. - -`session/interrupt` can then find and cancel the queued turn, just as it does -an ordinary turn. Cancellation goes through normal finalization and merge; -the dedicated abort handle remains available for timeout recovery. The -post-turn chain still waits for the queued task to finish before returning. -No session-map lock is retained while awaiting actor commands in admission. - -The TUI's immediate abort display is not proof of server cancellation. QA -requires the matching Native terminal turn to be interrupted and pending -agent-requested compaction to be cleared without a later snapshot. - -`queued_turn_interrupt.rs` gates provider responses to ensure a follow-up is -queued before the first turn ends, then interrupts its pending model stream. -It also checks a responsive mid-turn session read and retains an idle queue -push as a control for ordinary cancellation. No timing sleeps establish the -race. Ctrl+O, Escape key bindings and model-selection behavior are unchanged. diff --git a/docs/rlm-shell-escalation.md b/docs/rlm-shell-escalation.md deleted file mode 100644 index 8713d909..00000000 --- a/docs/rlm-shell-escalation.md +++ /dev/null @@ -1,79 +0,0 @@ -# RLM shell approval - -> **Temporarily disabled.** OS sandboxing and the permission picker are -> disabled repo-wide: every process runs with full user permissions and new -> sessions default to full-access. Revert by flipping -> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), -> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), -> and the default-preset literals back to their asking defaults. - - -The default RLM tool surface exposes only `ipython`. Models, including -`gpt-5.6-luna`, can request shell permissions from a Python cell using: - -```python -result = await rlm.shell( - "echo permission-probe", - sandbox_permissions="require_escalated", - justification="May I run this harmless probe outside the sandbox?", -) -print(result) -``` - -Use `sandbox_permissions="with_additional_permissions"` with -`additional_permissions` for narrow filesystem or network grants, for example -`{"network": {"enabled": True}}` or -`{"file_system": {"write": ["/absolute/path"]}}` (use native absolute paths). The request -uses the same parsing, approval UI, permission cache and shell executor as the -shell tool. An approved full escalation disables sandboxing for that command; -a narrow approval applies its overlay. The persistent Python kernel stays fenced. -Plan mode continues to deny shell mutation. A rejected approval raises an error -without executing or retrying the command locally. - -Ordinary `bash()` remains a local, fenced, asynchronous process helper. Use -`rlm.shell()` when a command needs approval; do not add permission fields to the -outer `ipython` tool call. Both `import rlm; await rlm.shell(...)` and the -preloaded `rlm` namespace support the helper. Commands use the host platform shell (PowerShell on Windows); use native -syntax instead of nesting another shell. It returns the structured host -shell result, including output and status. `timeout_ms` and `prefix_rule` are -optional. File access still uses `rlm.read()` and `rlm.write()`. - -## Manual verification (Windows, 2026-10-15) - -Ran the product binary under psmux with `gpt-5.6-luna` and `default` permissions. -The model used `rlm.shell()` from a real Python tool cell: - -- Full escalation displayed an approval prompt; choosing Yes ran the echo probe - with exit 0 and captured its output. -- Choosing No for a second escalation returned `RuntimeError: rejected by user`; - the model stopped and did not retry through another route. -- A narrow `file_system.write` grant displayed an approval prompt and allowed a - native PowerShell command to create, read/assert, and delete a probe in an - explicitly approved directory outside the workspace, with exit 0. -- Server logs recorded `state=Fenced` for the persistent Python kernel and user - approval decisions. The test session and probe directory were cleaned up. - -The initial narrow-grant command nested PowerShell with incorrect quoting. It -failed at execution; using native PowerShell syntax passed. The host's existing -PowerShell encoding prelude emits constrained-language warnings for sandboxed -commands, but the corrected probe still completed all file operations with exit 0. - -## Headless execution - -`devo prompt` accepts the same `rlm.shell()` helper. Its host bridge projects -`bash` and `bash.start` requests into the existing hidden shell runtime handler, -retaining permission metadata, command prefixes, and timeouts. Its existing -static-allow permission policy applies explicitly requested full or narrow grants -without marking them as human approvals. The headless command does not offer -interactive approval prompts; use the interactive client when a human approval -decision is required. - -Headless model selection uses `provider/model[/variant]` references, for example -`devo --model openai-codex/gpt-5.6-luna prompt "your task"`. An invalid explicit -reference returns an error rather than silently running the configured default. - -On Linux, `/dev/tty` exists even when a process has no controlling terminal. -The sandbox omits that optional device grant when opening it returns `ENXIO`, -so detached headless kernels can start while retaining filesystem and network -enforcement. The Linux regression probe verifies startup, scratch access, and -continued denial of `/proc/self/environ` after applying the kernel fence. diff --git a/docs/runtime-isolation.md b/docs/runtime-isolation.md deleted file mode 100644 index 3e3fa1d9..00000000 --- a/docs/runtime-isolation.md +++ /dev/null @@ -1,30 +0,0 @@ -# Installed interpreter isolation - -> **Temporarily disabled.** OS sandboxing and the permission picker are -> disabled repo-wide: every process runs with full user permissions and new -> sessions default to full-access. Revert by flipping -> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), -> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), -> and the default-preset literals back to their asking defaults. - - -Release installations resolve Node and Python from the complete archive or -Devo's checksum-pinned runtime cache. They do not fall back to system -interpreters when a bundled component or cache reference is broken. - -Private Node commands ignore inherited `NODE_OPTIONS` and `NODE_PATH`, so a -preload or global module setting from another application cannot prevent the -TUI from starting. Private Python commands ignore inherited `PYTHON*` settings, -then apply Devo's module path, encoding, and user-site policy. The Windows -sandbox environment snapshot follows the same Python policy. Development -binaries without a runtime manifest retain their existing host environment. - -To check an installed release, launch its absolute executable path under a -real tmux/psmux TTY with system Node and Python excluded from `PATH`. Repeat -with a nonexistent `NODE_OPTIONS=--require=...` preload and invalid -`PYTHONHOME`, `PYTHONPATH`, and `PYTHONUSERBASE` values. The TUI should open, -Python cells should execute, and `sys.executable`, `sys.prefix`, and the -kernel dependency paths should point into the Devo-managed installation/cache. -Also run `devo doctor` under those settings; its runtime dependency probe must -remain healthy. These checks require real release binaries: the runtime CI -packaging fixture intentionally uses placeholder backend executables. diff --git a/docs/trusted-kernel-host-shell.md b/docs/trusted-kernel-host-shell.md new file mode 100644 index 00000000..d19be7f7 --- /dev/null +++ b/docs/trusted-kernel-host-shell.md @@ -0,0 +1,84 @@ +# Trusted Python kernel and host shell execution + +The product Python kernel is a trusted, user-privileged orchestration process, +not a sandbox for untrusted Python. Direct Python filesystem, network, native +extension, and process APIs retain user permissions. Plan mode does not make +arbitrary Python OS-read-only. Host mutation checks and credential environment +filtering remain enabled; neither confines arbitrary Python. + +Plan mode has no separate shell gate. Shell requests in Plan use the same +configured permission checker and sandbox profile as Build. An auto-allow does +not disable the configured sandbox. Approval-required requests still ask; a +user's denial or an existing policy denial is not converted to an allow. Plan's +no-mutation rule is model policy, not an OS read-only guarantee for Python or +shell. + +Shell requests use the host session capability, approval checks, and configured +sandbox profile. A request is not a grant. Explicit escalation uses host approval. +No configured profile means no OS shell sandbox. There is no global temporary +sandbox-disable switch; tool and terminal profiles are resolved directly from +the session configuration, independently of trusted kernel startup. The kernel +launcher has no OS sandbox or filesystem grant channel; this applies to low-level +callers as well as product startup. + +## Migration + +Default `bash()` uses host-owned execution. It returns a pending handle before +asynchronous authorization. `handle_id` is opaque; `pid` is unavailable. Synchronous +`running`, `poll`, `output`, and `tail` read cached snapshots. `kill()` schedules +host cancellation, not an arbitrary OS signal. Python environment mutations do +not propagate to host commands. `os.getcwd()` is captured when `bash()` is called. +Host background Bash has no default timeout; explicit `timeout_ms` is honored. +Existing foreground shell tools keep their 120-second default. + +The host retains at most 32 handles per session and 256 globally. Live streamed +output has a 256 KiB hard ceiling per handle. On completion, the executor-formatted +final result replaces the live buffer rather than being added to it. The default +executor truncation is `max_output_tokens` = 16,000 tokens, approximately 64,000 +bytes of ASCII text (plus a truncation notice), so a normal final `poll()`/`output()` +is about 64 KiB, not 256 KiB. A caller-supplied larger `max_output_tokens`, or a +result without a text payload, can retain more up to the 256 KiB ceiling. Do not +treat 256 KiB as a guarantee of exact retained bytes. Python caches terminal +results and releases the host record; otherwise finished records expire after 30 +minutes. These are new resource bounds, not the legacy local Bash buffer sizes. +Explicit local execution remains in `rlm.local_bash`; it is not an isolation +mechanism. + +Pending starts stay within the creating cell's authorization lifetime. Idle +requests may inspect/cancel/release existing session-owned handles but cannot +start commands. Host work is canceled when its originating turn is canceled or +its kernel shuts down, terminates, loses its protocol stream, or is dropped. +A kernel's cancellation does not cancel replacement-kernel commands. +Interrupted first awaits request host cancellation; awaiting an +already-released background handle only waits. Cancellation while approval is +pending does not itself dismiss the approval dialog. A raw kernel interrupt can +wait for the pending admission reply; cancellation of the originating host turn +must also cancel approval. A terminal host failure without an OS exit code raises +an error; cached output remains available for inspection. + +Known limitation: kernel death while an approval is pending stops owned shell +execution, but immediate cleanup of the pending approval UI/persistence is not +guaranteed. Ordinary turn cancellation uses the existing approval cleanup path. + +`rlm.shell(command, workdir=...)` is the existing one-shot host path. Relative +workdir is resolved against the host session workspace. Approval and execution +use the same resolved directory. Omission uses the workspace, not Python cwd. + +## Legacy Windows credential residue + +Earlier Windows builds could deliver inheritable allow ACEs for a random +per-session SID and record them in +`/.sandbox/credential_journal.json`. This change removes the only +code that read or wrote that journal and the only code that revoked those ACEs. +Earlier builds revoked grants on a normal session shutdown, so residue is +mainly expected after crashes or interrupted shutdowns. The residue is not +cleaned automatically: + +- Journal rows and their ACL entries can remain on disk after an upgrade. +- Deleting the journal file does not revoke the ACL entries. It only discards + the record of which paths were modified. +- This change introduces no automatic ACL mutation or cleanup pass. + +The SIDs are random values rather than account SIDs, but that does not make the +residue harmless: an ACE still grants access to any token that presents the +SID. Removing the residue requires an explicit migration or manual cleanup. diff --git a/docs/tui-context-usage.md b/docs/tui-context-usage.md deleted file mode 100644 index 1b67cee8..00000000 --- a/docs/tui-context-usage.md +++ /dev/null @@ -1,21 +0,0 @@ -# TUI context usage - -The footer uses Native occupancy's effective context window. It can differ -from the raw model catalog limit, and must not be replaced by a client default -when stats refresh, catalog enrichment or session resume completes. - -Before Native occupancy is available, the model window supplies the initial -zero-token footer. An explicit model switch updates that provisional window -immediately: an immediate stats read may still describe the previous model. -The next Native usage notification with a known window ends that provisional -state and supplies the authoritative effective window. - -Token-only notifications retain the last known window and recalculate the -percentage. The connection emits that resolved value to the UI as well as -storing it; unknown-window sentinel values must not replace known capacity. -New-session and resume transitions reset the provisional state appropriately. - -Regression coverage in native-agent-connection.test.ts checks authoritative -stats through catalog enrichment, model-switch behavior through fresh Native -usage, and partial usage notifications in both connection state and UI events. -Ctrl+O detail cycling and model selection commands keep their existing behavior. diff --git a/docs/usage.md b/docs/user-guide.md similarity index 88% rename from docs/usage.md rename to docs/user-guide.md index 4ccf5bfb..a87eb4a5 100644 --- a/docs/usage.md +++ b/docs/user-guide.md @@ -1,11 +1,11 @@ # Devo user guide -> **Temporarily disabled.** OS sandboxing and the permission picker are -> disabled repo-wide: every process runs with full user permissions and new -> sessions default to full-access. Revert by flipping -> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), -> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), -> and the default-preset literals back to their asking defaults. +> Shell and terminal execution follow the configured sandbox profile; there is +> no global sandbox-disable switch. Python kernels are trusted user-privileged +> processes, not OS-isolated environments. See +> [the execution boundary](trusted-kernel-host-shell.md). +> Permission-picker UI flags and new-session permission defaults are separate +> settings; removing the global switch does not change them. [Back to README](../README.md) @@ -120,6 +120,20 @@ Local catalog files can still be refreshed, and the bundled catalog is always av Model requests go to the endpoint you configure. For local model traffic, choose a compatible local endpoint. +### Errors during a conversation + +A saved API key does not guarantee available credits. If an OpenAI-compatible +Responses stream reports `error` or `response.failed`, Devo ends that turn as a +failure and displays the provider's error in the conversation, even when no +assistant text arrived. Text already received remains visible. + +Within these stream events, `insufficient_quota` and `credit_balance_exhausted` +are quota failures, not login failures. Devo does not automatically retry them +or invalidate the saved credential. Add credits or choose another configured +provider, then send a new message. The failed turn remains visible when the +session is resumed. Non-success HTTP responses retain their existing status-code +classification; this handling applies to failures inside an established SSE stream. + ## Installation ### Desktop app @@ -169,6 +183,12 @@ installation uses it; the app's `runtime/*.path` files reference it. Re-running the installer repairs missing runtimes. Old runtime packs remain available to running terminals and older installations; they are not automatically deleted. +If an install is interrupted while downloading a private runtime, a stale +runtime-cache lock can remain. Devo does not delete stale locks automatically, +so it cannot remove a lock held by another installer. If the installer times +out, confirm that no other installer is running, remove the lock directory +named in the timeout message, then rerun the installer. + Linux terminal archives use names such as `devo-tui-v0.2.0-x86_64-linux-musl.tar.gz` and `devo-tui-v0.2.0-aarch64-linux-musl.tar.gz`. Online app/runtime components use the same platform suffix. diff --git a/install.ps1 b/install.ps1 index 4db105dd..60182dfd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -400,6 +400,19 @@ function Receive-DevoAsset { } } +function Expand-DevoArchive { + param( + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)][string]$Destination + ) + if ($Archive -like "*.zip") { + [IO.Compression.ZipFile]::ExtractToDirectory($Archive, $Destination) + } else { + & "$env:SystemRoot\System32\tar.exe" -xzf $Archive -C $Destination + if ($LASTEXITCODE -ne 0) { throw "Failed to extract $Archive" } + } +} + function Get-CachedDevoRuntime { param([string]$Kind, [string]$Digest, [string]$Asset, [string]$BaseUrl) $cache = if ($env:DEVO_RUNTIME_CACHE) { $env:DEVO_RUNTIME_CACHE } else { Join-Path $env:LOCALAPPDATA "devo\runtimes" } @@ -427,12 +440,12 @@ function Get-CachedDevoRuntime { } } New-Item -ItemType Directory -Path $stage | Out-Null - $archive = Join-Path $stage "pack.tar.gz" + $archiveName = if ($Asset -like "*.zip") { "pack.zip" } else { "pack.tar.gz" } + $archive = Join-Path $stage $archiveName Write-Host "Downloading $Kind runtime ..." Receive-DevoAsset -Url "$BaseUrl/$Asset" -Destination $archive if ((Get-Sha256Hex $archive) -ine $Digest) { throw "Runtime SHA-256 verification failed: $Asset" } - & "$env:SystemRoot\System32\tar.exe" -xzf $archive -C $stage - if ($LASTEXITCODE -ne 0) { throw "Failed to extract $Asset" } + Expand-DevoArchive -Archive $archive -Destination $stage Remove-Item -LiteralPath $archive $extractedBinary = Join-Path $stage "runtime\$Kind\$Kind.exe" if (-not (Test-Path -LiteralPath $extractedBinary -PathType Leaf)) { throw "Incomplete $Kind runtime pack" } @@ -473,7 +486,7 @@ function Install-DevoOnline { for ($i = 1; $i -le 3; $i++) { $kind = @("app", "node", "python")[$i - 1] $parts = $lines[$i] -split ' ' - if ($parts.Count -ne 3 -or $parts[0] -ne $kind -or $parts[1] -cnotmatch '^[a-f0-9]{64}$' -or $parts[2] -cnotmatch '^[a-zA-Z0-9_.-]+\.tar\.gz$') { throw "Invalid $kind runtime index entry" } + if ($parts.Count -ne 3 -or $parts[0] -ne $kind -or $parts[1] -cnotmatch '^[a-f0-9]{64}$' -or $parts[2] -cnotmatch '^[a-zA-Z0-9_.-]+\.(tar\.gz|zip)$') { throw "Invalid $kind runtime index entry" } $packs[$kind] = @{ Digest = $parts[1]; Asset = $parts[2] } } # A version tag can be rebuilt; compare its verified app digest before @@ -491,15 +504,15 @@ function Install-DevoOnline { # Function definitions live only in this runspace; the worker # runspaces get them re-declared, including the PS 3.0-safe # SHA-256 helper used by Get-CachedDevoRuntime. - $workerScript = "param(`$Kind, `$Digest, `$Asset, `$BaseUrl)`n`$ErrorActionPreference = 'Stop'`nfunction Get-Sha256Hex { $((Get-Command Get-Sha256Hex).Definition) }`nfunction Receive-DevoAsset { $((Get-Command Receive-DevoAsset).Definition) }`nfunction Get-CachedDevoRuntime { $((Get-Command Get-CachedDevoRuntime).Definition) }`nGet-CachedDevoRuntime -Kind `$Kind -Digest `$Digest -Asset `$Asset -BaseUrl `$BaseUrl" + $workerScript = "param(`$Kind, `$Digest, `$Asset, `$BaseUrl)`n`$ErrorActionPreference = 'Stop'`nAdd-Type -AssemblyName System.IO.Compression.FileSystem`nfunction Get-Sha256Hex { $((Get-Command Get-Sha256Hex).Definition) }`nfunction Receive-DevoAsset { $((Get-Command Receive-DevoAsset).Definition) }`nfunction Expand-DevoArchive { $((Get-Command Expand-DevoArchive).Definition) }`nfunction Get-CachedDevoRuntime { $((Get-Command Get-CachedDevoRuntime).Definition) }`nGet-CachedDevoRuntime -Kind `$Kind -Digest `$Digest -Asset `$Asset -BaseUrl `$BaseUrl" [void]$shell.AddScript($workerScript).AddArgument($kind).AddArgument($packs[$kind].Digest).AddArgument($packs[$kind].Asset).AddArgument($runtimeBase) $workers += @{ Kind = $kind; Shell = $shell; Handle = $shell.BeginInvoke() } } - $appArchive = Join-Path $TempRoot "app.tar.gz" + $appArchiveName = if ($packs.app.Asset -like "*.zip") { "app.zip" } else { "app.tar.gz" } + $appArchive = Join-Path $TempRoot $appArchiveName Receive-DevoAsset -Url "$baseUrl/$($packs.app.Asset)" -Destination $appArchive if ((Get-Sha256Hex $appArchive) -ine $packs.app.Digest) { throw "App SHA-256 verification failed" } - & "$env:SystemRoot\System32\tar.exe" -xzf $appArchive -C $extracted - if ($LASTEXITCODE -ne 0) { throw "Failed to extract app archive" } + Expand-DevoArchive -Archive $appArchive -Destination $extracted } catch { $failure = $_ } finally { foreach ($worker in $workers) { try { diff --git a/install.sh b/install.sh index 5c6fd29e..5a4f9e58 100644 --- a/install.sh +++ b/install.sh @@ -549,21 +549,26 @@ cached_runtime() ( destination="$cache/$digest" lock="$cache/$digest.lock" attempts=0 - while ! mkdir "$lock" 2>/dev/null; do + # `mkdir` is not a portable lock here: uutils coreutils (Ubuntu 26.04) + # reports a concurrent EEXIST as success, which lets two installers share + # the lock. Noclobber creation is atomic and fails closed on existing + # regular lock files and legacy mkdir lock directories; special files + # differ. + while ! ( set -C; printf '%s\n' "$$" > "$lock" ) 2>/dev/null; do attempts=$((attempts + 1)) - [ "$attempts" -lt 300 ] || die "Timed out waiting for runtime cache lock: $lock" - if [ -f "$lock/pid" ]; then - owner="$(cat "$lock/pid")" - case "$owner" in ''|*[!0-9]*) ;; *) - if ! kill -0 "$owner" 2>/dev/null; then rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true; fi ;; - esac - fi + [ "$attempts" -lt 300 ] || die "Timed out waiting for runtime cache lock. If no installer is running, remove the stale lock manually: $lock" sleep 1 done stage="" - trap 'if [ -n "$stage" ]; then rm -rf "$stage"; fi; rm -f "$lock/pid"; rmdir "$lock"' EXIT + cleanup_runtime_lock() { + # A crash leaves the lock for explicit user cleanup. Only the process + # that wrote this pid token may release its own lock. + if [ -f "$lock" ] && [ "$(cat "$lock" 2>/dev/null || true)" = "$$" ]; then + rm -f "$lock" + fi + } + trap 'if [ -n "$stage" ]; then rm -rf "$stage"; fi; cleanup_runtime_lock' EXIT trap 'exit 1' INT TERM - printf '%s\n' "$$" > "$lock/pid" case "$kind" in node) executable="bin/node" ;; python) executable="bin/python3" ;; esac binary="$destination/runtime/$kind/$executable" if [ -f "$destination/.complete" ] && [ "$(cat "$destination/.complete")" = "$digest" ] && [ -x "$binary" ] && [ -f "$destination/.binary.sha256" ]; then diff --git a/scripts/runtime/installer-fixture.ts b/scripts/runtime/installer-fixture.ts index d171d47a..f7e5007d 100644 --- a/scripts/runtime/installer-fixture.ts +++ b/scripts/runtime/installer-fixture.ts @@ -10,7 +10,7 @@ export function writeChecksums(assets: string): void { } /** Run the actual network/install functions without modifying the user's PATH. */ -export async function installOnline(root: string, destination: string, cache: string, origin: string, target: string, version = "v0.2.0", mode: "functions" | "entrypoint" = "functions") { +export async function installOnline(root: string, destination: string, cache: string, origin: string, target: string, version = "v0.2.0", mode: "functions" | "entrypoint" = "functions", extraEnv: Record = {}) { const work = join(root, `work-${crypto.randomUUID()}`); mkdirSync(work); const windows = process.platform === "win32"; @@ -32,7 +32,7 @@ export async function installOnline(root: string, destination: string, cache: st ? [join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", path] : ["sh", path, "--no-modify-path"], { stdout: "pipe", stderr: "pipe", - env: { ...process.env, VERSION: version, DEVO_RUNTIME_CACHE: cache, DEVO_RELEASE_BASE_URL: origin, DEVO_RUNTIME_BASE_URL: `${origin}/runtime-packs`, DEVO_INSTALL_DIR: destination, DEVO_NO_MODIFY_PATH: "1", + env: { ...process.env, ...extraEnv, VERSION: version, DEVO_RUNTIME_CACHE: cache, DEVO_RELEASE_BASE_URL: origin, DEVO_RUNTIME_BASE_URL: `${origin}/runtime-packs`, DEVO_INSTALL_DIR: destination, DEVO_NO_MODIFY_PATH: "1", ...(windows ? { PSModulePath: join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/Modules") } : {}) }, }); const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); diff --git a/scripts/runtime/packs.test.ts b/scripts/runtime/packs.test.ts index 18a045e4..5707cc41 100644 --- a/scripts/runtime/packs.test.ts +++ b/scripts/runtime/packs.test.ts @@ -1,14 +1,19 @@ import { expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; -import { archiveTree, buildPacks } from "./packs"; +import { archiveTree, archiveTreeZip, buildPacks } from "./packs"; import { installOnline, writeChecksums } from "./installer-fixture"; import { WINDOWS_SANDBOX_HELPERS } from "./backend"; const target = `${process.arch === "arm64" ? "aarch64" : "x86_64"}-${process.platform === "win32" ? "pc-windows-msvc" : process.platform === "darwin" ? "apple-darwin" : "unknown-linux-musl"}`; const assetTarget = target.replace("-unknown-linux-", "-linux-"); +const WINDOWS_LONG_FILE_NAME = `${"long path ".repeat(10)}unicode-路径.txt`; +const WINDOWS_LONG_APP_FILE = `runtime/python-site/${WINDOWS_LONG_FILE_NAME}`; +const WINDOWS_HIDDEN_APP_FILE = "runtime/python-site/.hidden-unicode-payload"; +const WINDOWS_EMPTY_APP_DIR = "runtime/python-site/empty-app-dir"; function fixture(root: string) { const source = join(root, "source"); @@ -19,10 +24,84 @@ function fixture(root: string) { mkdirSync(dirname(join(source, file)), { recursive: true }); writeFileSync(join(source, file), "original " + file, { mode: 0o755 }); } + if (windows) { + writeFileSync(join(source, WINDOWS_LONG_APP_FILE), "unicode payload"); + writeFileSync(join(source, WINDOWS_HIDDEN_APP_FILE), "hidden payload"); + mkdirSync(join(source, WINDOWS_EMPTY_APP_DIR)); + } writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.0", target })); return source; } +test.skipIf(process.platform === "win32")("Windows ZIP packs require a Windows build host", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows pack host ")); + try { + const source = fixture(root); + writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.0", target: "x86_64-pc-windows-msvc" })); + await expect(buildPacks(source, join(root, "assets"))).rejects.toThrow("Windows build host"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test.skipIf(process.platform !== "win32")("Windows ZIP rejects reparse points", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows reparse ")); + try { + const source = fixture(root); + try { + symlinkSync(join(source, "runtime", "python"), join(source, "runtime", "python", "linked"), "junction"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") { + console.warn(`Skipping Windows reparse test: junction creation unavailable (${code})`); + return; + } + throw error; + } + await expect(archiveTreeZip(source, ["runtime/python"], join(root, "out.zip"))).rejects.toThrow("reparse point"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test.skipIf(process.platform !== "win32")("Windows ZIP pack handles zero, one and multiple paths", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows zip path counts ")); + try { + const source = fixture(root); + await expect(archiveTreeZip(source, [], join(root, "zero.zip"))).rejects.toThrow("No pack paths"); + await archiveTreeZip(source, ["runtime/python"], join(root, "one.zip")); + expect(existsSync(join(root, "one.zip"))).toBe(true); + await archiveTreeZip(source, ["runtime/python", "runtime/node"], join(root, "many.zip")); + expect(existsSync(join(root, "many.zip"))).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test.skipIf(process.platform !== "win32")("Windows ZIP default reader preserves UTF8 names", async () => { + const root = mkdtempSync(join(tmpdir(), "devo Windows zip utf8 ")); + try { + const source = join(root, "source"); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, WINDOWS_LONG_FILE_NAME), "unicode payload"); + const manifest = join(root, "paths.json"); + writeFileSync(manifest, JSON.stringify([WINDOWS_LONG_FILE_NAME])); + const zip = join(root, "utf8.zip"); + const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); + const packed = Bun.spawnSync([ + powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", join(import.meta.dir, "zip-pack.ps1"), "-Root", source, "-Output", zip, "-Manifest", manifest, + ], { stdout: "pipe", stderr: "pipe" }); + expect(packed.exitCode).toBe(0); + const listedFile = join(root, "listed.txt"); + const listed = Bun.spawnSync([ + powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", join(import.meta.dir, "zip-list.ps1"), "-Archive", zip, "-OutputFile", listedFile, + ], { stdout: "pipe", stderr: "pipe" }); + expect(listed.exitCode).toBe(0); + const bytes = readFileSync(zip); + expect(bytes.readUInt32LE(0)).toBe(0x04034b50); + expect(bytes.readUInt16LE(6) & 0x0800).toBe(0x0800); + // ZipFile.OpenRead uses the default reader; without bit 11 this name would + // decode as ANSI mojibake instead of the expected Unicode path. + expect(readFileSync(listedFile, "utf8").split(/\r?\n/)).toContain(WINDOWS_LONG_FILE_NAME); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 30_000); + test("Linux public pack names omit the vendor while retaining canonical manifest targets", async () => { const root = mkdtempSync(join(tmpdir(), "devo Linux asset names ")); try { @@ -42,7 +121,7 @@ test("Linux public pack names omit the vendor while retaining canonical manifest } finally { rmSync(root, { recursive: true, force: true }); } }); -test("runtime tar archives are reproducible and preserve long Unicode paths, executable modes and symlinks", async () => { +test.skipIf(process.platform === "win32")("runtime tar archives are reproducible and preserve long Unicode paths, executable modes and symlinks", async () => { const root = mkdtempSync(join(tmpdir(), "devo tar QA ")); try { const source = fixture(root); @@ -58,8 +137,26 @@ test("runtime tar archives are reproducible and preserve long Unicode paths, exe const extracted = join(root, "extracted"); mkdirSync(extracted); const tar = process.platform === "win32" ? join(process.env.SystemRoot!, "System32/tar.exe") : "tar"; + // DIAGNOSTIC: gather Windows native tar evidence without hiding exit/stderr. + const version = Bun.spawnSync([tar, "--version"], { stdout: "pipe", stderr: "pipe" }); + console.log("DIAG tar --version", { exit: version.exitCode, stdout: version.stdout.toString(), stderr: version.stderr.toString() }); + const listed = Bun.spawnSync([tar, "-tzf", first], { stdout: "pipe", stderr: "pipe" }); + console.log("DIAG tar -tzf", { exit: listed.exitCode, stdout: listed.stdout.toString(), stderr: listed.stderr.toString() }); const result = Bun.spawnSync([tar, "-xzf", first, "-C", extracted], { stdout: "pipe", stderr: "pipe" }); - expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath: readFileSync(join(extracted, long), "utf8") }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); + let extractedEntries: unknown; + try { + extractedEntries = readdirSync(extracted, { recursive: true }); + } catch (error) { + extractedEntries = String(error); + } + let longPath: string | null = null; + try { + longPath = readFileSync(join(extracted, long), "utf8"); + } catch (error) { + console.log("DIAG longPath error", String(error)); + } + console.log("DIAG tar extract", { exit: result.exitCode, stderr: result.stderr.toString(), extractedEntries }); + expect({ exit: result.exitCode, stderr: result.stderr.toString(), longPath }).toEqual({ exit: 0, stderr: "", longPath: "unicode payload" }); if (process.platform !== "win32") expect(readFileSync(join(extracted, "runtime/python/python-link"), "utf8")).toBe("original runtime/python/bin/python3"); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -68,12 +165,14 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const root = mkdtempSync(join(tmpdir(), "devo shared installer QA ")); const assets = join(root, "assets"); const requests: string[] = []; + const requestLog: Array<{ at: number; name: string }> = []; const runtimeUrls: string[] = []; let corrupt = ""; const missing = new Set(); const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { const name = basename(new URL(request.url).pathname); requests.push(name); + requestLog.push({ at: Date.now(), name }); if (name.startsWith("devo-runtime-")) runtimeUrls.push(new URL(request.url).pathname); if (missing.has(name)) return new Response("Not found", { status: 404 }); if (name === corrupt) return new Response("corrupt bytes"); @@ -85,6 +184,20 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr const index = await buildPacks(source, assets); const first = readFileSync(index, "utf8").trim().split("\n").slice(1).map(line => line.split(" ")); expect(first.map(parts => parts[0])).toEqual(["app", "node", "python"]); + if (process.platform === "win32") { + const repeat = join(root, "repeat assets"); + const repeatIndex = await buildPacks(source, repeat); + expect(readFileSync(repeatIndex, "utf8")).toEqual(readFileSync(index, "utf8")); + const powershell = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); + const listedFile = join(root, "zip-list.txt"); + const listed = Bun.spawnSync([ + powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", join(import.meta.dir, "zip-list.ps1"), "-Archive", join(repeat, first[0][2]), + "-OutputFile", listedFile, + ], { stdout: "pipe", stderr: "pipe" }); + expect(listed.exitCode).toBe(0); + expect(readFileSync(listedFile, "utf8").split(/\r?\n/)).toContain(WINDOWS_LONG_APP_FILE); + } writeFileSync(join(source, "runtime/manifest.json"), JSON.stringify({ schema: 1, version: "0.2.1", target })); await buildPacks(source, assets); const second = readFileSync(join(assets, `devo-tui-v0.2.1-${assetTarget}.install.txt`), "utf8").trim().split("\n").slice(1).map(line => line.split(" ")); @@ -98,7 +211,7 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); expect(requests.filter(name => name.startsWith("devo-runtime-")).sort()).toEqual(first.slice(1).map(parts => parts[2]).sort()); expect(runtimeUrls.every(path => path.startsWith("/runtime-packs/"))).toBe(true); - expect({ localNode: existsSync(join(destination, "runtime/node")), localPython: existsSync(join(destination, "runtime/python")), nodeReference: existsSync(join(destination, "runtime/node.path")), appModule: existsSync(join(destination, "runtime/python-site/rlm/repl.py")) }).toEqual({ localNode: false, localPython: false, nodeReference: true, appModule: true }); + expect({ localNode: existsSync(join(destination, "runtime/node")), localPython: existsSync(join(destination, "runtime/python")), nodeReference: existsSync(join(destination, "runtime/node.path")), appModule: existsSync(join(destination, "runtime/python-site/rlm/repl.py")), longUnicode: process.platform === "win32" ? readFileSync(join(destination, WINDOWS_LONG_APP_FILE), "utf8") : "unicode payload", hidden: process.platform === "win32" ? readFileSync(join(destination, WINDOWS_HIDDEN_APP_FILE), "utf8") : "hidden payload", emptyDir: process.platform === "win32" ? statSync(join(destination, WINDOWS_EMPTY_APP_DIR)).isDirectory() && readdirSync(join(destination, WINDOWS_EMPTY_APP_DIR)).length === 0 : true }).toEqual({ localNode: false, localPython: false, nodeReference: true, appModule: true, longUnicode: "unicode payload", hidden: "hidden payload", emptyDir: true }); requests.length = 0; result = await installOnline(root, destination, cache, origin, target, "v0.2.1"); if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); @@ -129,10 +242,59 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr expect({ failed: corruptIndex.exit !== 0, version: JSON.parse(readFileSync(join(destination, "runtime/manifest.json"), "utf8")).version }).toEqual({ failed: true, version: "0.2.1" }); corrupt = ""; requests.length = 0; + if (process.platform !== "win32") { + // A stale lock must fail closed: no automatic deletion. A controlled + // `sleep` on PATH makes the 300-attempt wait deterministic without a + // production timeout knob. Exercise the legacy mkdir directory and the + // current lock file; neither may be reclaimed automatically. + const fakeSleepDir = join(root, "fake sleep bin"); + mkdirSync(fakeSleepDir); + writeFileSync(join(fakeSleepDir, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + for (const legacyDirectory of [true, false]) { + const label = legacyDirectory ? "legacy directory" : "file"; + const staleCache = join(root, `stale lock ${label} cache`); + mkdirSync(staleCache); + const staleLock = join(staleCache, `${first[1][1]}.lock`); + if (legacyDirectory) { + mkdirSync(staleLock); + writeFileSync(join(staleLock, "pid"), "999999\n"); + } else { + writeFileSync(staleLock, "999999\n"); + } + const blocked = await installOnline(root, join(root, `stale lock ${label} install`), staleCache, origin, target, "v0.2.0", "functions", { + PATH: `${fakeSleepDir}:${process.env.PATH ?? ""}`, + }); + expect({ + failed: blocked.exit !== 0, + lockPreserved: existsSync(staleLock), + pidPreserved: readFileSync(legacyDirectory ? join(staleLock, "pid") : staleLock, "utf8"), + manualCleanupMessage: blocked.stderr.includes("remove the stale lock manually"), + }).toEqual({ failed: true, lockPreserved: true, pidPreserved: "999999\n", manualCleanupMessage: true }); + rmSync(staleLock, { recursive: true, force: true }); + const retry = await installOnline(root, join(root, `stale lock ${label} retry install`), staleCache, origin, target); + if (retry.exit !== 0) throw new Error(`${retry.stdout}\n${retry.stderr}`); + expect(existsSync(staleLock)).toBe(false); + requests.length = 0; + } + // Old-version installers use mkdir for the same lock pathname. A new + // file lock must make that legacy acquisition fail closed too. + const legacyProbe = join(root, "legacy mkdir probe.lock"); + writeFileSync(legacyProbe, "999999\n"); + const legacyMkdir = Bun.spawnSync(["/bin/mkdir", legacyProbe]); + expect(legacyMkdir.exitCode).not.toBe(0); + rmSync(legacyProbe, { force: true }); + } + + requestLog.length = 0; const concurrentCache = join(root, "concurrent cache"); const results = await Promise.all(["one", "two"].map(name => installOnline(root, join(root, name), concurrentCache, origin, target))); for (const item of results) if (item.exit !== 0) throw new Error(`${item.stdout}\n${item.stderr}`); - expect(requests.filter(name => name.startsWith("devo-runtime-")).sort()).toEqual(first.slice(1).map(parts => parts[2]).sort()); + const concurrentRuntimeRequests = requests.filter(name => name.startsWith("devo-runtime-")).sort(); + if (JSON.stringify(concurrentRuntimeRequests) !== JSON.stringify(first.slice(1).map(parts => parts[2]).sort())) { + console.log("CONCURRENT_REQUEST_LOG", JSON.stringify(requestLog)); + console.log("CONCURRENT_CACHE", JSON.stringify(readdirSync(concurrentCache, { recursive: true }))); + } + expect(concurrentRuntimeRequests).toEqual(first.slice(1).map(parts => parts[2]).sort()); expect(readdirSync(concurrentCache).some(name => name.startsWith(".stage"))).toBe(false); const entrypoint = await installOnline(root, join(root, "entrypoint install"), cache, origin, target, "v0.2.0", "entrypoint"); if (entrypoint.exit !== 0) throw new Error(`${entrypoint.stdout}\n${entrypoint.stderr}`); @@ -173,3 +335,34 @@ test("real installers reuse runtimes across upgrades, repair damage, reject corr } } finally { server.stop(true); rmSync(root, { recursive: true, force: true }); } }, 120_000); + +test.skipIf(process.platform !== "win32")("legacy ASCII tar runtime index still installs on Windows", async () => { + const root = mkdtempSync(join(tmpdir(), "devo legacy tar index ")); + let server: ReturnType | undefined; + try { + const source = fixture(root); + rmSync(join(source, WINDOWS_LONG_APP_FILE), { force: true }); + const assets = join(root, "legacy assets"); + mkdirSync(assets); + const appPaths = readdirSync(source).filter(name => name !== "runtime") + .concat(readdirSync(join(source, "runtime")).filter(name => !["node", "python", "node.path", "python.path"].includes(name)).map(name => `runtime/${name}`)); + const appName = `devo-tui-app-v0.2.0-${assetTarget}.tar.gz`; + const nodeName = `devo-runtime-node-${assetTarget}.tar.gz`; + const pythonName = `devo-runtime-python-${assetTarget}.tar.gz`; + await archiveTree(source, appPaths, join(assets, appName)); + await archiveTree(source, ["runtime/node"], join(assets, nodeName)); + await archiveTree(source, ["runtime/python"], join(assets, pythonName)); + const digest = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const index = join(assets, `devo-tui-v0.2.0-${assetTarget}.install.txt`); + writeFileSync(index, ["devo-install-v1", `app ${digest(join(assets, appName))} ${appName}`, `node ${digest(join(assets, nodeName))} ${nodeName}`, `python ${digest(join(assets, pythonName))} ${pythonName}`].join("\n") + "\n"); + writeChecksums(assets); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const name = basename(new URL(request.url).pathname); + const file = Bun.file(join(assets, name)); + return await file.exists() ? new Response(file) : new Response("Not found", { status: 404 }); + } }); + const result = await installOnline(root, join(root, "legacy installed"), join(root, "legacy cache"), `http://127.0.0.1:${server.port}`, target); + if (result.exit !== 0) throw new Error(`${result.stdout}\n${result.stderr}`); + expect(existsSync(join(root, "legacy installed", "runtime/manifest.json"))).toBe(true); + } finally { server?.stop(true); rmSync(root, { recursive: true, force: true }); } +}, 30_000); diff --git a/scripts/runtime/packs.ts b/scripts/runtime/packs.ts index 12dc06ce..72d1df88 100644 --- a/scripts/runtime/packs.ts +++ b/scripts/runtime/packs.ts @@ -63,10 +63,50 @@ export async function archiveTree(root: string, paths: string[], output: string) await pipeline(Readable.from(archive()), createGzip({ level: 9 }), createWriteStream(output)); } +/** + * Create a deterministic Windows runtime pack through the maintained .NET + * ZipArchive writer. Windows packs are built only on Windows hosts; a + * cross-target Unix build fails explicitly instead of silently depending on + * PowerShell. + */ +export async function archiveTreeZip(root: string, paths: string[], output: string): Promise { + if (process.platform !== "win32") { + throw new Error("Windows ZIP runtime packs require a Windows build host with PowerShell/.NET"); + } + const powershell = join(process.env.SystemRoot ?? "C:/Windows", "System32/WindowsPowerShell/v1.0/powershell.exe"); + const helper = join(import.meta.dir, "zip-pack.ps1"); + const manifest = `${output}.paths.json`; + writeFileSync(manifest, JSON.stringify(paths)); + try { + const result = Bun.spawnSync([ + powershell, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + helper, + "-Root", + root, + "-Output", + output, + "-Manifest", + manifest, + ], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(`Windows ZIP pack failed (${result.exitCode}): ${result.stderr.toString()}`); + } + } finally { + rmSync(manifest, { force: true }); + } +} + /** Emit the small online app and independently cacheable interpreter packs. */ export async function buildPacks(bundle: string, output: string): Promise { const manifest = JSON.parse(readFileSync(join(bundle, "runtime/manifest.json"), "utf8")); const assetTarget = manifest.target.replace("-unknown-linux-", "-linux-"); + const windows = manifest.target.includes("-pc-windows-msvc"); + const extension = windows ? ".zip" : ".tar.gz"; mkdirSync(output, { recursive: true }); const lines = ["devo-install-v1"]; for (const kind of ["app", "node", "python"] as const) { @@ -76,12 +116,16 @@ export async function buildPacks(bundle: string, output: string): Promise name !== "runtime") .concat(readdirSync(join(bundle, "runtime")).filter(name => !["node", "python", "node.path", "python.path"].includes(name)).map(name => `runtime/${name}`)) : [`runtime/${kind}`]; - await archiveTree(bundle, paths, partial); + if (windows) { + await archiveTreeZip(bundle, paths, partial); + } else { + await archiveTree(bundle, paths, partial); + } const hash = createHash("sha256"); for await (const chunk of createReadStream(partial)) hash.update(chunk); const digest = hash.digest("hex"); - const name = kind === "app" ? `devo-tui-app-v${manifest.version}-${assetTarget}.tar.gz` - : `devo-runtime-${kind}-${assetTarget}-${digest}.tar.gz`; + const name = kind === "app" ? `devo-tui-app-v${manifest.version}-${assetTarget}${extension}` + : `devo-runtime-${kind}-${assetTarget}-${digest}${extension}`; const destination = join(output, name); // Overwriting the same runtime digest is harmless; app archives are versioned. rmSync(destination, { force: true }); diff --git a/scripts/runtime/zip-list.ps1 b/scripts/runtime/zip-list.ps1 new file mode 100644 index 00000000..38ccad18 --- /dev/null +++ b/scripts/runtime/zip-list.ps1 @@ -0,0 +1,15 @@ +param( + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)][string]$OutputFile +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [IO.Compression.ZipFile]::OpenRead($Archive) +try { + $names = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $zip.Entries) { $names.Add($entry.FullName) } + [IO.File]::WriteAllLines($OutputFile, $names, [System.Text.UTF8Encoding]::new($false)) +} finally { + $zip.Dispose() +} diff --git a/scripts/runtime/zip-pack.ps1 b/scripts/runtime/zip-pack.ps1 new file mode 100644 index 00000000..b4461067 --- /dev/null +++ b/scripts/runtime/zip-pack.ps1 @@ -0,0 +1,97 @@ +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Output, + [Parameter(Mandatory = $true)][string]$Manifest +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$rootFull = [IO.Path]::GetFullPath($Root) +$manifestText = [IO.File]::ReadAllText($Manifest, [Text.Encoding]::UTF8) +$parsed = ConvertFrom-Json -InputObject $manifestText +$paths = @() +if ($null -eq $parsed) { + # Windows PowerShell 5.1 may unwrap an empty JSON array to $null. +} elseif ($parsed -is [System.Array]) { + foreach ($item in $parsed) { + if ($item -isnot [string]) { throw "Pack paths must be a flat JSON array of strings" } + $paths += $item + } +} elseif ($parsed -is [string]) { + # Windows PowerShell 5.1 unwraps a one-element JSON array to a scalar. + $paths = @($parsed) +} else { + throw "Pack paths must be a flat JSON array of strings" +} +if ($paths.Count -eq 0) { throw "No pack paths were provided" } + +$entries = [System.Collections.Generic.List[object]]::new() +$seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + +function Add-ZipEntry { + param([string]$FullPath, [string]$Relative, [bool]$Directory) + $name = $Relative.Replace('\', '/') + while ($name.StartsWith("./")) { $name = $name.Substring(2) } + if ($name -eq "" -or $name.StartsWith("/") -or ($name.Split('/') -contains "..")) { + throw "Invalid runtime pack path: $Relative" + } + if ($Directory) { $name = $name.TrimEnd('/') + "/" } + if (-not $seen.Add($name)) { throw "Duplicate runtime pack entry: $name" } + $entries.Add([pscustomobject]@{ FullPath = $FullPath; Name = $name; Directory = $Directory }) +} + +function Add-Tree { + param([string]$FullPath, [string]$Relative) + $item = Get-Item -LiteralPath $FullPath -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Unsupported reparse point in Windows runtime pack: $Relative" + } + if ($item.PSIsContainer) { + Add-ZipEntry -FullPath $item.FullName -Relative $Relative -Directory $true + foreach ($child in @(Get-ChildItem -LiteralPath $item.FullName -Force | Sort-Object Name)) { + Add-Tree -FullPath $child.FullName -Relative ($Relative.TrimEnd('/') + "/" + $child.Name) + } + } else { + Add-ZipEntry -FullPath $item.FullName -Relative $Relative -Directory $false + } +} + +foreach ($path in $paths) { + Add-Tree -FullPath (Join-Path $rootFull $path) -Relative $path +} + +$byName = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) +foreach ($entry in $entries) { $byName[$entry.Name] = $entry } +$names = [string[]]@($byName.Keys) +[Array]::Sort($names, [StringComparer]::Ordinal) +$sorted = foreach ($name in $names) { $byName[$name] } + +$stream = [IO.File]::Open($Output, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + $utf8 = [System.Text.Encoding]::UTF8 + $archive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList @($stream, [IO.Compression.ZipArchiveMode]::Create, $true, $utf8) + try { + # Fixed DOS timestamp keeps repeated Windows pack builds byte-identical + # within the supported .NET toolchain. + $fixed = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + foreach ($entry in $sorted) { + # ZipArchive writes UTF-8 entry names (and the UTF-8 flag) when a + # name contains non-ASCII characters. + $zipEntry = $archive.CreateEntry($entry.Name, [IO.Compression.CompressionLevel]::Optimal) + $zipEntry.LastWriteTime = $fixed + if (-not $entry.Directory) { + $input = [IO.File]::OpenRead($entry.FullPath) + try { + $outputStream = $zipEntry.Open() + try { $input.CopyTo($outputStream) } finally { $outputStream.Dispose() } + } finally { $input.Dispose() } + } + } + } finally { + $archive.Dispose() + } +} finally { + $stream.Dispose() +}