diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe563baa1..f83d72c65 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,147 @@ 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. 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" + 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/README.md b/README.md index 752f37211..9cd0a6005 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/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 98ef687d6..062ec3c21 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 6da3c5f0e..e086435e9 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 6f14ec019..c47aaf815 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" && ( 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") { @@ -8900,18 +8906,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. } diff --git a/apps/tui/scripts/smoke-mock-provider.mjs b/apps/tui/scripts/smoke-mock-provider.mjs new file mode 100644 index 000000000..9308a6101 --- /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 03f94a7bc..aadc15324 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/apps/tui/src/native-agent-connection.ts b/apps/tui/src/native-agent-connection.ts index ca3273b17..b27cf8be4 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; diff --git a/crates/cli/src/doctor_command.rs b/crates/cli/src/doctor_command.rs index ea2c38678..4ba3d2073 100644 --- a/crates/cli/src/doctor_command.rs +++ b/crates/cli/src/doctor_command.rs @@ -25,9 +25,10 @@ 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/core/src/tools/handlers/ipython.rs b/crates/core/src/tools/handlers/ipython.rs index 96f4894aa..ae9f5ddc5 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/handlers/ipython_tests.rs b/crates/core/src/tools/handlers/ipython_tests.rs index 7636c80bd..4b710db22 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 508d2d29f..c8fed6aa2 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.rs b/crates/core/src/tools/router.rs index aeda040b0..695db8ae4 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/core/src/tools/router_tests.rs b/crates/core/src/tools/router_tests.rs index 4088c7e07..8ea9297d9 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/kernel/src/session.rs b/crates/kernel/src/session.rs index a2359d67f..4bd2a6858 100644 --- a/crates/kernel/src/session.rs +++ b/crates/kernel/src/session.rs @@ -99,9 +99,14 @@ 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 +126,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 +660,10 @@ 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/protocol/src/permissions.rs b/crates/protocol/src/permissions.rs index 7d8ca834f..4d7e30fdc 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 c7a72890b..37bc5bbbb 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/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs index a9bf43567..782d1fbd9 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 0b60be0a0..64c11392f 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()) diff --git a/crates/server/src/runtime/connection_tests.rs b/crates/server/src/runtime/connection_tests.rs index d4a19f2c4..01aa1b30b 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/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index cd3b0cfb1..551855e5e 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 f8733c6a9..e4aa55058 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/acp_session_contract_e2e.rs b/crates/server/tests/acp_session_contract_e2e.rs index bda7afe35..756c2d258 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/approval_park_preemption.rs b/crates/server/tests/approval_park_preemption.rs index ae7c2f02f..f8851a5c6 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 7ed79a702..c8ca22cf4 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 diff --git a/crates/server/tests/settings_mid_turn.rs b/crates/server/tests/settings_mid_turn.rs index f575d3c22..696436008 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 5b66d587c..b8a5bb027 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 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,47 @@ 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(); diff --git a/docs/development-checks.md b/docs/development-checks.md index 51e8f3c96..257b0c419 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 @@ -102,6 +110,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 diff --git a/docs/devo-home-permissions.md b/docs/devo-home-permissions.md index 595ebd657..2db85fbc5 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 509715aa1..8713d909d 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 269dcddc3..3e3fa1d9e 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 02f63996d..4ccf5bfbd 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