From 7c324f56a18e695ed31407428fa7081573f84e98 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:25:39 +0000 Subject: [PATCH 01/11] fix(tui): guard openModelPicker crash after /login model selection resolvePrimeInferencePostLoginModelAction is a debrand stub that returns undefined; the caller dereferenced action.openModelPicker unconditionally and crashed the post-login jump. Resolve the action first and treat a missing openModelPicker as a no-op. Co-Authored-By: Claude Code --- .../src/modes/interactive/interactive-mode.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts b/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts index 78d4b56d..85683a74 100644 --- a/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts @@ -8900,18 +8900,20 @@ export class InteractiveMode { const selectedModel = currentModel?.provider === "unknown" && currentModel.id === "unknown" ? undefined : currentModel; let action = resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, this.modelRegistry); - if (!action.openModelPicker) { + // Devo's debrand stub returns undefined (no hosted-inference auto-select). + if (!action?.openModelPicker) { return false; } if (!selectedModel) { try { const availableModels = await this.getConnectionAvailableModels(); - action = resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, { - find: (provider, modelId) => - availableModels.find((model) => model.provider === provider && model.id === modelId) ?? - this.modelRegistry.find(provider, modelId), - }); + action = + resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, { + find: (provider, modelId) => + availableModels.find((model) => model.provider === provider && model.id === modelId) ?? + this.modelRegistry.find(provider, modelId), + }) ?? action; } catch { // Preserve the registry fallback so selection can still report a specific failure below. } From 8fab0f42a7883803791b119538f135e16df51d63 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:25:43 +0000 Subject: [PATCH 02/11] fix(kernel): resolve bare python via PATH before spawning kernel Source builds pass a bare interpreter name ("python"), which fails with ENOENT on hosts without a python alias. fallback_interpreter now scans PATH for python/python3 on Unix (Windows keeps "python"), the kernel spawn reports the interpreter path on ENOENT, and doctor probes with the same fallback. Co-Authored-By: Claude Code --- crates/cli/src/doctor_command.rs | 9 +++-- crates/kernel/src/session.rs | 20 ++++++++-- crates/utils/paths/src/runtime.rs | 66 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/doctor_command.rs b/crates/cli/src/doctor_command.rs index ea2c3867..236b8c62 100644 --- a/crates/cli/src/doctor_command.rs +++ b/crates/cli/src/doctor_command.rs @@ -25,9 +25,12 @@ pub(crate) async fn run_doctor() -> Result<()> { let node = bundle .as_ref() .map_or_else(|| "node".into(), |runtime| runtime.node.clone()); - let python = bundle - .as_ref() - .map_or_else(|| "python".into(), |runtime| runtime.python.clone()); + let python = bundle.as_ref().map_or_else( + || { + devo_util_paths::runtime::fallback_interpreter(std::env::var_os("PATH").as_deref()) + }, + |runtime| runtime.python.clone(), + ); use devo_util_paths::runtime_environment::{Interpreter, remove_interpreter_environment}; for (program, interpreter) in [(&node, Interpreter::Node), (&python, Interpreter::Python)] { let mut command = Command::new(program); diff --git a/crates/kernel/src/session.rs b/crates/kernel/src/session.rs index a2359d67..d42089fc 100644 --- a/crates/kernel/src/session.rs +++ b/crates/kernel/src/session.rs @@ -99,9 +99,10 @@ impl Default for KernelSessionConfig { fn default() -> Self { let bundle = devo_util_paths::runtime::RuntimeBundle::current(); Self { - python: bundle - .as_ref() - .map_or_else(|| PathBuf::from("python"), |runtime| runtime.python.clone()), + python: bundle.as_ref().map_or_else( + || devo_util_paths::runtime::fallback_interpreter(std::env::var_os("PATH").as_deref()), + |runtime| runtime.python.clone(), + ), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), python_path_entries: bundle .as_ref() @@ -121,6 +122,12 @@ pub enum SpawnError { Repl(#[from] ReplError), #[error("failed to spawn kernel: {0}")] Spawn(#[source] std::io::Error), + #[error("failed to spawn kernel interpreter `{python}`: {source}")] + SpawnInterpreter { + python: PathBuf, + #[source] + source: std::io::Error, + }, } /// Collected output from one kernel request that waits for `done`. @@ -649,7 +656,12 @@ impl KernelSession { cmd.env(key, value); } - let mut child = cmd.spawn().map_err(SpawnError::Spawn)?; + let mut child = cmd + .spawn() + .map_err(|source| SpawnError::SpawnInterpreter { + python: config.python.clone(), + source, + })?; let stdin = child .stdin .take() diff --git a/crates/utils/paths/src/runtime.rs b/crates/utils/paths/src/runtime.rs index 868d32e8..63de4475 100644 --- a/crates/utils/paths/src/runtime.rs +++ b/crates/utils/paths/src/runtime.rs @@ -67,6 +67,33 @@ fn runtime_root(root: &Path, kind: &str) -> PathBuf { root.join(format!("runtime/invalid-{kind}-reference")) } +/// Interpreter used when no runtime bundle is installed (source builds). +/// +/// Prefers a name that already exists on `PATH`: the historical `python` +/// first, then `python3` — stock macOS ships no unversioned `python`, only +/// `python3` from the Command Line Tools, so a bare `Command::new("python")` +/// fails with ENOENT there. Windows keeps the bare `python` name: its +/// `python3.exe` may be a Microsoft Store alias stub that opens the Store +/// instead of running Python, so probing for it would be misleading. +pub fn fallback_interpreter(path_var: Option<&std::ffi::OsStr>) -> PathBuf { + #[cfg(windows)] + { + let _ = path_var; + PathBuf::from("python") + } + #[cfg(not(windows))] + { + for name in ["python", "python3"] { + if let Some(paths) = path_var + && std::env::split_paths(paths).any(|dir| dir.join(name).is_file()) + { + return PathBuf::from(name); + } + } + PathBuf::from("python") + } +} + #[cfg(test)] mod tests { use super::*; @@ -81,6 +108,45 @@ mod tests { ); } + #[test] + fn fallback_interpreter_prefers_existing_names_on_path() { + let with_python = tempfile::tempdir().unwrap(); + std::fs::write(with_python.path().join("python"), "").unwrap(); + let with_python3 = tempfile::tempdir().unwrap(); + std::fs::write(with_python3.path().join("python3"), "").unwrap(); + + let join_paths = |dir: &std::path::Path| { + std::env::join_paths([dir, std::path::Path::new("/nonexistent")]).unwrap() + }; + + if cfg!(windows) { + // Windows never probes PATH: the python3.exe Store alias stub + // would satisfy an existence check without running Python. + assert_eq!( + fallback_interpreter(Some(join_paths(with_python.path()).as_os_str())), + PathBuf::from("python") + ); + } else { + // Historical name wins when both exist (no behavior change on Linux). + assert_eq!( + fallback_interpreter(Some(join_paths(with_python.path()).as_os_str())), + PathBuf::from("python") + ); + // macOS shape: only python3 exists. + assert_eq!( + fallback_interpreter(Some(join_paths(with_python3.path()).as_os_str())), + PathBuf::from("python3") + ); + } + // Nothing found (or no PATH at all): keep the historical bare name so + // the spawn error stays recognizable. + assert_eq!( + fallback_interpreter(Some(join_paths(std::path::Path::new("/nonexistent")).as_os_str())), + PathBuf::from("python") + ); + assert_eq!(fallback_interpreter(None), PathBuf::from("python")); + } + #[test] fn bundle_is_relocatable_and_broken_components_do_not_fall_back() { let temp = tempfile::tempdir().unwrap(); From 0312daba0ac31748061e5d3bcf276ea04cab3fc7 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:25:50 +0000 Subject: [PATCH 03/11] ci(tui): add mock-provider smoke and three-wire live LLM jobs The secret-free tmux job completes a real turn (T2) against a local mock provider (smoke-mock-provider.mjs, openai/anthropic/responses wires) so fork PRs get turn coverage. The repo-owned tmux-live job seeds one home per wire API: DeepSeek chat completions, DeepSeek anthropic-messages (same key), and optional OpenAI responses, plus the T3 interrupt scenario, on ubuntu and macOS. Cleanup retries are best-effort so an ENOTEMPTY race never fails a passing run. Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 141 +++++++++++++++++++++++ apps/tui/scripts/smoke-mock-provider.mjs | 84 ++++++++++++++ apps/tui/scripts/smoke-runner.js | 13 ++- docs/development-checks.md | 43 +++++++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 apps/tui/scripts/smoke-mock-provider.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe563baa..a69758f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main, master] pull_request: branches: [main, master] + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -150,6 +151,146 @@ jobs: - run: npm test - run: node --import tsx --test lib/coding-agent/src/modes/interactive/interactive-mode-model-candidates.test.ts lib/coding-agent/src/modes/interactive/interactive-mode-session-ancillary.test.ts + tmux-secrets: + name: Detect live TUI provider secrets + runs-on: ubuntu-latest + outputs: + live: ${{ steps.detect.outputs.live }} + steps: + # Job-level `if` cannot read the secrets context; fork PRs see empty + # secrets, so this doubles as the fork gate for the live job. + - id: detect + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + run: | + if [ -n "$DEEPSEEK_API_KEY" ]; then + echo "live=true" >> "$GITHUB_OUTPUT" + else + echo "live=false" >> "$GITHUB_OUTPUT" + fi + + tmux: + needs: changes + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tui == 'true' + name: TUI tmux smoke (mock provider) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: apps/tui/package-lock.json + - name: Ensure tmux + run: command -v tmux || (sudo apt-get update && sudo apt-get install -y tmux) + - name: Install TUI dependencies + working-directory: apps/tui + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Build CLI + run: cargo build -p devo-cli --bin devo --locked + - name: Boot and outcome scenarios (no provider credentials) + working-directory: apps/tui + run: | + for scenario in T1 goal heartbeat; do + echo "::group::tmux smoke: $scenario" + node scripts/run-tmux-smoke.js "$scenario" + echo "::endgroup::" + done + - name: Completed-turn scenario against the local mock provider + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "120000" + run: | + home="$(mktemp -d)" + node scripts/smoke-mock-provider.mjs 8801 & + mock_pid=$! + trap 'kill "$mock_pid" 2>/dev/null || true' EXIT + printf '%s\n' \ + '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "http://127.0.0.1:8801"}}}' \ + > "$home/providers.json" + printf '%s\n' '{"deepseek": {"type": "api_key", "key": "ci-mock-key"}}' > "$home/auth.json" + chmod 600 "$home/auth.json" + DEVO_HOME="$home" node scripts/run-tmux-smoke.js T2 + + tmux-live: + needs: [changes, tmux-secrets] + if: >- + needs.tmux-secrets.outputs.live == 'true' && + (needs.changes.outputs.rust == 'true' || needs.changes.outputs.tui == 'true') + name: TUI tmux live LLM (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # macOS covers the seatbelt fence and python3 fallback paths that + # Linux cannot reach with the same code. + os: [ubuntu-latest, macos-latest] + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: apps/tui/package-lock.json + - name: Ensure tmux + run: command -v tmux || (if [ "$(uname)" = Darwin ]; then brew install tmux; else sudo apt-get update && sudo apt-get install -y tmux; fi) + - name: Install TUI dependencies + working-directory: apps/tui + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Build CLI + run: cargo build -p devo-cli --bin devo --locked + - name: Seed live Devo homes (one per wire API) + run: | + # chat-completions: builtin DeepSeek endpoint + chat="$(mktemp -d)/home"; mkdir -p "$chat" + printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "https://api.deepseek.com", "wire_api": "openai_chat_completions"}}}' > "$chat/providers.json" + # anthropic-messages: DeepSeek's Anthropic-compatible endpoint, same key + anth="$(mktemp -d)/home"; mkdir -p "$anth" + printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "https://api.deepseek.com/anthropic", "wire_api": "anthropic_messages"}}}' > "$anth/providers.json" + # responses: builtin OpenAI (openai_responses wire) + resp="$(mktemp -d)/home"; mkdir -p "$resp" + printf '%s\n' '{"model": "openai/gpt-5-mini", "provider": {"openai": {"credential": "openai", "enabled": true, "base_url": "https://api.openai.com/v1", "wire_api": "openai_responses"}}}' > "$resp/providers.json" + for home in "$chat" "$anth"; do + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ deepseek: { type: "api_key", key: process.env.DEEPSEEK_API_KEY } }, null, 2));' "$home/auth.json" + chmod 600 "$home/auth.json" + done + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ openai: { type: "api_key", key: process.env.OPENAI_API_KEY } }, null, 2));' "$resp/auth.json" + chmod 600 "$resp/auth.json" + echo "DEVO_LIVE_CHAT_HOME=$chat" >> "$GITHUB_ENV" + echo "DEVO_LIVE_ANTHROPIC_HOME=$anth" >> "$GITHUB_ENV" + echo "DEVO_LIVE_RESPONSES_HOME=$resp" >> "$GITHUB_ENV" + - name: Chat Completions wire (real DeepSeek) + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_CHAT_HOME" node scripts/run-tmux-smoke.js T2 + - name: Anthropic Messages wire (real DeepSeek) + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_ANTHROPIC_HOME" node scripts/run-tmux-smoke.js T2 + - name: Responses wire (real OpenAI) + # Optional second secret; skipped when absent. + if: secrets.OPENAI_API_KEY != '' + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_RESPONSES_HOME" node scripts/run-tmux-smoke.js T2 + - name: Interrupted-turn scenario (real DeepSeek, Chat Completions) + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_CHAT_HOME" node scripts/run-tmux-smoke.js T3 + check: needs: changes if: needs.changes.outputs.rust == 'true' diff --git a/apps/tui/scripts/smoke-mock-provider.mjs b/apps/tui/scripts/smoke-mock-provider.mjs new file mode 100644 index 00000000..9308a610 --- /dev/null +++ b/apps/tui/scripts/smoke-mock-provider.mjs @@ -0,0 +1,84 @@ +/** Minimal streaming mock used by the CI tmux smoke (secret-free). + * + * Serves one deterministic assistant reply per POST so scenario `T2` + * (send a prompt, assert a completed turn) runs without a real provider. + * Wire is chosen by `--wire`: + * openai → OpenAI-completions SSE chunks + `[DONE]` (default) + * anthropic → Anthropic Messages SSE event stream + * responses → OpenAI Responses SSE (`event:` + `data:` pairs; the official + * SDK parser rejects data-only frames) + * Start with `node scripts/smoke-mock-provider.mjs [port] [--wire openai|anthropic|responses]`; + * listens on 127.0.0.1 and never validates credentials. + */ +import { createServer } from "node:http"; + +const args = process.argv.slice(2); +const port = Number(args.find((a) => !a.startsWith("--")) ?? 8801); +const wire = args.includes("--wire") ? args[args.indexOf("--wire") + 1] : "openai"; +const reply = "Smoke acknowledged."; + +function openaiStream(response) { + const chunks = [ + { + id: "smoke", + object: "chat.completion.chunk", + created: 0, + model: "deepseek-v4-flash", + choices: [ + { index: 0, delta: { role: "assistant", content: reply }, finish_reason: null }, + ], + }, + { + id: "smoke", + object: "chat.completion.chunk", + created: 0, + model: "deepseek-v4-flash", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]; + for (const chunk of chunks) response.write(`data: ${JSON.stringify(chunk)}\n\n`); + response.end("data: [DONE]\n\n"); +} + +function anthropicStream(response) { + const events = [ + ["message_start", { type: "message_start", message: { id: "msg_smoke", type: "message", role: "assistant", model: "deepseek-v4-flash", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } } }], + ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }], + ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: reply } }], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + ["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } }], + ["message_stop", { type: "message_stop" }], + ]; + for (const [event, payload] of events) { + response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); + } + response.end(); +} + +function responsesStream(response) { + const events = [ + { type: "response.created", sequence_number: 0, response: { id: "resp_smoke", object: "response", status: "in_progress", model: "gpt-5-mini", output: [] } }, + { type: "response.output_item.added", sequence_number: 1, output_index: 0, item: { id: "msg_1", type: "message", status: "in_progress", role: "assistant", content: [] } }, + { type: "response.content_part.added", sequence_number: 2, item_id: "msg_1", output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, + { type: "response.output_text.delta", sequence_number: 3, item_id: "msg_1", output_index: 0, content_index: 0, delta: reply }, + { type: "response.output_text.done", sequence_number: 4, item_id: "msg_1", output_index: 0, content_index: 0, text: reply }, + { type: "response.content_part.done", sequence_number: 5, item_id: "msg_1", output_index: 0, content_index: 0, part: { type: "output_text", text: reply, annotations: [] } }, + { type: "response.output_item.done", sequence_number: 6, output_index: 0, item: { id: "msg_1", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: reply, annotations: [] }] } }, + { type: "response.completed", sequence_number: 7, response: { id: "resp_smoke", object: "response", status: "completed", model: "gpt-5-mini", output: [{ id: "msg_1", type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text: reply, annotations: [] }] }], usage: { input_tokens: 1, input_tokens_details: { cached_tokens: 0 }, output_tokens: 2, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 3 } } }, + ]; + for (const event of events) { + response.write(`event: ${event.type}\n`); + response.write(`data: ${JSON.stringify(event)}\n\n`); + } + response.end(); +} + +createServer((_request, response) => { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + if (wire === "anthropic") anthropicStream(response); + else if (wire === "responses") responsesStream(response); + else openaiStream(response); +}).listen(port, "127.0.0.1", () => { + console.log(`smoke mock provider (${wire}) on http://127.0.0.1:${port}`); +}); diff --git a/apps/tui/scripts/smoke-runner.js b/apps/tui/scripts/smoke-runner.js index 03f94a7b..aadc1532 100644 --- a/apps/tui/scripts/smoke-runner.js +++ b/apps/tui/scripts/smoke-runner.js @@ -122,7 +122,18 @@ async function run() { if (ownedSession) mux(["kill-session", "-t", session], false); // The target is our own mkdtemp child, never the source credential folder. if (dirname(home) !== resolve(tmpdir())) throw new Error("Unexpected smoke home location"); - rmSync(home, { recursive: true, force: true }); + // The killed product can flush session files for a few beats after + // kill-session; a cleanup race must never fail a passing run, so retry + // briefly and then leave the temp dir to the OS. + for (let attempt = 0; attempt < 10; attempt++) { + try { + rmSync(home, { recursive: true, force: true }); + break; + } catch (error) { + if (attempt === 9) console.error(`home cleanup left behind (${error.code ?? error.message}): ${home}`); + await sleep(500); + } + } } } diff --git a/docs/development-checks.md b/docs/development-checks.md index 51e8f3c9..f8d43ba6 100644 --- a/docs/development-checks.md +++ b/docs/development-checks.md @@ -102,6 +102,49 @@ 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 From 30f3346c7c26eb09293e4f320e284e4985e3ed2c Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:27:11 +0000 Subject: [PATCH 04/11] feat(sandbox): temporarily disable OS-sandbox enforcement everywhere SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED is read at the three spawn decision points (tool router, server terminals, RLM kernel fence), so every process runs with full user permissions regardless of the session's configured, persisted, or live-updated profile. The kernel Plan-mode fence gates relax accordingly; the enforcement machinery itself stays compiled and covered by its direct tests. Flip the const to false to re-enable. Co-Authored-By: Claude Code --- crates/core/src/tools/handlers/ipython.rs | 13 ++++++++++++- crates/core/src/tools/router.rs | 20 ++++++++++++++------ crates/sandbox/src/lib.rs | 8 ++++++++ crates/server/src/runtime/command_exec.rs | 5 +++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/core/src/tools/handlers/ipython.rs b/crates/core/src/tools/handlers/ipython.rs index 96f4894a..ae9f5ddc 100644 --- a/crates/core/src/tools/handlers/ipython.rs +++ b/crates/core/src/tools/handlers/ipython.rs @@ -529,6 +529,7 @@ pub async fn ensure_kernel_with_restore_state( 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)); @@ -583,7 +584,16 @@ pub async fn ensure_kernel_with_restore_state( // for the kernel. The kernel crate owns the explicit-downgrade path when // the fence cannot be raised (unprovisioned sandbox, missing platform // wiring). - if let Some(fence) = resolve_kernel_fence(cwd, session_dir, collaboration_mode) { + // + // 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. @@ -619,6 +629,7 @@ pub async fn ensure_kernel_with_restore_state( .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!( diff --git a/crates/core/src/tools/router.rs b/crates/core/src/tools/router.rs index aeda040b..695db8ae 100644 --- a/crates/core/src/tools/router.rs +++ b/crates/core/src/tools/router.rs @@ -325,12 +325,20 @@ impl ToolRuntime { } } - 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(), + // 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 already_bypassed_sandbox = sandbox_profile_is_inactive(sandbox_profile.as_deref()); // `already_approved`: only user/session/auto-review approvals unlock diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs index a9bf4356..782d1fbd 100644 --- a/crates/sandbox/src/lib.rs +++ b/crates/sandbox/src/lib.rs @@ -39,6 +39,14 @@ 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/server/src/runtime/command_exec.rs b/crates/server/src/runtime/command_exec.rs index 0b60be0a..64c11392 100644 --- a/crates/server/src/runtime/command_exec.rs +++ b/crates/server/src/runtime/command_exec.rs @@ -1021,11 +1021,16 @@ 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()) From a5d2af687eaa3125d7ea480ca3e523c30afecead Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:28:27 +0000 Subject: [PATCH 05/11] feat(permissions): default new sessions to full-access While OS sandboxing is temporarily disabled, new sessions default to the full-access preset so tool calls run uninterrupted (yolo + allow_network); the approval pipeline itself is unchanged. Explicit project permission_preset config still wins. child_approval_routing pins its session back to "default" because it exercises interactive approval routing. Co-Authored-By: Claude Code --- crates/protocol/src/permissions.rs | 4 +++- crates/safety/src/lib.rs | 4 +++- crates/server/src/runtime/handlers/session.rs | 4 +++- crates/server/src/session_context.rs | 6 ++++- .../server/tests/approval_park_preemption.rs | 4 +++- crates/server/tests/child_approval_routing.rs | 23 +++++++++++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/protocol/src/permissions.rs b/crates/protocol/src/permissions.rs index 7d8ca834..4d7e30fd 100644 --- a/crates/protocol/src/permissions.rs +++ b/crates/protocol/src/permissions.rs @@ -9,12 +9,14 @@ use ts_rs::TS; pub enum PermissionPreset { /// Read and edit workspace files and run shell commands; network and /// outside-workspace writes ask. - #[default] Default, /// Same base policy as default, but eligible approvals may be routed /// through an automatic reviewer before the user is interrupted. AutoReview, /// Allow all tool requests without approval. + /// Temporarily the product default while OS sandboxing is disabled; move + /// `#[default]` back to `Default` to re-enable the asking default. + #[default] FullAccess, } diff --git a/crates/safety/src/lib.rs b/crates/safety/src/lib.rs index c7a72890..37bc5bbb 100644 --- a/crates/safety/src/lib.rs +++ b/crates/safety/src/lib.rs @@ -32,12 +32,14 @@ pub enum PermissionMode { pub enum PermissionPreset { /// Read and edit workspace files and run shell commands; network and outside /// workspace writes ask. - #[default] Default, /// Same base policy as default, but eligible approvals may be routed through /// an automatic reviewer before the user is interrupted. AutoReview, /// Allow all tool requests without approval. + /// Temporarily the product default while OS sandboxing is disabled; move + /// `#[default]` back to `Default` to re-enable the asking default. + #[default] FullAccess, } diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index cd3b0cfb..551855e5 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -199,7 +199,9 @@ impl ServerRuntime { reasoning_effort: None, }, settings: devo_protocol::native::session::SessionSettings { - permission_profile: devo_protocol::native::model::PermissionProfile::AutoReview, + // Sandbox temporarily disabled: new sessions advertise the + // full-access default (matches the session_context fallback). + permission_profile: devo_protocol::native::model::PermissionProfile::FullAccess, reasoning_effort: initial_effort, mode: Some("build".to_string()), sandbox_profile: None, diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index f8733c6a..e4aa5505 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -411,9 +411,13 @@ impl SessionRuntimeContext { Some(devo_protocol::PermissionPreset::FullAccess) => { devo_safety::PermissionPreset::FullAccess } - Some(devo_protocol::PermissionPreset::AutoReview) | None => { + Some(devo_protocol::PermissionPreset::AutoReview) => { devo_safety::PermissionPreset::AutoReview } + // Sandbox temporarily disabled: new sessions default to + // FullAccess so nothing interrupts the user. Flip back to + // AutoReview to re-enable the asking default. + None => devo_safety::PermissionPreset::FullAccess, }; let sandbox_from_project = project.and_then(|project| project.sandbox_profile.clone()); let sandbox_from_global = config_store diff --git a/crates/server/tests/approval_park_preemption.rs b/crates/server/tests/approval_park_preemption.rs index ae7c2f02..f8851a5c 100644 --- a/crates/server/tests/approval_park_preemption.rs +++ b/crates/server/tests/approval_park_preemption.rs @@ -204,7 +204,9 @@ async fn park_turn_at_approval( subscription_response.get("error").is_none(), "subscription/create failed: {subscription_response}" ); - // Sessions start at AutoReview. The explicit unadvertised network request + // The session is pinned to "default" below: the product default is + // temporarily full-access (sandbox disabled), but this test needs the + // interactive-approval behavior. The explicit unadvertised network request // on the `ipython` call still requires an interactive approval. let tighten_response = runtime .handle_incoming( diff --git a/crates/server/tests/child_approval_routing.rs b/crates/server/tests/child_approval_routing.rs index 7ed79a70..c8ca22cf 100644 --- a/crates/server/tests/child_approval_routing.rs +++ b/crates/server/tests/child_approval_routing.rs @@ -89,6 +89,29 @@ async fn child_approval_routes_to_parent_subscriber_while_parent_idle() -> Resul let parent_session_id = start_parent_session(&runtime, connection_id, &workspace_root).await?; + // New sessions default to full-access while the sandbox is disabled; + // this test exercises interactive approval routing, so pin the preset + // back to "default" (same shape as approval_park_preemption). + let tighten_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 3, + "method": "session/metadata/update", + "params": { + "sessionId": parent_session_id.to_string(), + "expectedVersion": 1, + "settings": { "permissionProfile": "default" } + } + }), + ) + .await + .context("settings update response")?; + assert!( + tighten_response.get("error").is_none(), + "settings update failed: {tighten_response}" + ); + // The TUI subscribes with an explicit Session selector (subscription/ // create); only these selectors count as "controlling" for approvals. let subscription_response = runtime From 85e5533da01595e2d45be92dd09a8809e00cac59 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:29:33 +0000 Subject: [PATCH 06/11] feat(tui): hide /permissions while sandboxing is disabled PERMISSIONS_COMMAND_ENABLED gates the builtin command's autocomplete and dispatch (status message instead of the selector) and the connection-layer slash intercept that runs first in the product binary. The handler, selector component, parser, and read/updatePermissionProfile APIs stay compiled for the revert and for the --dangerously-skip-permissions CLI path. Co-Authored-By: Claude Code --- apps/tui/lib/coding-agent/src/core/slash-commands.ts | 5 +++++ .../src/modes/interactive/interactive-mode.ts | 8 +++++++- apps/tui/src/native-agent-connection.ts | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/tui/lib/coding-agent/src/core/slash-commands.ts b/apps/tui/lib/coding-agent/src/core/slash-commands.ts index db995e20..a77c8875 100644 --- a/apps/tui/lib/coding-agent/src/core/slash-commands.ts +++ b/apps/tui/lib/coding-agent/src/core/slash-commands.ts @@ -3,6 +3,11 @@ import type { SourceInfo } from "./source-info.js"; export type SlashCommandSource = "extension" | "prompt" | "skill"; +/** /permissions is temporarily hidden: OS sandboxing is disabled repo-wide and + * new sessions default to full-access, so the profile selector has nothing to + * switch. Flip to true to restore the command. */ +export const PERMISSIONS_COMMAND_ENABLED = false; + export interface SlashCommandInfo { name: string; description?: string; diff --git a/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts b/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts index 85683a74..8b54f605 100644 --- a/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/apps/tui/lib/coding-agent/src/modes/interactive/interactive-mode.ts @@ -131,6 +131,7 @@ import { builtinSlashCommandTakesArgument, isBuiltinSlashCommandName, parseSlashCommand, + PERMISSIONS_COMMAND_ENABLED, resolveBuiltinSlashCommandName, } from "../../core/slash-commands.js"; import { @@ -1103,6 +1104,7 @@ export class InteractiveMode { const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.filter( (command) => command.name !== "fast" || this.currentModelSupportsFastMode(), ) + .filter((command) => command.name !== "permissions" || PERMISSIONS_COMMAND_ENABLED) .filter((command) => !this.isExcludedBuiltinCommand(command.name)) .map((command) => ({ name: command.name, @@ -4655,7 +4657,11 @@ export class InteractiveMode { } if (commandName === "permissions") { this.editor.setText(""); - await this.handlePermissionCommand(commandArgs); + if (PERMISSIONS_COMMAND_ENABLED) { + await this.handlePermissionCommand(commandArgs); + } else { + this.showStatus("/permissions is temporarily unavailable"); + } return; } if (commandName === "fast") { diff --git a/apps/tui/src/native-agent-connection.ts b/apps/tui/src/native-agent-connection.ts index ca3273b1..b27cf8be 100644 --- a/apps/tui/src/native-agent-connection.ts +++ b/apps/tui/src/native-agent-connection.ts @@ -36,6 +36,7 @@ import type { } from "@earendil-works/pi-coding-agent/agent-connection"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ContextTreeNode } from "../lib/coding-agent/src/core/context-tree.js"; +import { PERMISSIONS_COMMAND_ENABLED } from "../lib/coding-agent/src/core/slash-commands.js"; import type { BashResult } from "../lib/coding-agent/src/core/bash-executor.js"; import { measureAsync } from "@earendil-works/pi-coding-agent/timings"; import { StdioJsonRpc, type JsonRpcId } from "./stdio-jsonrpc.js"; @@ -2187,7 +2188,7 @@ export class NativeAgentConnection implements AgentConnection { async prompt(message: string, options: AgentConnectionPromptOptions = {}): Promise { if (await this.tryHandleGoalSlash(message)) return; - if (await this.tryHandlePermissionsSlash(message)) return; + if (PERMISSIONS_COMMAND_ENABLED && (await this.tryHandlePermissionsSlash(message))) return; if (await this.tryHandleCollaborationModeSlash(message)) return; if (await this.tryHandleTracesSlash(message)) return; if (await this.tryHandleSessionSlash(message)) return; From dd0fc1c44839b63bf99e1b34494fd4d94c8a2bd6 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 08:31:00 +0000 Subject: [PATCH 07/11] feat(desktop): hide the permission picker, default launches to full-access PERMISSION_PICKER_ENABLED gates both composer-footer picker renders; state, stash, and persistence wiring stay in place so the source-string contract tests keep passing. The composer default flips to fullAccess to match the server's new-session default while the sandbox is disabled (Desktop pushes this value on launch). Co-Authored-By: Claude Code --- .../src/renderer/components/chat/chat-view.tsx | 13 ++++++++----- .../components/chat/composer-permission.test.ts | 2 +- .../renderer/components/chat/composer-permission.ts | 9 +++++++-- apps/desktop/src/renderer/components/new-chat.tsx | 13 ++++++++----- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 98ef687d..062ec3c2 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -147,6 +147,7 @@ import { DEFAULT_COMPOSER_PERMISSION_PROFILE, type ComposerPermissionProfile, parseComposerPermissionProfile, + PERMISSION_PICKER_ENABLED, takeComposerPermissionForSession, } from "./composer-permission" import { goalPromptText, parseComposerSlash } from "./composer-slash" @@ -2389,11 +2390,13 @@ export function ChatInputSection({ - + {PERMISSION_PICKER_ENABLED && ( + + )} {collaborationMode === "plan" && ( { expect(parseComposerPermissionProfile("auto-review")).toBe("autoReview") expect(parseComposerPermissionProfile("fullAccess")).toBe("fullAccess") expect(parseComposerPermissionProfile("default")).toBe("default") - expect(parseComposerPermissionProfile(undefined)).toBe("autoReview") + expect(parseComposerPermissionProfile(undefined)).toBe("fullAccess") expect(composerPermissionLabel("autoReview")).toBe("Approve for me") expect(composerPermissionLabel("default")).toBe("Ask for approval") }) diff --git a/apps/desktop/src/renderer/components/chat/composer-permission.ts b/apps/desktop/src/renderer/components/chat/composer-permission.ts index 6da3c5f0..e086435e 100644 --- a/apps/desktop/src/renderer/components/chat/composer-permission.ts +++ b/apps/desktop/src/renderer/components/chat/composer-permission.ts @@ -7,8 +7,13 @@ export type ComposerPermissionProfile = "default" | "autoReview" | "fullAccess" -/** Matches server `new_session_state`: new sessions default to AutoReview. */ -export const DEFAULT_COMPOSER_PERMISSION_PROFILE: ComposerPermissionProfile = "autoReview" +/** The picker is temporarily hidden while OS sandboxing is disabled repo-wide; + * flip to true to restore it. */ +export const PERMISSION_PICKER_ENABLED = false + +/** Matches server `new_session_state`: new sessions default to FullAccess + * (temporary product default while the sandbox is disabled). */ +export const DEFAULT_COMPOSER_PERMISSION_PROFILE: ComposerPermissionProfile = "fullAccess" export const COMPOSER_PERMISSION_PROFILES: readonly { id: ComposerPermissionProfile diff --git a/apps/desktop/src/renderer/components/new-chat.tsx b/apps/desktop/src/renderer/components/new-chat.tsx index 6f14ec01..c47aaf81 100644 --- a/apps/desktop/src/renderer/components/new-chat.tsx +++ b/apps/desktop/src/renderer/components/new-chat.tsx @@ -59,6 +59,7 @@ import { ComposerPermissionPicker } from "./chat/composer-permission-picker" import { DEFAULT_COMPOSER_PERMISSION_PROFILE, type ComposerPermissionProfile, + PERMISSION_PICKER_ENABLED, stashComposerPermissionForSession, } from "./chat/composer-permission" import { goalPromptText, parseComposerSlash } from "./chat/composer-slash" @@ -846,11 +847,13 @@ export function NewChat() { - + {PERMISSION_PICKER_ENABLED && ( + + )} {collaborationMode === "plan" && ( Date: Mon, 19 Oct 2026 08:33:00 +0000 Subject: [PATCH 08/11] docs: note the temporary sandbox disablement and its revert switches Banner on the sandbox/permissions docs, the user guide, the dev checks page, and the README pointing at the three enable constants and the default-preset literals. Co-Authored-By: Claude Code --- README.md | 7 +++++++ docs/development-checks.md | 8 ++++++++ docs/devo-home-permissions.md | 8 ++++++++ docs/rlm-shell-escalation.md | 8 ++++++++ docs/runtime-isolation.md | 8 ++++++++ docs/usage.md | 8 ++++++++ 6 files changed, 47 insertions(+) diff --git a/README.md b/README.md index 752f3721..9cd0a600 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ 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. + ## Configuration Connect endpoints using **OpenAI-compatible Chat Completions**, **OpenAI-compatible diff --git a/docs/development-checks.md b/docs/development-checks.md index f8d43ba6..257b0c41 100644 --- a/docs/development-checks.md +++ b/docs/development-checks.md @@ -1,5 +1,13 @@ # Fast development checks +> **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 diff --git a/docs/devo-home-permissions.md b/docs/devo-home-permissions.md index 595ebd65..2db85fbc 100644 --- a/docs/devo-home-permissions.md +++ b/docs/devo-home-permissions.md @@ -1,5 +1,13 @@ # 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 diff --git a/docs/rlm-shell-escalation.md b/docs/rlm-shell-escalation.md index 509715aa..8713d909 100644 --- a/docs/rlm-shell-escalation.md +++ b/docs/rlm-shell-escalation.md @@ -1,5 +1,13 @@ # 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: diff --git a/docs/runtime-isolation.md b/docs/runtime-isolation.md index 269dcddc..3e3fa1d9 100644 --- a/docs/runtime-isolation.md +++ b/docs/runtime-isolation.md @@ -1,5 +1,13 @@ # 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. diff --git a/docs/usage.md b/docs/usage.md index 02f63996..4ccf5bfb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,5 +1,13 @@ # 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. + + [Back to README](../README.md) Detailed runtime, installation, and configuration guidance. For the short From accca9e0015bfd46ca56f1c61d1bf33527d00e6f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 09:06:50 +0000 Subject: [PATCH 09/11] test: adjust contract tests for the full-access default Skip-gate the Plan-fence and SANDBOX_DENIED retry-ladder tests behind SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED (kept for the post-revert contract), pin interactive-approval tests back to a asking preset, and update fresh-session default assertions (mode currentValue, redundant fullAccess prelude) to the new full-access start. Co-Authored-By: Claude Code --- .../core/src/tools/handlers/ipython_tests.rs | 12 +++++++++ .../tools/handlers/kernel_startup_tests.rs | 6 +++++ crates/core/src/tools/router_tests.rs | 17 +++++++++++++ crates/server/src/runtime/connection_tests.rs | 10 ++++---- .../server/tests/acp_session_contract_e2e.rs | 9 ++++--- crates/server/tests/settings_mid_turn.rs | 25 +++---------------- .../tests/support/acp_permission_support.rs | 23 +++++++++++++++++ 7 files changed, 71 insertions(+), 31 deletions(-) diff --git a/crates/core/src/tools/handlers/ipython_tests.rs b/crates/core/src/tools/handlers/ipython_tests.rs index 7636c80b..4b710db2 100644 --- a/crates/core/src/tools/handlers/ipython_tests.rs +++ b/crates/core/src/tools/handlers/ipython_tests.rs @@ -610,6 +610,12 @@ fn plan_fence_reads_workspace_but_only_build_fence_writes_it() { #[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, @@ -634,6 +640,12 @@ async fn plan_kernel_fails_closed_when_workspace_is_in_scratch() { #[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")) diff --git a/crates/core/src/tools/handlers/kernel_startup_tests.rs b/crates/core/src/tools/handlers/kernel_startup_tests.rs index 508d2d29..c8fed6aa 100644 --- a/crates/core/src/tools/handlers/kernel_startup_tests.rs +++ b/crates/core/src/tools/handlers/kernel_startup_tests.rs @@ -25,6 +25,12 @@ fn session_fence_does_not_grant_shared_temp_roots() { #[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( diff --git a/crates/core/src/tools/router_tests.rs b/crates/core/src/tools/router_tests.rs index 4088c7e0..8ea9297d 100644 --- a/crates/core/src/tools/router_tests.rs +++ b/crates/core/src/tools/router_tests.rs @@ -952,6 +952,13 @@ fn sandbox_runtime( #[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())); @@ -1001,6 +1008,11 @@ 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( @@ -1026,6 +1038,11 @@ 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/server/src/runtime/connection_tests.rs b/crates/server/src/runtime/connection_tests.rs index d4a19f2c..01aa1b30 100644 --- a/crates/server/src/runtime/connection_tests.rs +++ b/crates/server/src/runtime/connection_tests.rs @@ -4431,7 +4431,7 @@ async fn native_metadata_update_branches() -> Result<()> { serde_json::json!({ "sessionId": session_id.to_string(), "expectedVersion": 1, - "settings": { "permissionProfile": "fullAccess" }, + "settings": { "permissionProfile": "autoReview" }, }), ) .await; @@ -4439,7 +4439,7 @@ async fn native_metadata_update_branches() -> Result<()> { json_result(&response, "rpc"); assert_eq!( result.session.settings.permission_profile, - devo_protocol::native::model::PermissionProfile::FullAccess + devo_protocol::native::model::PermissionProfile::AutoReview ); assert_eq!(result.session.version, 2); let rollout_path = env @@ -4454,7 +4454,7 @@ async fn native_metadata_update_branches() -> Result<()> { .expect("session") .settings .permission_profile, - devo_protocol::native::model::PermissionProfile::FullAccess + devo_protocol::native::model::PermissionProfile::AutoReview ); let conflict = env @@ -4570,7 +4570,7 @@ async fn native_metadata_update_during_active_turn() -> Result<()> { serde_json::json!({ "sessionId": session_id.to_string(), "expectedVersion": 1, - "settings": { "permissionProfile": "fullAccess" }, + "settings": { "permissionProfile": "autoReview" }, }), ) .await; @@ -4586,7 +4586,7 @@ async fn native_metadata_update_during_active_turn() -> Result<()> { let inline = stream.turn_inline.as_ref().expect("turn inline state"); assert_eq!( inline.hook_context.config.permission_profile.preset, - devo_safety::PermissionPreset::FullAccess + devo_safety::PermissionPreset::AutoReview ); drop(stream); open.store(true, std::sync::atomic::Ordering::SeqCst); diff --git a/crates/server/tests/acp_session_contract_e2e.rs b/crates/server/tests/acp_session_contract_e2e.rs index bda7afe3..756c2d25 100644 --- a/crates/server/tests/acp_session_contract_e2e.rs +++ b/crates/server/tests/acp_session_contract_e2e.rs @@ -243,7 +243,8 @@ base_instructions = "Catalog-only model instructions" assert_config_option_values(reasoning_effort_option, &["low", "medium", "high"])?; let mode_option = acp_config_option(&session_new_response["result"], "mode")?; assert_eq!(mode_option["name"], json!("Session Mode")); - assert_eq!(mode_option["currentValue"], json!("auto-review")); + // New sessions default to full-access while the sandbox is disabled. + assert_eq!(mode_option["currentValue"], json!("full-access")); assert_config_option_values(mode_option, &["default", "auto-review", "full-access"])?; let set_reasoning_effort_response = stdio_request_until( @@ -300,7 +301,7 @@ base_instructions = "Catalog-only model instructions" assert!(acp_config_option_optional(&set_config_response["result"], "thought_level").is_none()); assert_eq!( acp_config_option(&set_config_response["result"], "mode")?["currentValue"], - json!("auto-review") + json!("full-access") ); let set_mode_response = stdio_request_until( @@ -310,14 +311,14 @@ base_instructions = "Catalog-only model instructions" json!({ "sessionId": session_id, "configId": "mode", - "value": "full-access" + "value": "auto-review" }), "ACP session/set_config_option mode response", ) .await?; assert_eq!( acp_config_option(&set_mode_response["result"], "mode")?["currentValue"], - json!("full-access") + json!("auto-review") ); assert_eq!( acp_model_config_option(&set_mode_response["result"])?["currentValue"], diff --git a/crates/server/tests/settings_mid_turn.rs b/crates/server/tests/settings_mid_turn.rs index f575d3c2..69643600 100644 --- a/crates/server/tests/settings_mid_turn.rs +++ b/crates/server/tests/settings_mid_turn.rs @@ -335,27 +335,8 @@ async fn mid_turn_tighten_to_default_triggers_approval_for_ipython_permission_re "subscription/create failed: {subscription_response}" ); - // Start permissive: the first probe must run without any approval. - let settings_response = runtime - .handle_incoming( - connection_id, - json!({ - "id": 4, - "method": "session/metadata/update", - "params": { - "sessionId": session_id.to_string(), - "expectedVersion": 1, - "settings": { "permissionProfile": "fullAccess" } - } - }), - ) - .await - .context("settings update response")?; - assert!( - settings_response.get("error").is_none(), - "settings update failed: {settings_response}" - ); - + // Start permissive (the temporary full-access default while the sandbox + // is disabled): the first probe must run without any approval. let turn_response = runtime .handle_incoming( connection_id, @@ -416,7 +397,7 @@ async fn mid_turn_tighten_to_default_triggers_approval_for_ipython_permission_re "method": "session/metadata/update", "params": { "sessionId": session_id.to_string(), - "expectedVersion": 2, + "expectedVersion": 1, "settings": { "permissionProfile": "default" } } }), diff --git a/crates/server/tests/support/acp_permission_support.rs b/crates/server/tests/support/acp_permission_support.rs index 5b66d587..b8a5bb02 100644 --- a/crates/server/tests/support/acp_permission_support.rs +++ b/crates/server/tests/support/acp_permission_support.rs @@ -114,6 +114,29 @@ pub(crate) async fn start_permission_prompt(prompt_request_id: u64) -> Result Date: Mon, 19 Oct 2026 09:07:15 +0000 Subject: [PATCH 10/11] style: cargo fmt Co-Authored-By: Claude Code --- crates/cli/src/doctor_command.rs | 4 +--- crates/kernel/src/session.rs | 16 +++++++++------- crates/utils/paths/src/runtime.rs | 4 +++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/cli/src/doctor_command.rs b/crates/cli/src/doctor_command.rs index 236b8c62..4ba3d207 100644 --- a/crates/cli/src/doctor_command.rs +++ b/crates/cli/src/doctor_command.rs @@ -26,9 +26,7 @@ pub(crate) async fn run_doctor() -> Result<()> { .as_ref() .map_or_else(|| "node".into(), |runtime| runtime.node.clone()); let python = bundle.as_ref().map_or_else( - || { - devo_util_paths::runtime::fallback_interpreter(std::env::var_os("PATH").as_deref()) - }, + || devo_util_paths::runtime::fallback_interpreter(std::env::var_os("PATH").as_deref()), |runtime| runtime.python.clone(), ); use devo_util_paths::runtime_environment::{Interpreter, remove_interpreter_environment}; diff --git a/crates/kernel/src/session.rs b/crates/kernel/src/session.rs index d42089fc..4bd2a685 100644 --- a/crates/kernel/src/session.rs +++ b/crates/kernel/src/session.rs @@ -100,7 +100,11 @@ impl Default for KernelSessionConfig { let bundle = devo_util_paths::runtime::RuntimeBundle::current(); Self { python: bundle.as_ref().map_or_else( - || devo_util_paths::runtime::fallback_interpreter(std::env::var_os("PATH").as_deref()), + || { + devo_util_paths::runtime::fallback_interpreter( + std::env::var_os("PATH").as_deref(), + ) + }, |runtime| runtime.python.clone(), ), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), @@ -656,12 +660,10 @@ impl KernelSession { cmd.env(key, value); } - let mut child = cmd - .spawn() - .map_err(|source| SpawnError::SpawnInterpreter { - python: config.python.clone(), - source, - })?; + let mut child = cmd.spawn().map_err(|source| SpawnError::SpawnInterpreter { + python: config.python.clone(), + source, + })?; let stdin = child .stdin .take() diff --git a/crates/utils/paths/src/runtime.rs b/crates/utils/paths/src/runtime.rs index 63de4475..e0b93bd9 100644 --- a/crates/utils/paths/src/runtime.rs +++ b/crates/utils/paths/src/runtime.rs @@ -141,7 +141,9 @@ mod tests { // Nothing found (or no PATH at all): keep the historical bare name so // the spawn error stays recognizable. assert_eq!( - fallback_interpreter(Some(join_paths(std::path::Path::new("/nonexistent")).as_os_str())), + fallback_interpreter(Some( + join_paths(std::path::Path::new("/nonexistent")).as_os_str() + )), PathBuf::from("python") ); assert_eq!(fallback_interpreter(None), PathBuf::from("python")); From c9fb12f95d6218ecb9345a1c0ca5b681ca160a8f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Mon, 19 Oct 2026 09:20:46 +0000 Subject: [PATCH 11/11] fix(ci): read the optional OPENAI key from job env in the step if The secrets context is not available in step-level if expressions, so the workflow failed to compose on GitHub (0-second workflow file error). Mirror the job env value instead, which actionlint accepts. Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a69758f0..f83d72c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -279,8 +279,9 @@ jobs: DEVO_TUI_TEST_TIMEOUT_MS: "180000" run: DEVO_HOME="$DEVO_LIVE_ANTHROPIC_HOME" node scripts/run-tmux-smoke.js T2 - name: Responses wire (real OpenAI) - # Optional second secret; skipped when absent. - if: secrets.OPENAI_API_KEY != '' + # Optional second secret; skipped when absent. The secrets context is + # not available in step-level `if`, so read the job env mirror. + if: env.OPENAI_API_KEY != '' working-directory: apps/tui env: DEVO_TUI_TEST_TIMEOUT_MS: "180000"