diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 532145a38..f21aa230d 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -33,6 +33,14 @@ build and performance evidence. Root `just agent-check`, `agent-build`, and --no-update-lock-file` additionally validates workflow selection and security contracts when CI, Nix, or routing changes. +`just test` and `just ci` prepare the pinned bundled CPython fixture and run its +worker and packaging suites. `just code-mode-smoke` exercises the actual worker; +direct Cargo worker tests require `just python-prepare` first. These commands +run from `apps/maple-agent/`. Runtime execution never downloads Python or falls +back to the system interpreter. Linux Nix packages retain their separately +declared CPython runtime closure; portable debug/archive layouts use the pinned +Python standalone distribution. + Agent has its own Cargo and Nix lockfiles. Shared Rust SDK/proxy runtime changes must select Agent as well as the Research consumer; component-only changes must not unnecessarily select Research packaging. Maintain the root diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index 1c89f35cf..ba246d0a0 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -83,7 +83,15 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest-8-cores, macos-26, windows-latest] + include: + - os: ubuntu-latest-8-cores + focused: false + - os: macos-26 + focused: false + - os: windows-latest + focused: false + - os: macos-15-intel + focused: true steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -102,6 +110,12 @@ jobs: # Matches the component's frozen rust-overlay input. toolchain: 1.98.0 + - name: Install Windows build Python + if: runner.os == 'Windows' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.13' + - name: Cache Agent Rust dependencies uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -122,20 +136,39 @@ jobs: fi python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" + # Linux runs these same worker and packaging suites through just ci. + - name: Prepare pinned Python and test worker packaging + if: runner.os != 'Linux' + run: | + if [ "$RUNNER_OS" = Windows ]; then + python scripts/prepare-python.py + python scripts/test-python-worker.py + python -m unittest discover -s scripts/tests + else + nix develop --no-update-lock-file . -c just python-test + nix develop --no-update-lock-file . -c python3 -m unittest discover -s scripts/tests + fi + - name: Format, lint, build, and test Linux feature matrix if: runner.os == 'Linux' run: nix develop --no-update-lock-file . -c just ci - name: Build macOS workspace and test targets - if: runner.os == 'macOS' + if: runner.os == 'macOS' && !matrix.focused run: nix develop --no-update-lock-file . -c cargo build --workspace --all-targets --locked + - name: Build and test focused Intel macOS worker + if: matrix.focused + run: | + nix develop --no-update-lock-file . -c cargo build -p maple-code-mode --all-targets --locked + nix develop --no-update-lock-file . -c cargo test -p maple-code-mode --locked + - name: Package and smoke macOS debug app - if: runner.os == 'macOS' + if: runner.os == 'macOS' && !matrix.focused run: nix develop --no-update-lock-file . -c ./scripts/macos-debug-app.sh - name: Test macOS workspace - if: runner.os == 'macOS' + if: runner.os == 'macOS' && !matrix.focused run: nix develop --no-update-lock-file . -c cargo test --workspace --locked - name: Build and test Windows workspace @@ -144,16 +177,72 @@ jobs: cargo build --workspace --all-targets --locked cargo test --workspace --locked + - name: CodeMode debug smoke and relocated package without Python on PATH + run: | + if [ "$RUNNER_OS" = Windows ]; then + cargo run -p maple-code-mode --bin code-mode-smoke --locked + python scripts/check-python-package.py --smoke target/debug/code-mode-smoke.exe + else + nix develop --no-update-lock-file . -c just code-mode-smoke + nix develop --no-update-lock-file . -c python3 scripts/check-python-package.py --smoke target/debug/code-mode-smoke + fi + - name: Build Linux release binary if: runner.os == 'Linux' run: nix develop --no-update-lock-file . -c just release + - name: Stage complete Linux CI archive + if: runner.os == 'Linux' + run: | + nix develop --no-update-lock-file . -c python3 scripts/prepare-python.py \ + --distribution pbs --destination target/release/runtime/python + nix develop --no-update-lock-file . -c python3 scripts/package-archive.py \ + --binary target/release/maple-gpui --runtime target/release/runtime/python \ + --name "maple-agent-linux-x86_64-ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \ + --output-dir target/ci-dist + # This is an unsigned CI build, never a GitHub Release or an updater feed. - - name: Upload Linux CI binary + - name: Upload complete Linux CI archive if: runner.os == 'Linux' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: maple-agent-linux-x86_64-ci-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/release/maple-gpui + path: apps/maple-agent/target/ci-dist/* if-no-files-found: error retention-days: 5 + + nix-python: + name: Agent Nix runtime and package (Linux ARM64) + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install pinned Nix environment + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Build package with its retained interpreter + run: nix build --no-update-lock-file .#default + + - name: Verify installed closure and native worker + run: | + manifest="$(readlink -f result)/share/maple-gpui/python/runtime.json" + export MAPLE_CODE_MODE_DISTRIBUTION=nix + export MAPLE_CODE_MODE_RUNTIME_MANIFEST="$manifest" + nix develop --no-update-lock-file . -c just python-test + nix develop --no-update-lock-file . -c cargo test -p maple-code-mode --locked + nix develop --no-update-lock-file . -c just code-mode-smoke + nix path-info --recursive ./result > closure.txt + nix develop --no-update-lock-file . -c python3 - "$manifest" <<'PYTHON' + import json, pathlib, subprocess, sys + manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) + interpreter = pathlib.Path(manifest['executable']) + assert str(interpreter.parents[1]) in pathlib.Path('closure.txt').read_text().splitlines() + subprocess.run([str(interpreter), '-I', '-B', '-c', 'import ssl, sqlite3, ctypes, zlib, bz2, lzma'], check=True, env={}) + PYTHON diff --git a/apps/maple-agent/.gitattributes b/apps/maple-agent/.gitattributes new file mode 100644 index 000000000..51ce4c665 --- /dev/null +++ b/apps/maple-agent/.gitattributes @@ -0,0 +1,2 @@ +# Keep pinned upstream license notices byte-for-byte, including blank EOF lines. +scripts/python-licenses/* whitespace=-blank-at-eof diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 7e9946bed..2df4cfd60 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -3869,7 +3869,7 @@ dependencies = [ [[package]] name = "goose" version = "1.47.0" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -3965,7 +3965,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" version = "1.47.0" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "quote", "syn 2.0.119", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "goose-agent" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-trait", @@ -3989,7 +3989,7 @@ dependencies = [ [[package]] name = "goose-context-management" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-trait", @@ -4005,7 +4005,7 @@ dependencies = [ [[package]] name = "goose-download-manager" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "once_cell", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-stream", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-stream", @@ -4068,7 +4068,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -5820,6 +5820,7 @@ dependencies = [ "icu_properties", "libc", "log", + "maple-code-mode", "maple-proxy", "maple-sdk", "once_cell", @@ -5848,6 +5849,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "maple-code-mode" +version = "0.1.0" +dependencies = [ + "libc", + "log", + "process-wrap", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "windows 0.62.2", +] + [[package]] name = "maple-gpui" version = "0.1.0" @@ -5887,6 +5903,19 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "maple-harness" +version = "0.1.0" +dependencies = [ + "jsonschema 0.49.9", + "schemars 1.2.2", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio-util", + "uuid", +] + [[package]] name = "maple-proxy" version = "0.3.4" @@ -8839,6 +8868,7 @@ dependencies = [ "schemars_derive", "serde", "serde_json", + "uuid", ] [[package]] diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index 5f3507df7..329fe8526 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/maple-agent", "crates/maple-billing", "app"] +members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-code-mode", "crates/maple-harness", "app"] [workspace.package] edition = "2024" diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index ce873b434..719304f56 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -19,10 +19,10 @@ crates/maple-agent/ Maple's transport-neutral agent runtime, extracted from tools, permission policy, account-scoped session storage, and the ACP server. crates/maple-billing/ HTTP client for the Maple billing API. -docs/ Theme spec measured from the Tauri app. -scripts/ One maintainer helper: screenshot.py takes a desktop - screenshot through the xdg portal on GNOME Wayland. - Nothing in the build or the app uses it. +crates/maple-code-mode/ Bundled CPython worker, protocol, and process lifecycle. +docs/ Product and implementation notes, including the theme. +scripts/ Python preparation, packaging and validation helpers; + macOS debug-app staging; optional Wayland screenshots. ``` ### Backend / frontend boundary @@ -43,7 +43,8 @@ remove Tauri: sink are injectable traits - public visibility opened on the service surface the app consumes -Goose is pinned to the aaif-goose fork revision recorded in this component’s +Goose is pinned to an aaif-goose fork with Maple’s native-client integration and +opt-in ordered tool scheduling. Its exact revision is recorded in this component’s Cargo manifests and lockfile; Research has an independent dependency graph. ## Features @@ -56,18 +57,18 @@ Cargo manifests and lockfile; Research has an independent dependency graph. - Agent chat with streaming Markdown, tool calls, permission prompts, agent questions, image attachments (picker, paste, or drag and drop), a per-message Copy button, and a context-window indicator. +- [Python scratchpad](docs/python-code-mode.md): the normal `python_code` + tool uses bundled CPython with persistent task state and top-level await. + Existing permissions apply; the task menu can reset retained Python state. - Slash commands in the composer: `/btw` asks a side question the task never sees, plus `/compact`, `/new`, `/pin`, `/web`, `/model`, and `/help`. The account's skills appear in the same list. - The task's latest todo list stays pinned above the composer. - Subagents: the task can give a piece of work to a subagent with the - `delegate` tool, which runs it in its own context. Known limitation: - a subagent does not inherit the task's permission mode. Goose runs - every subagent with all tools approved, so even in Read only mode a - subagent can run shell commands and edit files without a prompt. The - fix needs the Goose fork to forward subagent approvals to the parent - (summon.rs hard-codes Auto because an approval would hang). The - subagents that work now show above the composer with the tool each one + `delegate` tool, which runs it in its own context. The pinned Goose fork + inherits the parent's permission mode and forwards child approvals. Its + independently constructed clients do not receive Maple's Python capability. + Subagents show above the composer with the tool each one runs and how long it has worked. A subagent that runs in the background keeps its row after the turn ends, and Maple tells the task when it finishes: into the running turn, or into the next one. diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index d61379294..f515adfd0 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -20,11 +20,11 @@ use std::sync::Arc; use maple_agent::agent::{ AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentEventSink, AgentIntegration, AgentIntegrationPermissionKind, AgentIntegrationPermissions, AgentProjectRootRegistration, - AgentProjectTrustStatus, AgentQueueControlRequest, AgentRenameSessionRequest, - AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, - AgentSessionSummary, AgentSetIntegrationEnabledRequest, AgentSetupIntegrationRequest, - AgentSlashCommand, AgentStartRequest, AgentSubagent, MapleAgentHostResources, - MapleAgentService, RecentProjectRoot, + AgentProjectTrustStatus, AgentPythonStatus, AgentQueueControlRequest, + AgentRenameSessionRequest, AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, + AgentSessionDetail, AgentSessionSummary, AgentSetIntegrationEnabledRequest, + AgentSetupIntegrationRequest, AgentSlashCommand, AgentStartRequest, AgentSubagent, + MapleAgentHostResources, MapleAgentService, RecentProjectRoot, }; use maple_agent::maple_api::{ MapleApiAuthEventSink, MapleApiAuthRequest, MapleApiAuthSnapshot, MapleApiAuthState, @@ -50,8 +50,8 @@ pub struct PendingPermission { pub request_id: String, pub tool_name: String, pub prompt: Option, - /// Pretty-printed tool arguments, formatted once when the request - /// arrives instead of on every frame. + /// Prepared tool arguments, including literal multiline Python source, + /// formatted once when the request arrives instead of on every frame. pub arguments: Arc, } @@ -1716,6 +1716,28 @@ impl AgentBackend { .await } + /// Read the current task's reset availability without starting Python. + pub async fn python_status( + &self, + user_id: &str, + session_id: &str, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .python_status(session_id.to_string()) + .await + } + + /// Reset the currently retained task state; success means cleanup completed. + pub async fn reset_python(&self, user_id: &str, session_id: &str) -> Result<(), String> { + self.service + .handle_for_user(user_id) + .await? + .reset_python(session_id.to_string()) + .await + } + /// The subagents still working for a task. A task whose run ended can /// still have a background subagent; this rebuilds the card for it. pub async fn session_subagents( diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 64d16e842..98eaec2d4 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -48,8 +48,8 @@ use self::navigation::ApplicationVimState; use self::sidebar::SessionActivity; use self::sidebar::{Sidebar, SidebarEvent, root_display_name, session_summary_eq}; use self::transcript::{ - ActiveSubagent, PlanEntry, PlanStatus, plan_entries, render_permission_card, - render_question_card, render_waiting_indicator, + ActiveSubagent, PlanEntry, PlanStatus, permission_arguments, plan_entries, + render_permission_card, render_question_card, render_waiting_indicator, }; gpui::actions!( @@ -4101,13 +4101,7 @@ impl ChatScreen { self.apply_timeline_item(session_id, item); } let arguments = serde_json::Value::Object(request.arguments); - let arguments: std::sync::Arc = if arguments.is_null() { - "".into() - } else { - serde_json::to_string_pretty(&arguments) - .unwrap_or_default() - .into() - }; + let arguments = permission_arguments(&request.tool_name, &arguments); let prompt = request .prompt .clone() diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index f4293a861..419c15cff 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -84,6 +84,7 @@ pub(super) struct SidebarRow { pub(super) menu_pin_id: SharedString, pub(super) menu_settle_id: SharedString, pub(super) menu_archive_id: SharedString, + pub(super) menu_python_reset_id: SharedString, pub(super) title: SharedString, /// Display name of the task's project, shown on every row. pub(super) project_name: SharedString, @@ -108,6 +109,7 @@ impl SidebarRow { menu_pin_id: SharedString::from(format!("pin-task-{id}")), menu_settle_id: SharedString::from(format!("settle-task-{id}")), menu_archive_id: SharedString::from(format!("archive-task-{id}")), + menu_python_reset_id: SharedString::from(format!("reset-python-{id}")), title: SharedString::from(session.title.clone()), project_name: SharedString::from(project_name.to_string()), search: session.title.to_lowercase(), @@ -124,6 +126,23 @@ enum SidebarPopup { Task(String), } +/// A menu can close and reopen for the same task before its status read +/// finishes. The request sequence distinguishes those otherwise equal reads. +#[derive(Clone)] +struct PythonMenuRequest { + user_id: String, + session_id: String, + sequence: u64, +} + +impl PythonMenuRequest { + fn is_current(&self, user_id: &str, task_menu: Option<&str>, sequence: u64) -> bool { + self.user_id == user_id + && task_menu == Some(self.session_id.as_str()) + && self.sequence == sequence + } +} + /// One row of the virtualized sidebar list, in display order. Rebuilt /// with the sections and when a section folds; the list builds only the /// rows on screen. @@ -246,6 +265,8 @@ pub(super) struct Sidebar { task_menu: Option, project_menu: Option, menu_trust: Option, + python_menu_sequence: u64, + menu_python_resettable: bool, // Search and rename. filter: String, search_input: Entity, @@ -321,6 +342,8 @@ impl Sidebar { task_menu: None, project_menu: None, menu_trust: None, + python_menu_sequence: 0, + menu_python_resettable: false, filter: String::new(), search_input, rename: None, @@ -622,6 +645,23 @@ impl Sidebar { cx.notify(); } + #[cfg(test)] + pub(super) fn set_python_resettable_for_test(&mut self, resettable: bool) { + self.menu_python_resettable = resettable; + } + + #[cfg(test)] + pub(super) fn task_menu_labels_for_test(&self, session_id: &str) -> Vec<&'static str> { + self.task_menu_entry(session_id) + .map(|task| { + self.task_menu_items(task) + .iter() + .map(|item| item.label) + .collect() + }) + .unwrap_or_default() + } + #[cfg(test)] pub(super) fn set_unsettled_for_test(&mut self, unsettled: HashSet) { self.unsettled_tasks = unsettled; @@ -1228,15 +1268,81 @@ impl Sidebar { /// Open or close the overflow menu of one task row. fn toggle_task_menu(&mut self, session_id: &str, cx: &mut Context) { self.menu_selected = None; + self.menu_python_resettable = false; + self.python_menu_sequence = self.python_menu_sequence.wrapping_add(1); if self.task_menu.as_deref() == Some(session_id) { self.task_menu = None; } else { self.task_menu = Some(session_id.to_string()); + let request = PythonMenuRequest { + user_id: self.user_id.clone(), + session_id: session_id.to_string(), + sequence: self.python_menu_sequence, + }; + let requested = request.clone(); + let backend = self.backend.clone(); + self.call( + async move { + backend + .python_status(&requested.user_id, &requested.session_id) + .await + }, + cx, + move |this, result, cx| { + if !request.is_current( + &this.user_id, + this.task_menu.as_deref(), + this.python_menu_sequence, + ) || !this + .sessions + .iter() + .any(|task| task.id == request.session_id) + { + return; + } + if let Ok(status) = result + && this.menu_python_resettable != status.resettable + { + this.menu_python_resettable = status.resettable; + cx.notify(); + } + }, + ); } cx.notify(); } - /// The overflow menu of a task row: rename, pin, settle, archive. + fn reset_python(&mut self, session_id: &str, cx: &mut Context) { + let Some(session) = self.sessions.iter().find(|task| task.id == session_id) else { + return; + }; + let title = session.title.clone(); + let user_id = self.user_id.clone(); + let task_id = session_id.to_string(); + let requested_user = user_id.clone(); + let requested_task = task_id.clone(); + let backend = self.backend.clone(); + self.task_menu = None; + self.menu_python_resettable = false; + cx.notify(); + self.call( + async move { backend.reset_python(&requested_user, &requested_task).await }, + cx, + move |this, result, cx| { + if this.user_id != user_id || !this.sessions.iter().any(|task| task.id == task_id) { + return; + } + let message = match result { + Ok(()) => format!("Python reset for “{title}”. The next call starts fresh."), + Err(error) => format!("Could not reset Python: {error}"), + }; + cx.emit(SidebarEvent::Notice(message.into())); + }, + ); + } + + /// The overflow menu also offers explicit reclamation of retained Python + /// state. The backend resolves current authority and state at click time. fn task_menu_items(&self, task: SidebarTaskEntry) -> Vec { let Some(row) = self.rows.get(task.session) else { return Vec::new(); @@ -1248,7 +1354,7 @@ impl Sidebar { let pinned = task.pinned; let archived = task.archived; let active = !archived && (self.running.contains(&*row.id) || !task.settled); - vec![ + let mut items = vec![ SidebarMenuItem { id: row.menu_rename_id.clone(), icon: "pencil", @@ -1300,7 +1406,17 @@ impl Sidebar { cx.notify(); }), }, - ] + ]; + if self.menu_python_resettable && self.task_menu.as_deref() == Some(row.id.as_ref()) { + let python_id = row.id.to_string(); + items.push(SidebarMenuItem { + id: row.menu_python_reset_id.clone(), + icon: "undo-2", + label: "Reset Python", + on_click: Box::new(move |this, cx| this.reset_python(&python_id, cx)), + }); + } + items } /// The overflow menu of one switcher project row. @@ -2598,3 +2714,23 @@ pub(super) fn root_display_name(root: &str) -> String { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| root.to_string()) } + +#[cfg(test)] +mod python_menu_tests { + use super::*; + + #[test] + fn status_reply_requires_the_same_account_task_and_menu_opening() { + let request = PythonMenuRequest { + user_id: "account-a".into(), + session_id: "task-a".into(), + sequence: 7, + }; + assert!(request.is_current("account-a", Some("task-a"), 7)); + assert!(!request.is_current("account-b", Some("task-a"), 7)); + assert!(!request.is_current("account-a", Some("task-b"), 7)); + assert!(!request.is_current("account-a", None, 7)); + // Closing and reopening the same task must reject the earlier read. + assert!(!request.is_current("account-a", Some("task-a"), 9)); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 3001be693..b73844714 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1958,6 +1958,31 @@ mod state_tests { }); } + #[gpui::test] + fn test_retained_python_is_resettable_on_a_settled_task(cx: &mut TestAppContext) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![summary("s1", "Retained scratchpad")]; + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.settle_task("s1", cx); + assert!(sidebar.settled_tasks().contains("s1")); + sidebar.open_task_menu_for_test("s1", cx); + assert!( + !sidebar + .task_menu_labels_for_test("s1") + .contains(&"Reset Python") + ); + sidebar.set_python_resettable_for_test(true); + assert!( + sidebar + .task_menu_labels_for_test("s1") + .contains(&"Reset Python") + ); + }); + }); + } + /// Application Vim drives the switcher popup: a count prefix reaches /// a project row and Enter scopes the sidebar to it. #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 3494bd4bf..24c6d123e 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -826,6 +826,7 @@ fn render_tool_with_diff( pub(super) fn tool_label_title(title: &str) -> &str { const LABELS: &[&str] = &[ "Terminal", + "Python", "Subagent", "Load", "Editor", @@ -935,8 +936,8 @@ fn render_tool( let derived = transcript.derived.get(item, revision); // A click anywhere on the card, payload included, toggles it. let mut payload = div().flex().flex_col().gap_1(); - // Expanded: the call arguments and, until a summary exists, the raw - // output. + // Python retains its result details after summarization: the namespace, + // traceback, and output-loss notices remain inspectable. if let Some(input) = &derived.input_line { payload = payload.child( div() @@ -947,7 +948,9 @@ fn render_tool( .child(input.clone()), ); } - if !has_summary && let Some(output) = &derived.output_text { + if tool_output_visible(item, has_summary) + && let Some(output) = &derived.output_text + { payload = payload.child( div() .mt_1() @@ -965,6 +968,43 @@ fn render_tool( div().child(card.child(payload)) } +fn tool_output_visible(item: &AgentTimelineItem, has_summary: bool) -> bool { + !has_summary + || item + .output + .as_ref() + .and_then(|output| output.pointer("/structuredContent/maple_python/version")) + .and_then(serde_json::Value::as_u64) + == Some(1) +} + +/// Approval has no result marker yet. Match the Maple tool identity rather +/// than recognizing arbitrary extensions whose names happen to end in Python. +fn is_python_tool(tool_name: &str) -> bool { + matches!(tool_name, "python_code" | "developer__python_code") +} + +/// Prepare arguments on request arrival. Python source is literal multiline +/// text, including the final line; the scrollable card never substitutes an +/// abbreviated excerpt for the code being approved. +pub(super) fn permission_arguments(tool_name: &str, arguments: &serde_json::Value) -> Arc { + if arguments.is_null() { + return "".into(); + } + if is_python_tool(tool_name) { + let formatted = format_tool_input(arguments); + if arguments.get("reset").is_none() { + format!("reset: false\n{formatted}").into() + } else { + formatted.into() + } + } else { + serde_json::to_string_pretty(arguments) + .unwrap_or_default() + .into() + } +} + /// Readable form of the call arguments: one `key: value` line per /// field, strings shown as-is, nested values as pretty JSON. pub(super) fn tool_input_line(item: &AgentTimelineItem) -> Option { @@ -1345,15 +1385,22 @@ pub(super) fn render_permission_card( .child(description), ); if !arguments.is_empty() { - card = card.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .font_family(crate::assets::FONT_MONO) - .max_h(gpui::px(120.)) - .overflow_hidden() - .child(arguments), - ); + let payload = div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .font_family(crate::assets::FONT_MONO) + .max_h(gpui::px(120.)) + .child(arguments); + card = if is_python_tool(&permission.tool_name) { + card.child( + payload + .id(SharedString::from(permission.request_id.clone())) + .overflow_scroll() + .whitespace_nowrap(), + ) + } else { + card.child(payload.overflow_hidden()) + }; } let mut buttons = div().flex().gap_2(); for (index, (id, label, allow, color)) in [ @@ -1407,3 +1454,76 @@ pub(super) fn render_permission_card( } card.child(buttons) } + +#[cfg(test)] +mod python_presentation_tests { + use super::*; + use serde_json::json; + + #[test] + fn approval_preserves_multiline_source_and_explicit_reset() { + let source = format!("{}\nprint('final Ω line')", "value = 1\n".repeat(12_000)); + for name in ["python_code", "developer__python_code"] { + let arguments = permission_arguments(name, &json!({"code": source, "reset": true})); + assert!(arguments.contains(&source)); + assert!(arguments.contains("reset: true")); + assert!(!arguments.contains("\\n")); + assert!(arguments.contains("print('final Ω line')")); + } + let defaults = permission_arguments("python_code", &json!({"code": "40 + 2"})); + assert!(defaults.contains("reset: false")); + assert!(defaults.contains("code: 40 + 2")); + } + + #[test] + fn approval_formatting_is_specific_to_maples_python_tool() { + let arguments = json!({"code": "first\nsecond", "reset": false}); + for name in ["shell", "other__python_code", "python_code_extra"] { + assert_eq!( + permission_arguments(name, &arguments).as_ref(), + serde_json::to_string_pretty(&arguments).unwrap() + ); + } + // A malformed reset value stays inspectable rather than appearing + // to be the valid default that the user did not submit. + assert!( + permission_arguments("python_code", &json!({"code": "42", "reset": "invalid"})) + .contains("reset: invalid") + ); + } + + #[test] + fn python_results_remain_inspectable_after_summary_without_changing_other_tools() { + let mut item = AgentTimelineItem { + id: "call".into(), + item_type: "tool".into(), + role: None, + title: Some("Python: print('hello')".into()), + text: None, + status: Some("completed".into()), + input: Some(json!({"code": "print('hello')", "reset": false})), + output: None, + created_ms: 0, + merge: "replace".into(), + }; + assert_eq!(tool_label_title(item.title.as_deref().unwrap()), "Python"); + assert!(tool_output_visible(&item, false)); + assert!(!tool_output_visible(&item, true)); + for kind in ["execution", "reset", "error"] { + item.output = Some(json!({ + "text": "Traceback\nValueError: invalid\nOutput truncated; Python state was lost.", + "structuredContent": {"maple_python": {"version": 1, "kind": kind}} + })); + assert!(tool_output_visible(&item, true)); + assert!( + tool_output_markdown(&item) + .unwrap() + .contains("Python state was lost.") + ); + } + item.output = Some(json!({"structuredContent": {"maple_python": {"version": 2}}})); + assert!(!tool_output_visible(&item, true)); + item.output = Some(json!({"text": "maple_python version 1"})); + assert!(!tool_output_visible(&item, true)); + } +} diff --git a/apps/maple-agent/crates/maple-agent/Cargo.toml b/apps/maple-agent/crates/maple-agent/Cargo.toml index 292acbc7b..41d42f1a4 100644 --- a/apps/maple-agent/crates/maple-agent/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent/Cargo.toml @@ -11,6 +11,7 @@ default = ["acp"] acp = ["dep:agent-client-protocol"] [dependencies] +maple-code-mode = { path = "../maple-code-mode" } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -23,8 +24,8 @@ once_cell = { workspace = true } http = { workspace = true } reqwest = { workspace = true } chrono = { version = "0.4", default-features = false, features = ["clock"] } -goose = { git = "https://github.com/AnthonyRonning/goose.git", rev = "785d655d110746147117d23690e09cc7023aa9dc", package = "goose", default-features = false } -goose-providers = { git = "https://github.com/AnthonyRonning/goose.git", rev = "785d655d110746147117d23690e09cc7023aa9dc", package = "goose-providers", default-features = false } +goose = { git = "https://github.com/AnthonyRonning/goose.git", rev = "bf62027fd499ad3133af35db9f0f566de6a5cc4c", package = "goose", default-features = false } +goose-providers = { git = "https://github.com/AnthonyRonning/goose.git", rev = "bf62027fd499ad3133af35db9f0f566de6a5cc4c", package = "goose-providers", default-features = false } maple-sdk = { workspace = true } maple-proxy = { workspace = true } rand = "0.8.6" diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index ca56a0160..151b1c6cc 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -6,6 +6,7 @@ mod attachments; #[cfg(target_os = "macos")] mod bounded_process; +mod code_mode; #[cfg(embedded_cua)] mod cua; mod developer_tools; @@ -33,6 +34,7 @@ mod web_tools; use crate::maple_api::{MapleApiSession, account_scope}; pub use attachments::AgentImageUpload; use attachments::{AgentAttachmentStore, AgentImageAttachment, PreparedAgentImage}; +use code_mode::PythonTaskBinding; #[cfg(test)] use developer_tools::EXTERNAL_MCP_TOOL_NAME; use developer_tools::MapleDeveloperClient; @@ -121,9 +123,10 @@ pub fn begin_integration_setup( } } #[cfg(test)] -const MAPLE_DEVELOPER_TOOLS: [&str; 10] = [ +const MAPLE_DEVELOPER_TOOLS: [&str; 11] = [ "read", "shell", + "python_code", "edit", "write", "read_image", @@ -160,6 +163,7 @@ const MAPLE_GOOSE_PERMISSION_CONFIG: &str = r#"user: - delegate - read - shell + - python_code - edit - write - read_image @@ -367,6 +371,28 @@ struct InstalledAgentToolContext { installation_id: u64, context: SharedAgentToolContext, owner: AgentToolContextOwner, + python: Option>, +} + +impl InstalledAgentToolContext { + /// Close admission synchronously; observing cleanup never holds Maple's locks. + fn retire_python(&self, reason: &str) { + if let Some(binding) = &self.python { + let cleanup = binding.retire(reason.to_string()); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + if let Err(error) = cleanup.await { + log::warn!("Python worker cleanup remains pending: {error}"); + } + }); + } + } + } + + fn revoke(&self, reason: &str) { + self.context.revoke(); + self.retire_python(reason); + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -629,11 +655,190 @@ fn resolve_session_tool_context( installation_id: next_tool_context_installation_id(), context: context.clone(), owner: AgentToolContextOwner::Maple, + python: None, }, ); Ok(context) } +fn python_task_key(account_scope: &str, session_id: &str) -> String { + // The existing context installation fences authority. A stable task key also + // keeps replacement owners behind this task's exact process cleanup barrier. + format!("{account_scope}:{session_id}") +} + +fn resolve_session_python( + service: &MapleAgentService, + runtime: &mut AgentRuntime, + session: &Session, + context: &SharedAgentToolContext, + routing: AgentPermissionRouting, +) -> Result, String> { + let installed = runtime + .session_tool_contexts + .get_mut(&session.id) + .filter(|installed| installed.context.ptr_eq(context) && !context.is_revoked()) + .ok_or_else(|| AGENT_TOOL_CONTEXT_INACTIVE_ERROR.to_string())?; + if let Some(binding) = &installed.python { + if !binding.matches_root(&session.working_dir) { + return Err("Python task root does not match the persisted Agent task".to_string()); + } + if !binding.is_closed() { + return Ok(Arc::clone(binding)); + } + } + let state_loss_reason = installed + .python + .as_ref() + .and_then(|binding| binding.status().state_loss_reason) + .or_else(|| { + (session.message_count > 0).then(|| { + "This Python owner starts fresh. Any previous scratchpad state is not restored after ownership changes or Maple restarts.".to_string() + }) + }); + let diagnostics = python_capacity_diagnostics( + Arc::downgrade(&service.inner), + runtime.account_scope.clone(), + session.id.clone(), + installed.installation_id, + context.clone(), + runtime.lifetime.clone(), + Arc::clone(&runtime.session_manager), + routing, + ); + let binding = Arc::new( + PythonTaskBinding::new( + service.python_runtime.clone(), + python_task_key(&runtime.account_scope, &session.id), + session.id.clone(), + session.working_dir.clone(), + context.clone(), + ) + .with_capacity_diagnostics(diagnostics) + .with_state_loss_reason(state_loss_reason), + ); + installed.python = Some(Arc::clone(&binding)); + Ok(binding) +} + +#[allow(clippy::too_many_arguments)] +fn python_capacity_diagnostics( + runtime: Weak>>, + account_scope: String, + session_id: String, + installation_id: u64, + context: SharedAgentToolContext, + lifetime: CancellationToken, + session_manager: Arc, + routing: AgentPermissionRouting, +) -> code_mode::CapacityDiagnostics { + Arc::new(move |holders| { + let runtime = runtime.clone(); + let account_scope = account_scope.clone(); + let session_id = session_id.clone(); + let context = context.clone(); + let lifetime = lifetime.clone(); + let session_manager = Arc::clone(&session_manager); + Box::pin(async move { + let mut accessible = HashMap::new(); + if !context.is_revoked() + && !lifetime.is_cancelled() + && let Some(runtime) = runtime.upgrade() + { + let guard = runtime.lock().await; + if let Some(current) = guard.as_ref() + && current.account_scope == account_scope + && Arc::ptr_eq(¤t.session_manager, &session_manager) + && current + .session_tool_contexts + .get(&session_id) + .is_some_and(|installed| { + installed.installation_id == installation_id + && installed.context.ptr_eq(&context) + }) + { + for (id, installed) in ¤t.session_tool_contexts { + // ACP exposes an exact task capability, not authority to + // inspect another connection's tasks in the same account. + if routing == AgentPermissionRouting::CallingSurface && id != &session_id { + continue; + } + if let Some(binding) = &installed.python { + accessible + .insert(binding.key().to_string(), (id.clone(), installed.owner)); + } + } + } + } + let mut named = Vec::new(); + for holder in holders.iter().take(maple_code_mode::MAX_WORKERS) { + if let Some((id, owner)) = accessible.get(&holder.key) + && let Ok(session) = session_manager.get_session(id, false).await + { + named.push((session.name, *owner, holder.phase.clone())); + } + } + // Metadata I/O ran outside lifecycle locks; revoked owners must not + // receive names from the account that just stopped or changed. + if context.is_revoked() || lifetime.is_cancelled() { + named.clear(); + } + format_python_capacity(holders.len(), named, routing) + }) + }) +} + +fn format_python_capacity( + count: usize, + named: Vec<(String, AgentToolContextOwner, maple_code_mode::WorkerPhase)>, + routing: AgentPermissionRouting, +) -> String { + let inaccessible = count.saturating_sub(named.len()); + let mut message = format!( + "All {} Python worker slots are occupied.", + maple_code_mode::MAX_WORKERS + ); + for (title, owner, phase) in named { + let title = title + .chars() + .take(MAX_AGENT_SESSION_TITLE_CHARS) + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + let phase = match phase { + maple_code_mode::WorkerPhase::Empty => "empty", + maple_code_mode::WorkerPhase::Starting => "starting", + maple_code_mode::WorkerPhase::Idle => "idle", + maple_code_mode::WorkerPhase::Executing => "executing", + maple_code_mode::WorkerPhase::Retiring => "retiring", + maple_code_mode::WorkerPhase::CleanupPending => "cleanup pending", + }; + let remedy = if owner == AgentToolContextOwner::Leased { + "its owning client can close the session or connection" + } else { + "finish or Stop its active run, then use Reset Python" + }; + message.push_str(&format!("\n- {title}: {phase}; {remedy}.")); + } + if inaccessible > 0 { + message.push_str(&format!("\n{inaccessible} slot(s) belong to other or retired owners; their task details are unavailable.")); + } + if routing == AgentPermissionRouting::CallingSurface { + message.push_str("\nIn a session that owns a worker, call python_code with {\"code\":\"\",\"reset\":true}, or have its caller use session/close or close the connection. Idle session/cancel does not free a worker."); + } else { + message.push_str( + "\nUse Reset Python on an idle task, or close the owning external session/connection.", + ); + } + message.push_str(" Slots become available only after cleanup completes. No task was evicted."); + message +} + impl AgentRuntime { fn desktop_status(&self) -> AgentRuntimeStatus { AgentRuntimeStatus { @@ -797,6 +1002,8 @@ pub struct MapleAgentService { /// Routes ask_user questions to the UI and answers back. host: MapleAgentHostResources, inner: Arc>>, + /// Process owners and permits survive replacement of the account runtime. + python_runtime: maple_code_mode::Runtime, runtime_lifecycle: Arc>, #[cfg(target_os = "macos")] login_shell_search_paths: Arc>>, @@ -890,6 +1097,7 @@ impl MapleAgentService { host, questions, inner: Arc::new(Mutex::new(None)), + python_runtime: maple_code_mode::Runtime::new(Default::default()), runtime_lifecycle: Arc::new(Mutex::new(())), #[cfg(target_os = "macos")] login_shell_search_paths: Arc::new(tokio::sync::OnceCell::new()), @@ -2019,7 +2227,7 @@ async fn stop_runtime_inner( }; for installed in tool_contexts.into_values() { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } { let mut queues = state.desktop_queues.lock().await; @@ -2248,7 +2456,14 @@ async fn start_runtime_for_user( true, GoosePlatform::GooseDesktop, ) - .with_use_login_shell_path(true); + .with_use_login_shell_path(true) + .with_ordered_tool_calls( + [ + "python_code".to_string(), + "developer__python_code".to_string(), + ], + code_mode::MAX_PYTHON_BATCH_CALLS, + ); let agent_manager = Arc::new( AgentManager::new(goose_config, None) .await @@ -2543,6 +2758,18 @@ impl AgentRuntimeHandle { let mut config = load_agent_config_inner(&state.host.paths, &self.user_id) .map_err(|error| error.to_string())?; apply_project_root_removal(&mut config, &path, fallback_path.as_deref())?; + { + let runtime = state.inner.lock().await; + if let Some(current) = runtime.as_ref() { + for (session_id, installed) in ¤t.session_tool_contexts { + if session_roots.get(session_id) == Some(&path) { + installed.retire_python( + "Python state was retired when this project was removed", + ); + } + } + } + } // The tombstone is the only persistent removal state. Saving the fallback // into roaming config would let this device's removal alter another // device. Runtime/UI use the fallback immediately; startup filters the @@ -2904,6 +3131,7 @@ impl AgentRuntimeHandle { installation_id, context: tool_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }); None } @@ -3003,6 +3231,7 @@ impl AgentRuntimeHandle { mode: &mode, primary_model_supports_vision: false, tool_context: &tool_context, + python_binding: None, allow_embedded_cua: !has_external_tool_context, }, ) @@ -3284,9 +3513,10 @@ impl AgentRuntimeHandle { installation_id, context: tool_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ) { - replaced.context.revoke(); + replaced.revoke("Python task ownership changed"); } } tool_context_installation.arm_lease_cleanup(state.clone(), tool_context_access.clone()); @@ -3380,6 +3610,7 @@ impl AgentRuntimeHandle { mode: &mode, primary_model_supports_vision: false, tool_context: &tool_context, + python_binding: None, allow_embedded_cua: false, }, ) @@ -3874,6 +4105,110 @@ impl AgentRuntimeHandle { Ok(summary) } + /// A small asynchronous snapshot for the existing task menu. Execution and + /// cleanup remain task-owned; polling this never resolves or starts Python. + pub async fn python_status(&self, session_id: String) -> Result { + let state = &self.service; + let _runtime_lifecycle = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let _session_lifecycle = state.session_lifecycle.lock().await; + let (session_manager, resettable) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &self.account_scope)?; + let resettable = current + .session_tool_contexts + .get(&session_id) + .filter(|installed| installed.owner == AgentToolContextOwner::Maple) + .and_then(|installed| installed.python.as_ref()) + .is_some_and(|binding| binding.status().generation.is_some()); + (Arc::clone(¤t.session_manager), resettable) + } + None => ( + account_session_manager(&state.host.paths, &self.user_id)?, + false, + ), + } + }; + session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to find Agent task: {error}"))?; + Ok(AgentPythonStatus { resettable }) + } + + /// Reset the task's current Python state, not a menu-time generation. + /// Success confirms cleanup; all process waits happen outside lifecycle locks. + pub async fn reset_python(&self, session_id: String) -> Result<(), String> { + let cleanup = { + let state = &self.service; + let _runtime_lifecycle = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let _session_lifecycle = state.session_lifecycle.lock().await; + let (session_manager, agent_manager) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &self.account_scope)?; + if has_active_session_run(¤t.active_runs, &session_id) { + return Err( + "Stop the running agent before resetting Python".to_string() + ); + } + if current + .session_tool_contexts + .get(&session_id) + .is_some_and(|installed| { + installed.owner == AgentToolContextOwner::Leased + }) + { + return Err("This task is controlled by another Agent surface; close its session or reset Python through that surface".to_string()); + } + ( + Arc::clone(¤t.session_manager), + Some(Arc::clone(¤t.agent_manager)), + ) + } + None => ( + account_session_manager(&state.host.paths, &self.user_id)?, + None, + ), + } + }; + session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to find Agent task: {error}"))?; + // Goose claims this token before Maple releases its locks for MCP + // preparation, earlier than insertion into current.active_runs. + if let Some(manager) = agent_manager + && manager.is_session_busy(&session_id).await + { + return Err( + "Stop or finish the preparing agent run before resetting Python".to_string(), + ); + } + let runtime = state.inner.lock().await; + runtime + .as_ref() + .and_then(|current| current.session_tool_contexts.get(&session_id)) + .and_then(|installed| installed.python.as_ref()) + .map(|binding| { + if binding.is_closed() { + binding.retire("Python was reset by the user") + } else { + binding.reset("Python was reset by the user") + } + }) + }; + if let Some(cleanup) = cleanup { + cleanup.await.map_err(|error| error.to_string())?; + } + Ok(()) + } + /// Archive or restore a task. Archived tasks keep their history and /// stay listed with `archived` set, so the UI can show them apart. pub async fn set_session_archived( @@ -3909,6 +4244,18 @@ impl AgentRuntimeHandle { .get_session(&session_id, false) .await .map_err(|error| format!("Failed to load Agent task before archiving: {error}"))?; + if archived { + let runtime = state.inner.lock().await; + if let Some(installed) = runtime + .as_ref() + .and_then(|current| current.session_tool_contexts.get(&session_id)) + { + // Preparation releases these lifecycle locks before becoming + // an active run. Retire its captured Python capability now, + // even if the following metadata write fails or is cancelled. + installed.retire_python("Python state was retired when this task was archived"); + } + } if current_session.archived_at.is_some() == archived { return Ok(session_summary(¤t_session)); } @@ -4240,6 +4587,10 @@ impl AgentRuntimeHandle { .to_string(), ); } + if let Some(installed) = current.session_tool_contexts.get(&session_id) { + installed + .retire_python("Python state was retired when this task was deleted"); + } ( Some(Arc::clone(¤t.agent_manager)), Arc::clone(¤t.session_manager), @@ -4301,7 +4652,7 @@ impl AgentRuntimeHandle { } }; if let Some(installed) = removed_tool_context { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } Ok(()) @@ -5000,6 +5351,14 @@ impl AgentRuntimeHandle { )? } }; + let python_binding = { + let mut runtime = state.inner.lock().await; + let current = runtime + .as_mut() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, account_scope)?; + resolve_session_python(state, current, &session, &tool_context, permission_routing)? + }; let should_name_from_prompt = should_name_session_from_prompt(&session); if should_name_from_prompt { session_manager @@ -5067,6 +5426,9 @@ impl AgentRuntimeHandle { if cancel_token.is_cancelled() { return Err(MCP_STARTUP_CANCELLED_ERROR.to_string()); } + if python_binding.is_closed() || tool_context.is_revoked() { + return Err("Agent task ownership changed while preparing Python tools".to_string()); + } let (agent, mcp_errors) = finish_session_agent( prepared, AgentSkillsScope { @@ -5083,6 +5445,7 @@ impl AgentRuntimeHandle { mode: &effective_mode, primary_model_supports_vision: request.vision_capable, tool_context: &tool_context, + python_binding: Some(&python_binding), allow_embedded_cua: permission_routing == AgentPermissionRouting::Desktop, }, ) @@ -7822,15 +8185,10 @@ fn maple_skills_extension_config() -> ExtensionConfig { /// subagent it starts inherits this task's provider and its enabled MCP /// servers. /// -/// KNOWN ISSUE: a subagent does not inherit the task's permission mode. -/// Goose hard-codes `GooseMode::Auto` for every subagent (summon.rs: -/// an approval mode would hang on the subagent's `confirmation_rx`, -/// because subagent `ActionRequired` messages are not forwarded to the -/// parent). So a subagent runs every tool without approval, even when -/// the task is in Read only mode. Fixing this needs the aaif-goose fork -/// to forward subagent approvals; until then `delegate` sits in -/// `ask_before` in `MAPLE_GOOSE_PERMISSION_CONFIG`, so Read only mode -/// prompts before each hand-off. +/// The pinned Goose fork forwards child ActionRequired messages and inherits +/// the parent's permission mode. Child tools are constructed by Goose's own +/// factory; the parent's injected Maple developer client and its task-bound +/// Python capability are not copied. Delegation remains ask-before in Maple. fn maple_subagent_extension_config() -> ExtensionConfig { ExtensionConfig::Platform { name: SUMMON_EXTENSION_NAME.to_string(), @@ -7965,6 +8323,7 @@ struct SessionAgentConfiguration<'a> { mode: &'a str, primary_model_supports_vision: bool, tool_context: &'a SharedAgentToolContext, + python_binding: Option<&'a Arc>, /// True only while a task is being driven by Maple's desktop surface. /// A cached User session may later be leased by ACP, so SessionType alone /// is not a sufficient host-process capability check. @@ -8318,6 +8677,7 @@ async fn finish_session_agent( mode, primary_model_supports_vision, tool_context, + python_binding, allow_embedded_cua, } = configuration; let PreparedSessionAgent { @@ -8355,7 +8715,7 @@ async fn finish_session_agent( skills_scope.paths, skills_scope.user_id, )?); - let developer_client = MapleDeveloperClient::new( + let mut developer_client = MapleDeveloperClient::new( developer_context, primary_model_supports_vision, web_transport, @@ -8366,6 +8726,9 @@ async fn finish_session_agent( .with_attachment_store(attachment_store) .with_web_enabled(session_web_enabled(session)) .with_desktop_ui_tools(session.session_type != SessionType::Acp); + if let Some(binding) = python_binding { + developer_client = developer_client.with_python_binding(Arc::clone(binding)); + } agent .extension_manager .add_client( @@ -10693,6 +11056,350 @@ mod tests { value } + async fn python_lifecycle_fixture( + label: &str, + ) -> ( + PathBuf, + AgentRuntimeHandle, + Session, + SharedAgentToolContext, + Arc, + ) { + let (root, service, manager, project_root, scope) = + tool_context_cleanup_test_context(label).await; + let session = manager + .create_session( + project_root, + "Python lifecycle".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + let (context, binding) = { + let mut guard = service.inner.lock().await; + let runtime = guard.as_mut().unwrap(); + let context = resolve_session_tool_context( + &mut runtime.session_tool_contexts, + &scope, + &session.id, + None, + &AgentToolContextSpec::default(), + ) + .unwrap(); + let binding = resolve_session_python( + &service, + runtime, + &session, + &context, + AgentPermissionRouting::Desktop, + ) + .unwrap(); + (context, binding) + }; + let handle = service + .handle_for_user(&format!("{label}-user")) + .await + .unwrap(); + (root, handle, session, context, binding) + } + + #[tokio::test] + async fn python_binding_is_retained_across_runs_and_rejects_root_changes() { + let (root, handle, session, context, original) = + python_lifecycle_fixture("python-reuse").await; + { + let mut guard = handle.service.inner.lock().await; + let runtime = guard.as_mut().unwrap(); + let second = resolve_session_python( + &handle.service, + runtime, + &session, + &context, + AgentPermissionRouting::Desktop, + ) + .unwrap(); + assert!(Arc::ptr_eq(&original, &second)); + let mut changed = session.clone(); + changed.working_dir = root.join("another-root"); + assert!( + resolve_session_python( + &handle.service, + runtime, + &changed, + &context, + AgentPermissionRouting::Desktop, + ) + .is_err() + ); + } + assert!(!context.is_revoked()); + assert!(handle.service.python_runtime.snapshot().holders.is_empty()); + assert!( + !handle + .python_status(session.id.clone()) + .await + .unwrap() + .resettable + ); + handle.reset_python(session.id).await.unwrap(); + assert!( + !original.is_closed(), + "empty reset preserves the logical task binding" + ); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_archive_fences_prepared_binding_and_allows_later_fresh_use() { + let (root, handle, session, context, original) = + python_lifecycle_fixture("python-archive").await; + let summary = handle + .set_session_archived(session.id.clone(), true) + .await + .unwrap(); + assert!(summary.archived); + assert!(original.is_closed()); + assert!( + !context.is_revoked(), + "archive must preserve the surrounding tool context" + ); + let replacement = { + let mut guard = handle.service.inner.lock().await; + resolve_session_python( + &handle.service, + guard.as_mut().unwrap(), + &session, + &context, + AgentPermissionRouting::Desktop, + ) + .unwrap() + }; + assert!(!Arc::ptr_eq(&original, &replacement)); + assert!(!replacement.is_closed()); + assert_eq!(original.key(), replacement.key()); + assert!( + replacement + .status() + .state_loss_reason + .unwrap() + .contains("archived") + ); + assert!(handle.service.python_runtime.snapshot().holders.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_reset_rejects_preparing_run_and_external_lease() { + let (root, handle, session, _context, original) = + python_lifecycle_fixture("python-reset-authority").await; + let manager = Arc::clone( + &handle + .service + .inner + .lock() + .await + .as_ref() + .unwrap() + .agent_manager, + ); + manager + .try_register_cancel_token(&session.id, CancellationToken::new()) + .await + .unwrap(); + let error = handle.reset_python(session.id.clone()).await.unwrap_err(); + assert!(error.contains("preparing")); + assert!(!original.is_closed()); + manager.unregister_cancel_token(&session.id).await; + { + let mut guard = handle.service.inner.lock().await; + guard + .as_mut() + .unwrap() + .session_tool_contexts + .get_mut(&session.id) + .unwrap() + .owner = AgentToolContextOwner::Leased; + } + let error = handle.reset_python(session.id.clone()).await.unwrap_err(); + assert!(error.contains("another Agent surface")); + assert!(!handle.python_status(session.id).await.unwrap().resettable); + assert!(handle.service.python_runtime.snapshot().holders.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_project_removal_fences_only_affected_prepared_bindings() { + let (root, handle, session, context, original) = + python_lifecycle_fixture("python-project-removal").await; + handle + .remove_project_root(path_string(&session.working_dir), None) + .await + .unwrap(); + assert!(original.is_closed()); + assert!(!context.is_revoked()); + assert!( + original + .status() + .state_loss_reason + .unwrap() + .contains("project") + ); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_menu_reset_confirms_native_cleanup_and_completed_run_stop_preserves_state() { + let (root, handle, session, context, _original) = + python_lifecycle_fixture("python-native-menu").await; + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + let package = maple_code_mode::PackagedPython::from_manifest(manifest) + .expect("run `just python-prepare` before native Python tests"); + let binding = Arc::new( + PythonTaskBinding::new( + handle.service.python_runtime.clone(), + python_task_key(&handle.account_scope, &session.id), + session.id.clone(), + session.working_dir.clone(), + context.clone(), + ) + .with_packaged_python(package), + ); + handle + .service + .inner + .lock() + .await + .as_mut() + .unwrap() + .session_tool_contexts + .get_mut(&session.id) + .unwrap() + .python = Some(Arc::clone(&binding)); + let call = + ToolCallContext::new(session.id.clone(), Some(session.working_dir.clone()), None); + let run = CancellationToken::new(); + let first = binding + .call( + code_mode::PythonParams { + code: "answer = 42\nanswer".into(), + reset: false, + }, + &call, + async { None }, + run.clone(), + ) + .await; + assert_ne!(first.is_error, Some(true), "{first:?}"); + assert!( + handle + .python_status(session.id.clone()) + .await + .unwrap() + .resettable + ); + context.cancel_run(&run); + let second = binding + .call( + code_mode::PythonParams { + code: "answer".into(), + reset: false, + }, + &call, + async { panic!("retained worker must not probe PATH") }, + CancellationToken::new(), + ) + .await; + assert_ne!(second.is_error, Some(true), "{second:?}"); + assert_eq!( + second.structured_content.unwrap()["maple_python"]["value"], + "42" + ); + handle.reset_python(session.id.clone()).await.unwrap(); + assert!(!handle.python_status(session.id).await.unwrap().resettable); + assert!( + handle.service.python_runtime.snapshot().holders.is_empty(), + "menu success requires released capacity" + ); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_capacity_callback_hides_foreign_and_revoked_task_metadata() { + let (root, handle, session, context, binding) = + python_lifecycle_fixture("python-capacity-auth").await; + let diagnostics = { + let guard = handle.service.inner.lock().await; + let current = guard.as_ref().unwrap(); + python_capacity_diagnostics( + Arc::downgrade(&handle.service.inner), + handle.account_scope.to_string(), + session.id.clone(), + current.session_tool_contexts[&session.id].installation_id, + context.clone(), + current.lifetime.clone(), + Arc::clone(¤t.session_manager), + AgentPermissionRouting::Desktop, + ) + }; + let holders = vec![ + maple_code_mode::Holder { + key: binding.key().into(), + generation: 1, + phase: maple_code_mode::WorkerPhase::Idle, + }, + maple_code_mode::Holder { + key: "foreign-account:private-title".into(), + generation: 2, + phase: maple_code_mode::WorkerPhase::CleanupPending, + }, + ]; + let visible = diagnostics(holders.clone()).await; + assert!(visible.contains("Python lifecycle: idle")); + assert!(!visible.contains("private-title")); + assert!(visible.contains("1 slot(s)")); + context.revoke(); + let revoked = diagnostics(holders).await; + assert!(!revoked.contains("Python lifecycle")); + assert!(!revoked.contains("private-title")); + assert!(revoked.contains("2 slot(s)")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn python_capacity_guidance_names_authorized_holders_and_counts_the_rest() { + let message = format_python_capacity( + 4, + vec![ + ( + "Desktop task".into(), + AgentToolContextOwner::Maple, + maple_code_mode::WorkerPhase::Idle, + ), + ( + "ACP task".into(), + AgentToolContextOwner::Leased, + maple_code_mode::WorkerPhase::CleanupPending, + ), + ], + AgentPermissionRouting::Desktop, + ); + assert!(message.contains("Desktop task: idle")); + assert!(message.contains("Reset Python")); + assert!(message.contains("ACP task: cleanup pending")); + assert!(message.contains("owning client can close")); + assert!(message.contains("2 slot(s) belong to other or retired owners")); + let acp = format_python_capacity(4, Vec::new(), AgentPermissionRouting::CallingSurface); + assert!(!acp.contains("Desktop task")); + assert!(acp.contains("session/close")); + assert!(acp.contains("\"reset\":true")); + } + fn test_project_path(label: &str) -> String { std::env::temp_dir() .join(format!("maple-agent-project-{label}")) @@ -14127,12 +14834,8 @@ mod tests { Some(goose::config::permission::PermissionLevel::AlwaysAllow) ); for tool in MAPLE_SUBAGENT_TOOLS { - // KNOWN ISSUE: a subagent runs with every tool approved, - // whatever the task's mode (see the note on - // maple_subagent_extension_config). Until the fork forwards - // subagent approvals, the hand-off itself is the only approval - // boundary: `delegate` prompts before the subagent runs, while - // `load` only collects a finished result. + // Delegation is an explicit admission boundary; load only + // collects a result. The pinned fork forwards child approvals. let expected = if tool == "delegate" { goose::config::permission::PermissionLevel::AskBefore } else { @@ -16326,6 +17029,7 @@ mod tests { installation_id: 2, context: replacement.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, )]); @@ -16357,6 +17061,7 @@ mod tests { installation_id: 7, context: leased.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, )]); let access = AgentToolContextAccess { @@ -16506,6 +17211,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let lease = AgentToolContextLease { @@ -16602,6 +17308,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let mut pending = PendingAgentToolContextInstallation::new(context.clone()); @@ -16710,6 +17417,7 @@ mod tests { installation_id: untouched_installation_id, context: untouched_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); contexts.insert( @@ -16718,6 +17426,7 @@ mod tests { installation_id: modified_installation_id, context: modified_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); } @@ -16833,6 +17542,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let mut pending = PendingAgentToolContextInstallation::new(context.clone()); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs b/apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs new file mode 100644 index 000000000..15f1395c8 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs @@ -0,0 +1,872 @@ +//! Maple's task capability for the GPUI-free Python runtime. +//! +//! A binding is installed before asynchronous agent preparation. Retirement +//! closes this capability immediately, even if its first call is still repairing +//! PATH and has not created a runtime handle yet. +#[cfg(test)] +mod ordered_tests; + +use super::image_mediation::prioritized_text; +use super::tool_context::{AgentToolContextSnapshot, SENSITIVE_BRIDGE_ENV, SharedAgentToolContext}; +use goose::agents::ToolCallContext; +use maple_code_mode::{ + Error, Holder, LaunchSpec, Outcome, OutcomeStatus, PackagedPython, ResetOutcome, Runtime, + TaskHandle, TaskStatus, WorkerPhase, +}; +use rmcp::model::{CallToolResult, Tool, ToolAnnotations}; +use rmcp::object; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt::Write; +use std::future::Future; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use tokio_util::sync::CancellationToken; + +pub(super) const PYTHON_TOOL_NAME: &str = "python_code"; +pub(super) const MAX_PYTHON_BATCH_CALLS: NonZeroUsize = NonZeroUsize::new(32).unwrap(); +const RESULT_VERSION: u8 = 1; +const MAX_ERROR_BYTES: usize = 8 * 1024; +const RESET_REASON: &str = + "Python was explicitly reset; previous variables and background work were discarded"; + +pub(super) type PythonCleanup = + Pin> + Send + 'static>>; +pub(super) type CapacityDiagnostics = Arc< + dyn Fn(Vec) -> Pin + Send + 'static>> + Send + Sync, +>; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct PythonParams { + pub code: String, + #[serde(default)] + pub reset: bool, +} + +impl PythonParams { + fn validate(&self) -> Result<(), Error> { + if self.code.len() > maple_code_mode::MAX_SOURCE_BYTES { + return Err(Error::InvalidInput("Python code exceeds 256 KiB".into())); + } + if self.code.trim().is_empty() && !self.reset { + return Err(Error::InvalidInput( + "Python code must not be empty unless reset is true".into(), + )); + } + Ok(()) + } +} + +pub(super) fn python_tool() -> Tool { + Tool::new( + PYTHON_TOOL_NAME.to_string(), + r#"Execute Python in this task's persistent bundled CPython scratchpad. Variables, functions, imports, and background asyncio work survive calls and model turns. A non-None final expression is displayed and retained in _. Use top-level await; a final expression that merely returns an awaitable is not automatically awaited. asyncio.run() and synchronous wrappers that start another loop cannot run here; use their awaitable APIs or a separate script through the existing execution tools. + +Python calls in one tool batch execute one at a time in the order submitted, after those Python calls' permissions are resolved. Up to 32 approved calls are admitted per batch; excess calls fail without execution. Denied calls are skipped. Use asyncio.gather within a cell for concurrent async work. Other tools can run concurrently with Python; do not rely on them finishing before a Python cell. + +The bundle guarantees the Python standard library, not project packages or a writable shared installation. The task root is available for explicit project imports. Inspect sys.executable and explicitly add a known compatible dependency directory to sys.path when needed. Creating or activating a venv in a shell does not retarget this retained worker. There is no Maple Python SDK yet. + +Errors preserve partial assignments and external effects. Output is bounded and truncation is reported; use files for large results. Synchronous blocking calls are allowed but pause other asyncio work for their duration. Stop retires an unfinished Python cell and loses its namespace; force termination can skip cleanup handlers and lose buffered output. Stop after a settled cell preserves its namespace and background work. Reset explicitly ends retained Python work. Set reset=true to start fresh before supplied code, or pass code="" with reset=true to release this task's worker without starting another. App restart and task/owner retirement also discard state. This is ordinary native Python execution with the same machine access as other execution tools."#, + object!({ + "type": "object", + "additionalProperties": false, + "required": ["code"], + "properties": { + "code": { + "type": "string", + "description": "Python source, at most 256 KiB of UTF-8. Empty only for reset=true." + }, + "reset": { + "type": "boolean", + "default": false, + "description": "Retire the current interpreter before running code; empty code releases it without a replacement." + } + } + }), + ) + .annotate(ToolAnnotations::from_raw( + Some("Python".to_string()), + Some(false), + Some(true), + Some(false), + Some(true), + )) +} + +#[derive(Default)] +struct BindingState { + closed: bool, + handle: Option, + state_loss_reason: Option, +} + +pub(super) struct PythonTaskBinding { + runtime: Runtime, + key: String, + session_id: String, + root: PathBuf, + context: SharedAgentToolContext, + state: Mutex, + capacity_diagnostics: Option, + #[cfg(test)] + test_package: Option, +} + +impl PythonTaskBinding { + /// Installs authority only. Package reads, PATH repair, binding and spawn + /// happen lazily in an approved, non-reset-only invocation. + pub(super) fn new( + runtime: Runtime, + key: String, + session_id: String, + root: PathBuf, + context: SharedAgentToolContext, + ) -> Self { + Self { + runtime, + key, + session_id, + root, + context, + state: Mutex::new(BindingState::default()), + capacity_diagnostics: None, + #[cfg(test)] + test_package: None, + } + } + + pub(super) fn with_capacity_diagnostics(mut self, diagnostics: CapacityDiagnostics) -> Self { + self.capacity_diagnostics = Some(diagnostics); + self + } + + pub(super) fn with_state_loss_reason(self, reason: Option) -> Self { + self.state.lock().unwrap().state_loss_reason = reason.map(bound_error); + self + } + + #[cfg(test)] + pub(super) fn with_packaged_python(mut self, package: PackagedPython) -> Self { + self.test_package = Some(package); + self + } + + pub(super) fn key(&self) -> &str { + &self.key + } + + pub(super) fn matches_root(&self, root: &Path) -> bool { + self.root == root + } + + pub(super) fn is_closed(&self) -> bool { + self.state.lock().unwrap().closed || self.context.is_revoked() + } + + pub(super) fn status(&self) -> TaskStatus { + let state = self.state.lock().unwrap(); + let mut status = state + .handle + .as_ref() + .map(TaskHandle::status) + .unwrap_or(TaskStatus { + generation: None, + phase: WorkerPhase::Empty, + closed: false, + state_loss_reason: None, + }); + status.closed |= state.closed || self.context.is_revoked(); + if status.state_loss_reason.is_none() { + status + .state_loss_reason + .clone_from(&state.state_loss_reason); + } + status + } + + /// Closes even an unstarted capability synchronously. The runtime retains + /// cleanup ownership if the observation future is dropped or times out. + pub(super) fn retire(&self, reason: impl Into) -> PythonCleanup { + let mut state = self.state.lock().unwrap(); + let reason = bound_error(reason.into()); + state.closed = true; + state.state_loss_reason = Some(reason.clone()); + state.handle.as_ref().map_or_else( + || { + Box::pin(async { + Ok(ResetOutcome { + retired_generation: None, + }) + }) as PythonCleanup + }, + |handle| handle.retire(reason), + ) + } + + /// Menu reset has already checked current account/run/lease authority. + /// The context launch fence also excludes a concurrently revoked owner. + pub(super) fn reset(&self, reason: impl Into) -> PythonCleanup { + self.reset_for_call(reason.into(), &CancellationToken::new()) + } + + fn reset_for_call(&self, reason: String, run: &CancellationToken) -> PythonCleanup { + let mut state = self.state.lock().unwrap(); + let snapshot = self.context.snapshot(); + let _launch = match snapshot.begin_process_launch(run) { + Ok(guard) if !state.closed => guard, + Ok(_) => return Box::pin(async { Err(Error::Retired) }), + Err(error) => return Box::pin(async move { Err(Error::Unavailable(error)) }), + }; + let Some(handle) = state.handle.clone() else { + return Box::pin(async { + Ok(ResetOutcome { + retired_generation: None, + }) + }); + }; + let reason = bound_error(reason); + if handle.status().generation.is_some() { + state.state_loss_reason = Some(reason.clone()); + } + handle.reset(reason) + } + + fn validate_call( + &self, + state: &BindingState, + ctx: &ToolCallContext, + run: &CancellationToken, + ) -> Result<(), Error> { + if state.closed || self.context.is_revoked() { + return Err(Error::Retired); + } + if run.is_cancelled() { + return Err(Error::Cancelled); + } + if ctx.session_id != self.session_id { + return Err(Error::InvalidInput( + "Python capability does not belong to this task".into(), + )); + } + if ctx + .working_dir + .as_deref() + .is_some_and(|root| !self.matches_root(root)) + { + return Err(Error::LaunchMismatch); + } + Ok(()) + } + + pub(super) async fn call( + &self, + params: PythonParams, + ctx: &ToolCallContext, + login_path: impl Future> + Send, + run: CancellationToken, + ) -> CallToolResult { + match self.invoke(params, ctx, login_path, run).await { + Ok(response) => response.into_result(), + Err(Error::Capacity { holders }) => { + let message = match &self.capacity_diagnostics { + Some(diagnostics) => diagnostics(holders).await, + None => "All four Python worker slots are occupied. Use Reset Python on an idle Maple task, or close the owning ACP session or connection. A reset-only call {\"code\":\"\",\"reset\":true} releases the worker in that session after cleanup; reset with code keeps a slot occupied.".into(), + }; + python_error(message) + } + Err(error) => python_error(error.to_string()), + } + } + + async fn invoke( + &self, + params: PythonParams, + ctx: &ToolCallContext, + login_path: impl Future> + Send, + run: CancellationToken, + ) -> Result { + params.validate()?; + { + let state = self.state.lock().unwrap(); + self.validate_call(&state, ctx, &run)?; + } + if params.reset { + let reset = self.reset_for_call(RESET_REASON.into(), &run).await?; + self.validate_call(&self.state.lock().unwrap(), ctx, &run)?; + if params.code.trim().is_empty() { + return Ok(PythonResponse::Reset(reset)); + } + } + + let needs_launch = self.state.lock().unwrap().handle.is_none(); + let prepared = if needs_launch { + // Both futures start only after permission dispatch. Neither runs + // under lifecycle, logical-binding or context launch locks. + let (package, login_path) = tokio::join!(self.resolve_package(), login_path); + let snapshot = self.context.snapshot(); + let env = project_environment( + std::env::vars_os().collect(), + login_path.as_deref(), + &snapshot, + &self.session_id, + ); + Some((package?, env)) + } else { + None + }; + + let execution = { + let mut state = self.state.lock().unwrap(); + self.validate_call(&state, ctx, &run)?; + let snapshot = self.context.snapshot(); + let launch_guard = snapshot + .begin_process_launch(&run) + .map_err(Error::Unavailable)?; + if state.handle.is_none() { + let (python, env) = prepared.ok_or_else(|| { + Error::Unavailable("Python launch configuration was not prepared".into()) + })?; + state.handle = Some(self.runtime.bind( + self.key.clone(), + LaunchSpec { + python, + cwd: self.root.clone(), + env, + }, + self.context.lifetime_token(), + )?); + } + state + .handle + .as_ref() + .unwrap() + .execute_guarded(params.code, run, || Ok(launch_guard)) + }; + let mut outcome = execution.await?; + { + let mut state = self.state.lock().unwrap(); + // A completed run token may be cancelled later without revoking + // its settled worker. Only owner retirement suppresses its result. + if state.closed || self.context.is_revoked() { + return Err(Error::Retired); + } + if outcome.state_loss_reason.is_none() { + outcome.state_loss_reason = state.state_loss_reason.take(); + } else { + state.state_loss_reason = None; + } + } + Ok(PythonResponse::Execution(Box::new(outcome))) + } + + async fn resolve_package(&self) -> Result { + #[cfg(test)] + if let Some(package) = &self.test_package { + return Ok(package.clone()); + } + tokio::task::spawn_blocking(|| { + let executable = std::env::current_exe().map_err(|error| { + Error::Unavailable(format!("Could not locate Maple's bundled Python: {error}")) + })?; + PackagedPython::for_application_executable(executable) + }) + .await + .map_err(|error| { + Error::Unavailable(format!( + "Could not read Maple's Python installation: {error}" + )) + })? + } +} + +fn env_name_matches(actual: &OsStr, expected: &str) -> bool { + #[cfg(windows)] + { + actual + .to_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + } + #[cfg(not(windows))] + { + actual == OsStr::new(expected) + } +} + +fn replace_environment(env: &mut BTreeMap, name: &str, value: &str) { + env.retain(|key, _| !env_name_matches(key, name)); + env.insert(name.into(), value.into()); +} + +fn project_environment( + mut parent: BTreeMap, + login_path: Option<&str>, + context: &AgentToolContextSnapshot, + session_id: &str, +) -> BTreeMap { + if let Some(path) = login_path { + replace_environment(&mut parent, "PATH", path); + } + parent.retain(|key, _| { + !context + .scrub_from_parent + .iter() + .any(|name| env_name_matches(key, name)) + && !SENSITIVE_BRIDGE_ENV + .iter() + .any(|name| env_name_matches(key, name)) + }); + for (key, value) in &context.values { + if !SENSITIVE_BRIDGE_ENV + .iter() + .any(|name| env_name_matches(OsStr::new(key), name)) + { + replace_environment(&mut parent, key, value); + } + } + replace_environment(&mut parent, "AGENT_SESSION_ID", session_id); + parent +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum PythonResponse { + Execution(Box), + Reset(ResetOutcome), + Error { message: String }, +} + +impl PythonResponse { + fn into_result(self) -> CallToolResult { + let (text, is_error) = match &self { + Self::Execution(outcome) => { + (format_outcome(outcome), outcome.status != OutcomeStatus::Ok) + } + Self::Reset(reset) => ( + match reset.retired_generation { + Some(generation) => format!( + "Python generation {generation} was reset after cleanup. No replacement interpreter was started." + ), + None => "Python is already empty. No interpreter was started.".into(), + }, + false, + ), + Self::Error { message } => (format!("Python error: {message}"), true), + }; + let mut result = if is_error { + CallToolResult::error(vec![prioritized_text(text)]) + } else { + CallToolResult::success(vec![prioritized_text(text)]) + }; + let mut body = serde_json::to_value(self).expect("Python outcomes are JSON values"); + body.as_object_mut() + .unwrap() + .insert("version".into(), RESULT_VERSION.into()); + result.structured_content = Some(serde_json::json!({ "maple_python": body })); + result + } +} + +pub(super) fn python_error(message: impl Into) -> CallToolResult { + PythonResponse::Error { + message: bound_error(message.into()), + } + .into_result() +} + +fn bound_error(mut message: String) -> String { + if message.len() > MAX_ERROR_BYTES { + let mut end = MAX_ERROR_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + message.push_str("\n[error text truncated]"); + } + message +} + +fn format_outcome(outcome: &Outcome) -> String { + let status = match outcome.status { + OutcomeStatus::Ok => "completed", + OutcomeStatus::Error => "error", + OutcomeStatus::Cancelled => "cancelled", + OutcomeStatus::WorkerLost => "worker lost", + }; + let mut text = format!( + "Python generation {}, execution {}: {status} ({} ms)", + outcome.generation, outcome.execution_id, outcome.elapsed_ms + ); + if let Some(runtime) = &outcome.runtime { + let _ = write!( + text, + "\nCPython {} ({})\nExecutable: {}\nWorking directory: {}", + runtime.version, + runtime.distribution, + runtime.executable.display(), + runtime.cwd.display() + ); + } + for (label, output) in [ + ("stdout", Some(outcome.stdout.as_str())), + ("stderr", Some(outcome.stderr.as_str())), + ("value", outcome.value.as_deref()), + ("traceback", outcome.traceback.as_deref()), + ] { + if let Some(output) = output.filter(|output| !output.is_empty()) { + let _ = write!(text, "\n\n{label}:\n{output}"); + } + } + if outcome.dropped_stdout_bytes > 0 || outcome.dropped_stderr_bytes > 0 { + let _ = write!( + text, + "\n\nOutput truncated: {} stdout bytes and {} stderr bytes omitted.", + outcome.dropped_stdout_bytes, outcome.dropped_stderr_bytes + ); + } + for chunk in &outcome.background.chunks { + let attribution = chunk.execution_id.map_or_else( + || "unattributed native/subprocess output".into(), + |id| format!("late output from execution {id}"), + ); + let _ = write!( + text, + "\n\nBackground {} ({attribution}):\n{}", + chunk.stream, chunk.text + ); + } + if outcome.background.dropped_stdout_bytes > 0 || outcome.background.dropped_stderr_bytes > 0 { + let _ = write!( + text, + "\n\nBackground output truncated: {} stdout bytes and {} stderr bytes omitted.", + outcome.background.dropped_stdout_bytes, outcome.background.dropped_stderr_bytes + ); + } + if let Some(reason) = &outcome.state_loss_reason { + let _ = write!(text, "\n\nState loss: {reason}"); + } + if outcome.cleanup_pending { + text.push_str("\n\nPython cleanup is still pending; its capacity slot remains occupied."); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::AgentToolContextSpec; + use std::collections::BTreeSet; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn context(values: &[(&str, &str)], scrub: &[&str]) -> SharedAgentToolContext { + SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + values + .iter() + .map(|(key, value)| ((*key).into(), (*value).into())) + .collect(), + scrub + .iter() + .map(|key| (*key).into()) + .collect::>(), + true, + ) + .unwrap(), + ) + } + + fn fixture() -> PackagedPython { + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + PackagedPython::from_manifest(manifest) + .expect("prepare the packaged Python fixture with `nix develop -c just python-prepare`") + } + + fn binding( + runtime: Runtime, + root: &Path, + context: SharedAgentToolContext, + ) -> PythonTaskBinding { + PythonTaskBinding::new( + runtime, + "test-task".into(), + "session".into(), + root.to_path_buf(), + context, + ) + } + + fn call_context(root: &Path) -> ToolCallContext { + ToolCallContext::new("session".into(), Some(root.to_path_buf()), None) + } + + fn params(code: &str, reset: bool) -> PythonParams { + PythonParams { + code: code.into(), + reset, + } + } + + fn body(result: &CallToolResult) -> &serde_json::Value { + &result.structured_content.as_ref().unwrap()["maple_python"] + } + + #[test] + fn environment_projection_scrubs_only_named_keys_and_keeps_context_authority() { + let context = context( + &[ + ("PATH", "explicit-path"), + ("CUSTOM_VALUE", "custom"), + ("BUZZ_RELAY_URL", "supplied-relay"), + ("BUZZ_PRIVATE_KEY", "supplied-key"), + ("BUZZ_AUTH_TAG", "supplied-tag"), + ("BUZZ_API_TOKEN", "supplied-token"), + ("BUZZ_ACP_DISPLAY_NAME", "supplied-name"), + ("AGENT_SESSION_ID", "untrusted-session"), + ], + &["OLD_TOKEN"], + ); + let snapshot = context.snapshot(); + let mut parent = BTreeMap::from([ + (OsString::from("PATH"), OsString::from("parent-path")), + ("AGENT_SESSION_ID".into(), "parent-session".into()), + ("OLD_TOKEN".into(), "old".into()), + ("OTHER_TOKEN".into(), "ordinary-custom".into()), + ]); + for key in SENSITIVE_BRIDGE_ENV { + parent.insert(key.into(), "ambient-value".into()); + } + let env = project_environment(parent, Some("login-path"), &snapshot, "trusted-session"); + assert_eq!( + env.get(OsStr::new("PATH")), + Some(&OsString::from("explicit-path")) + ); + assert_eq!( + env.get(OsStr::new("AGENT_SESSION_ID")), + Some(&OsString::from("trusted-session")) + ); + assert_eq!( + env.get(OsStr::new("CUSTOM_VALUE")), + Some(&OsString::from("custom")) + ); + assert_eq!( + env.get(OsStr::new("OTHER_TOKEN")), + Some(&OsString::from("ordinary-custom")) + ); + assert!(!env.contains_key(OsStr::new("OLD_TOKEN"))); + for key in SENSITIVE_BRIDGE_ENV { + assert!(!env.contains_key(OsStr::new(key))); + } + assert!(snapshot.ephemeral); + assert!(!context.is_revoked()); + context.revoke(); + assert!(snapshot.revoked.is_cancelled()); + } + + #[test] + fn path_repair_precedes_context_scrubbing_and_platform_name_matching() { + let context = context(&[("CUSTOM", "replacement")], &["PATH"]); + let parent = BTreeMap::from([ + (OsString::from("Path"), OsString::from("ambient")), + ("Custom".into(), "old-custom".into()), + ("buzz_api_token".into(), "ambient-buzz".into()), + ("agent_session_id".into(), "ambient-session".into()), + ]); + let env = project_environment(parent, Some("repaired"), &context.snapshot(), "trusted"); + assert!(!env.contains_key(OsStr::new("PATH"))); + assert_eq!( + env.get(OsStr::new("AGENT_SESSION_ID")), + Some(&OsString::from("trusted")) + ); + assert_eq!( + env.get(OsStr::new("CUSTOM")), + Some(&OsString::from("replacement")) + ); + for key in ["Path", "Custom", "buzz_api_token", "agent_session_id"] { + assert_eq!(env.contains_key(OsStr::new(key)), !cfg!(windows)); + } + } + + #[tokio::test] + async fn reset_only_and_rejected_calls_do_not_prepare_python_or_path() { + let root = tempfile::tempdir().unwrap(); + let context = context(&[], &[]); + let runtime = Runtime::default(); + let binding = binding(runtime.clone(), root.path(), context.clone()); + let calls = AtomicUsize::new(0); + for parameters in [params("", true), params("", true), params("", false)] { + let result = binding + .call( + parameters, + &call_context(root.path()), + async { + calls.fetch_add(1, Ordering::SeqCst); + panic!("reset-only and invalid calls must not prepare PATH") + }, + CancellationToken::new(), + ) + .await; + assert_eq!(body(&result)["version"], RESULT_VERSION); + } + let cancelled = CancellationToken::new(); + cancelled.cancel(); + let result = binding + .call( + params("1 + 1", false), + &call_context(root.path()), + async { panic!("cancelled calls must not prepare PATH") }, + cancelled, + ) + .await; + assert!(result.is_error.unwrap()); + context.revoke(); + let result = binding + .call( + params("", true), + &call_context(root.path()), + async { panic!("revoked calls must not prepare PATH") }, + CancellationToken::new(), + ) + .await; + assert!(result.is_error.unwrap()); + assert!(runtime.snapshot().holders.is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + binding.retire("revoked test owner").await.unwrap(); + runtime.shutdown("test complete").await.unwrap(); + } + + #[tokio::test] + async fn archive_fences_a_call_while_its_path_probe_is_pending() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let binding = Arc::new( + binding(runtime.clone(), root.path(), context(&[], &[])) + .with_packaged_python(fixture()), + ); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let invocation_binding = binding.clone(); + let ctx = call_context(root.path()); + let invocation = tokio::spawn(async move { + invocation_binding + .call( + params("must_not_exist = True", false), + &ctx, + async { + started_tx.send(()).unwrap(); + release_rx.await.unwrap(); + None + }, + CancellationToken::new(), + ) + .await + }); + started_rx.await.unwrap(); + let cleanup = binding.retire("task was archived"); + assert!(binding.is_closed()); + release_tx.send(()).unwrap(); + let result = invocation.await.unwrap(); + assert!(result.is_error.unwrap()); + assert_eq!(body(&result)["kind"], "error"); + cleanup.await.unwrap(); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); + } + + #[tokio::test] + async fn native_binding_persists_across_calls_and_unrelated_stop_then_resets() { + let root = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + let runtime = Runtime::default(); + let context = context( + &[ + ("CUSTOM_VALUE", "supplied"), + ("BUZZ_API_TOKEN", "must-not-reach-python"), + ], + &[], + ); + let binding = binding(runtime.clone(), &root, context).with_packaged_python(fixture()); + let ctx = call_context(&root); + let first_run = CancellationToken::new(); + let first = binding.call(params("import os\nanswer = 40\nassert os.getenv('CUSTOM_VALUE') == 'supplied'\nassert os.getenv('BUZZ_API_TOKEN') is None\nassert os.getenv('AGENT_SESSION_ID') == 'session'\nanswer", false), &ctx, std::future::ready(None), first_run.clone()).await; + assert!(!first.is_error.unwrap_or(false), "{first:?}"); + assert_eq!(body(&first)["value"], "40"); + let generation = body(&first)["generation"].clone(); + first_run.cancel(); + let second = binding + .call( + params("answer + 2", false), + &ctx, + async { panic!("retained worker must not probe PATH again") }, + CancellationToken::new(), + ) + .await; + assert!(!second.is_error.unwrap_or(false), "{second:?}"); + assert_eq!(body(&second)["generation"], generation); + assert_eq!(body(&second)["value"], "42"); + let reset = binding + .call( + params("", true), + &ctx, + async { panic!("reset-only must not probe PATH") }, + CancellationToken::new(), + ) + .await; + assert_eq!(body(&reset)["kind"], "reset"); + assert_eq!(body(&reset)["retired_generation"], generation); + assert!(runtime.snapshot().holders.is_empty()); + let third = binding + .call( + params("'answer' in globals()", false), + &ctx, + async { panic!("immutable retained launch configuration needs no new probe") }, + CancellationToken::new(), + ) + .await; + assert!(!third.is_error.unwrap_or(false), "{third:?}"); + assert_ne!(body(&third)["generation"], generation); + assert_eq!(body(&third)["value"], "False"); + assert!( + body(&third)["state_loss_reason"] + .as_str() + .unwrap() + .contains("reset") + ); + binding.retire("test complete").await.unwrap(); + runtime.shutdown("test complete").await.unwrap(); + } + + #[tokio::test] + async fn mismatched_task_and_root_are_rejected_before_preparation() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let binding = binding(runtime.clone(), root.path(), context(&[], &[])); + for ctx in [ + ToolCallContext::new( + "other-session".into(), + Some(root.path().to_path_buf()), + None, + ), + ToolCallContext::new("session".into(), Some(root.path().join("other-root")), None), + ] { + let result = binding + .call( + params("1", false), + &ctx, + async { panic!("mismatched capabilities must not prepare PATH") }, + CancellationToken::new(), + ) + .await; + assert!(result.is_error.unwrap()); + } + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/code_mode/ordered_tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/code_mode/ordered_tests.rs new file mode 100644 index 000000000..b4703cefa --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/code_mode/ordered_tests.rs @@ -0,0 +1,340 @@ +//! Exercises Goose's ordered dispatch through Maple's actual native Python client. +//! The fake provider supplies one batch; Python execution and process cleanup are real. + +use super::*; +use crate::agent::AgentToolContextSpec; +use crate::agent::developer_tools::MapleDeveloperClient; +use crate::agent::web_tools::WebToolState; +use futures_util::StreamExt; +use goose::agents::{ + Agent, AgentConfig, AgentEvent, ExtensionConfig, GoosePlatform, SessionConfig, +}; +use goose::config::{GooseMode, PermissionManager}; +use goose::conversation::message::Message; +use goose::session::{SessionManager, SessionType}; +use goose_providers::base::{MessageStream, Provider, stream_from_single_message}; +use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; +use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use rmcp::model::CallToolRequestParams; +use std::num::NonZeroUsize; +use std::time::Duration; + +struct BatchProvider(Mutex>); + +#[async_trait::async_trait] +impl Provider for BatchProvider { + fn get_name(&self) -> &str { + "ordered-python-test" + } + + async fn stream( + &self, + _model_config: &ModelConfig, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + let message = self + .0 + .lock() + .unwrap() + .take() + .unwrap_or_else(|| Message::assistant().with_text("Done.")); + Ok(stream_from_single_message( + message, + ProviderUsage::new("ordered-python-test".into(), Usage::default()), + )) + } +} + +struct NativeBatch { + _temp: tempfile::TempDir, + root: PathBuf, + agent: Arc, + session_id: String, + runtime: Runtime, + binding: Arc, + context: SharedAgentToolContext, +} + +impl NativeBatch { + async fn new(calls: &[(&str, &str, bool)]) -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(temp.path()).unwrap(); + let sessions = Arc::new(SessionManager::new(root.join("sessions"))); + let session = sessions + .create_session( + root.clone(), + "Ordered Python test".into(), + SessionType::User, + GooseMode::Auto, + ) + .await + .unwrap(); + let mut config = AgentConfig::new( + sessions, + Arc::new(PermissionManager::new(root.join("permissions"))), + None, + GooseMode::Auto, + true, + GoosePlatform::GooseDesktop, + ) + .with_use_login_shell_path(false) + .with_ordered_tool_calls( + ["python_code".into(), "developer__python_code".into()], + NonZeroUsize::new(32).unwrap(), + ); + // Agent::with_config otherwise discovers and executes installed user + // hooks. Suppress those external programs in this native test harness. + config.is_subagent = true; + let agent = Arc::new(Agent::with_config(config)); + let batch = calls.iter().enumerate().fold( + Message::assistant(), + |message, (index, (name, code, reset))| { + message.with_tool_request( + format!("cell-{index}"), + Ok(CallToolRequestParams::new((*name).to_string()) + .with_arguments(object!({ "code": code, "reset": reset }))), + ) + }, + ); + agent + .update_provider( + Arc::new(BatchProvider(Mutex::new(Some(batch)))), + ModelConfig::new("ordered-python-test"), + &session.id, + ) + .await + .unwrap(); + + let runtime = Runtime::default(); + let context = SharedAgentToolContext::new(AgentToolContextSpec::default()); + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + let package = PackagedPython::from_manifest(manifest) + .expect("prepare the bundled Python fixture with `nix develop -c just python-prepare`"); + let binding = Arc::new( + PythonTaskBinding::new( + runtime.clone(), + "ordered-python-task".into(), + session.id.clone(), + root.clone(), + context.clone(), + ) + .with_packaged_python(package), + ); + // Start the real worker without an interactive login-shell probe. The + // injected client subsequently reuses this immutable launch configuration. + let ready = binding + .call( + PythonParams { + code: "None".into(), + reset: false, + }, + &ToolCallContext::new(session.id.clone(), Some(root.clone()), None), + std::future::ready(None), + CancellationToken::new(), + ) + .await; + assert!(!ready.is_error.unwrap_or(false), "{ready:?}"); + let client = MapleDeveloperClient::new( + agent.extension_manager.get_context().clone(), + true, + crate::maple_api::test_maple_api_session("ordered-python-test"), + Arc::new(WebToolState::default()), + context.clone(), + ) + .unwrap() + .with_python_binding(binding.clone()); + agent + .extension_manager + .add_client( + "developer".into(), + ExtensionConfig::Builtin { + name: "developer".into(), + description: "Native Python test".into(), + display_name: None, + timeout: None, + bundled: Some(true), + available_tools: Vec::new(), + }, + Arc::new(client), + None, + None, + ) + .await; + Self { + _temp: temp, + root, + agent, + session_id: session.id, + runtime, + binding, + context, + } + } + + fn start(&self, run: CancellationToken) -> tokio::task::JoinHandle> { + let agent = self.agent.clone(); + let session_id = self.session_id.clone(); + tokio::spawn(async move { + let mut stream = agent + .reply( + Message::user().with_text("Run the supplied Python batch."), + SessionConfig { + id: session_id, + schedule_id: None, + max_turns: Some(2), + retry_config: None, + }, + Some(run), + ) + .await + .unwrap(); + let mut responses = BTreeMap::new(); + while let Some(event) = stream.next().await { + if let AgentEvent::Message(message) = event.unwrap() { + for response in message.content.iter().filter_map(|c| c.as_tool_response()) { + responses.insert( + response.id.clone(), + response + .tool_result + .clone() + .unwrap_or_else(|error| python_error(error.to_string())), + ); + } + } + } + responses.into_values().collect() + }) + } + + async fn cleanup(&self) { + self.binding.retire("ordered test complete").await.unwrap(); + self.runtime + .shutdown("ordered test complete") + .await + .unwrap(); + assert!(self.runtime.snapshot().holders.is_empty()); + } +} + +fn python_body(result: &CallToolResult) -> &serde_json::Value { + assert!(!result.is_error.unwrap_or(false), "{result:?}"); + &result.structured_content.as_ref().unwrap()["maple_python"] +} + +#[tokio::test] +async fn native_ordered_batch_shares_state_between_cells() { + let batch = NativeBatch::new(&[ + ( + "python_code", + "import asyncio\nawait asyncio.sleep(0.05)\nanswer = 40\nanswer", + false, + ), + ("python_code", "answer += 2\nanswer", false), + ]) + .await; + let results = tokio::time::timeout( + Duration::from_secs(30), + batch.start(CancellationToken::new()), + ) + .await + .expect("ordered native batch must finish") + .unwrap(); + batch.cleanup().await; + assert_eq!(results.len(), 2, "{results:?}"); + assert_eq!(python_body(&results[0])["value"], "40"); + assert_eq!(python_body(&results[1])["value"], "42"); + assert_eq!( + python_body(&results[0])["generation"], + python_body(&results[1])["generation"] + ); +} + +#[tokio::test] +async fn native_ordered_reset_replaces_state_before_following_cell() { + let batch = NativeBatch::new(&[ + ("python_code", "answer = 40\nanswer", false), + ( + "python_code", + "assert 'answer' not in globals()\nreplacement = 6\nreplacement", + true, + ), + ("python_code", "replacement += 1\nreplacement", false), + ]) + .await; + let results = tokio::time::timeout( + Duration::from_secs(30), + batch.start(CancellationToken::new()), + ) + .await + .expect("reset and replacement cells must finish") + .unwrap(); + batch.cleanup().await; + assert_eq!(results.len(), 3, "{results:?}"); + assert_eq!(python_body(&results[0])["value"], "40"); + assert_eq!(python_body(&results[1])["value"], "6"); + assert_eq!(python_body(&results[2])["value"], "7"); + assert_ne!( + python_body(&results[0])["generation"], + python_body(&results[1])["generation"] + ); + assert_eq!( + python_body(&results[1])["generation"], + python_body(&results[2])["generation"] + ); +} + +async fn queued_cell_cannot_outlive_its_authority(retire_owner: bool) { + let batch = NativeBatch::new(&[ + ( + "python_code", + "from pathlib import Path\nimport asyncio\nPath('started').touch()\nawait asyncio.Event().wait()", + false, + ), + ( + "python_code", + "from pathlib import Path\nPath('queued-effect').touch()", + false, + ), + ]) + .await; + let run = CancellationToken::new(); + let execution = batch.start(run.clone()); + tokio::time::timeout(Duration::from_secs(30), async { + while !batch.root.join("started").exists() { + assert!(!execution.is_finished(), "foreground cell failed to start"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("first native cell must start before authority is withdrawn"); + if retire_owner { + batch.context.revoke(); + batch.binding.retire("test owner retired").await.unwrap(); + } else { + run.cancel(); + } + tokio::time::timeout(Duration::from_secs(30), execution) + .await + .expect("queued batch must settle after losing authority") + .unwrap(); + batch.cleanup().await; + assert!(!batch.root.join("queued-effect").exists()); +} + +#[tokio::test] +async fn native_ordered_queue_does_not_execute_after_run_cancellation() { + queued_cell_cannot_outlive_its_authority(false).await; +} + +#[tokio::test] +async fn native_ordered_queue_does_not_execute_after_owner_retirement() { + queued_cell_cannot_outlive_its_authority(true).await; +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs index a9cd1eece..2727966d9 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs @@ -1,4 +1,7 @@ use super::attachments::{AgentAttachmentStore, attachment_id_from_source}; +use super::code_mode::{ + PYTHON_TOOL_NAME, PythonParams, PythonTaskBinding, python_error, python_tool, +}; use super::web_tools::{ OPEN_URL_TOOL_NAME, OpenUrlParams, WEB_SEARCH_TOOL_NAME, WebSearchParams, WebToolState, bound_open_url_tool_error, bound_web_search_tool_error, execute_open_url, execute_web_search, @@ -145,6 +148,7 @@ pub(crate) struct MapleDeveloperClient { web_transport: Arc, web_state: Arc, tool_context: SharedAgentToolContext, + python_binding: Option>, contextual_image_context: Option, attachment_store: Option>, /// When false the web tools are left out of the catalog entirely, so @@ -185,6 +189,7 @@ impl MapleDeveloperClient { web_transport, web_state, tool_context, + python_binding: None, contextual_image_context, attachment_store: None, web_enabled: true, @@ -201,6 +206,11 @@ impl MapleDeveloperClient { self } + pub(super) fn with_python_binding(mut self, binding: Arc) -> Self { + self.python_binding = Some(binding); + self + } + pub(super) fn with_desktop_ui_tools(mut self, enabled: bool) -> Self { self.desktop_ui_tools = enabled; self @@ -589,6 +599,7 @@ impl McpClientTrait for MapleDeveloperClient { .any(|tool| !seen_names.insert(tool.name.to_string())) || seen_names.contains(WEB_SEARCH_TOOL_NAME) || seen_names.contains(OPEN_URL_TOOL_NAME) + || seen_names.contains(PYTHON_TOOL_NAME) { log::error!("Goose developer tools contained a duplicate Maple-owned tool name"); return Err(Error::UnexpectedResponse); @@ -619,6 +630,9 @@ impl McpClientTrait for MapleDeveloperClient { tools.push(web_search_tool()); tools.push(open_url_tool()); } + if self.python_binding.is_some() { + tools.push(python_tool()); + } if let Some(router) = self.tool_context.transient_mcp() { if tools @@ -648,6 +662,22 @@ impl McpClientTrait for MapleDeveloperClient { ) -> Result { let working_dir = ctx.working_dir.as_deref(); let result = match name { + PYTHON_TOOL_NAME => { + let Some(binding) = &self.python_binding else { + return Ok(python_error( + "Python is unavailable in this agent's tool context", + )); + }; + let params = match Self::parse_args::(arguments) { + Ok(params) => params, + Err(error) => return Ok(python_error(error)), + }; + #[cfg(not(windows))] + let login_path = self.login_path(); + #[cfg(windows)] + let login_path = std::future::ready(None); + return Ok(binding.call(params, ctx, login_path, cancel_token).await); + } "read" => match Self::parse_args::(arguments) { Ok(params) => read_file(params, working_dir, cancel_token).await, Err(error) => error_result(error), @@ -2933,6 +2963,144 @@ mod tests { ); } + #[tokio::test] + async fn python_catalog_requires_the_injected_task_capability_and_never_probes() { + let temp = TestDir::new(); + let client = test_client(temp.path().join("sessions"), true); + let denied = client + .call_tool( + &ToolCallContext::new("session".into(), None, None), + PYTHON_TOOL_NAME, + Some(object!({ "code": "", "reset": true })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(denied.is_error.unwrap()); + + let runtime = maple_code_mode::Runtime::default(); + let binding = Arc::new(PythonTaskBinding::new( + runtime.clone(), + "catalog-task".into(), + "session".into(), + temp.path().to_path_buf(), + client.tool_context.clone(), + )); + let client = client.with_python_binding(binding); + let catalog = client + .list_tools("session", None, CancellationToken::new()) + .await + .unwrap(); + let python = catalog + .tools + .iter() + .find(|tool| tool.name == PYTHON_TOOL_NAME) + .unwrap(); + let python = serde_json::to_value(python).unwrap(); + assert_eq!( + python["inputSchema"]["required"], + serde_json::json!(["code"]) + ); + assert_eq!( + python["inputSchema"]["properties"]["reset"]["default"], + false + ); + assert_eq!(python["annotations"]["readOnlyHint"], false); + assert_eq!(python["annotations"]["title"], "Python"); + let reset = client + .call_tool( + &ToolCallContext::new("session".into(), None, None), + PYTHON_TOOL_NAME, + Some(object!({ "code": "", "reset": true })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!reset.is_error.unwrap_or(false)); + assert_eq!( + reset.structured_content.unwrap()["maple_python"]["kind"], + "reset" + ); + #[cfg(not(windows))] + assert!(client.login_path.get().is_none()); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("catalog test complete").await.unwrap(); + } + + #[tokio::test] + async fn reconstructed_python_clients_share_native_task_state() { + let temp = TestDir::new(); + let root = fs::canonicalize(temp.path()).unwrap(); + let runtime = maple_code_mode::Runtime::default(); + let client = test_client(temp.path().join("sessions"), true); + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + let package = maple_code_mode::PackagedPython::from_manifest(manifest) + .expect("prepare the bundled Python fixture with `nix develop -c just python-prepare`"); + let binding = Arc::new( + PythonTaskBinding::new( + runtime.clone(), + "reconstructed-client-task".into(), + "session".into(), + root.clone(), + client.tool_context.clone(), + ) + .with_packaged_python(package), + ); + let client = client.with_python_binding(binding.clone()); + // Native tests consume the prepared fixture; they do not need a real + // interactive shell or mutate this process's environment to repair PATH. + #[cfg(not(windows))] + client.login_path.set(std::env::var("PATH").ok()).unwrap(); + let ctx = ToolCallContext::new("session".into(), Some(root), None); + let first = client + .call_tool( + &ctx, + PYTHON_TOOL_NAME, + Some(object!({ "code": "answer = 40\nanswer" })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!first.is_error.unwrap_or(false), "{first:?}"); + let context = client.tool_context.clone(); + drop(client); + let reconstructed = MapleDeveloperClient::new( + test_context(temp.path().join("sessions")), + true, + Arc::new(TestWebTransport), + Arc::new(WebToolState::default()), + context, + ) + .unwrap() + .with_python_binding(binding.clone()); + let second = reconstructed + .call_tool( + &ctx, + PYTHON_TOOL_NAME, + Some(object!({ "code": "answer + 2" })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!second.is_error.unwrap_or(false), "{second:?}"); + let first = first.structured_content.unwrap(); + let second = second.structured_content.unwrap(); + assert_eq!(second["maple_python"]["value"], "42"); + assert_eq!( + first["maple_python"]["generation"], + second["maple_python"]["generation"] + ); + #[cfg(not(windows))] + assert!(reconstructed.login_path.get().is_none()); + binding.retire("test complete").await.unwrap(); + runtime.shutdown("test complete").await.unwrap(); + } + #[tokio::test] async fn transient_catalog_is_exposed_only_through_one_static_external_mcp_wrapper() { let temp = TestDir::new(); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs index 874a454a2..05e6105dc 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs @@ -490,6 +490,7 @@ pub(super) fn friendly_tool_label(name: &str) -> String { let bare = name.rsplit("__").next().unwrap_or(name); match bare { "shell" => "Terminal".to_string(), + "python_code" => "Python".to_string(), "delegate" => "Subagent".to_string(), "load" => "Load".to_string(), "text_editor" | "str_replace_editor" | "str_replace_based_edit_tool" => { @@ -522,6 +523,21 @@ pub(super) fn descriptive_tool_title( // is described by its command; an editor call such as // `{command: "view", path: "src/main.rs"}` is about the file. let bare_name = tool_name.rsplit("__").next().unwrap_or(tool_name); + if bare_name == "python_code" { + let first_line = arguments + .get("code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|code| !code.is_empty()) + .and_then(|code| code.lines().next()); + return Some(match first_line { + Some(line) => format!( + "Python: {}", + bounded_timeline_text(line, MAX_AGENT_SESSION_TITLE_CHARS) + ), + None => "Python".to_string(), + }); + } let keys: &[&str] = if bare_name == "shell" { &[ "command", @@ -1169,3 +1185,27 @@ pub(super) async fn update_live_permission_status( item.merge = "replace".to_string(); Some(item.clone()) } + +#[cfg(test)] +mod python_title_tests { + use super::*; + + #[test] + fn python_titles_keep_one_bounded_code_line_and_label_reset_only_calls() { + for name in ["python_code", "developer__python_code"] { + assert_eq!(friendly_tool_label(name), "Python"); + assert_eq!( + descriptive_tool_title(name, &json!({"code": "\nvalues = [2, 3, 5]\nsum(values)"})), + Some("Python: values = [2, 3, 5]".to_string()) + ); + assert_eq!( + descriptive_tool_title(name, &json!({"code": "", "reset": true})), + Some("Python".to_string()) + ); + let title = descriptive_tool_title(name, &json!({"code": "é".repeat(1_024)})) + .expect("Python title"); + assert!(title.chars().count() <= "Python: ".len() + MAX_AGENT_SESSION_TITLE_CHARS + 1); + assert!(!title.contains('\n')); + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 4df9c61f9..d47231128 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -8,6 +8,12 @@ use super::*; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentPythonStatus { + pub resettable: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentConfig { @@ -865,7 +871,7 @@ pub(super) async fn release_tool_context_lease( (removed, Arc::clone(¤t.agent_manager)) }; if let Some(installed) = removed { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); // Dropping the cached Agent is the fail-closed way to remove every // transient MCP client (and any secret-bearing HTTP headers) without // mutating the persisted extension set. A later Desktop or ACP use @@ -918,7 +924,7 @@ pub(super) async fn cleanup_provisional_created_session( access.installation_id, &access.context, ) { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } Some(Arc::clone(¤t.permission_modes)) } diff --git a/apps/maple-agent/crates/maple-code-mode/Cargo.toml b/apps/maple-agent/crates/maple-code-mode/Cargo.toml new file mode 100644 index 000000000..4f73e6d73 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "maple-code-mode" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tokio-util.workspace = true +log.workspace = true +process-wrap = { version = "=9.1.0", default-features = false, features = ["tokio1", "creation-flags", "process-group", "kill-on-drop"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] } + +[dev-dependencies] +tempfile = "3" diff --git a/apps/maple-agent/crates/maple-code-mode/README.md b/apps/maple-agent/crates/maple-code-mode/README.md new file mode 100644 index 000000000..325be9ba7 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/README.md @@ -0,0 +1,100 @@ +# Maple CPython worker + +This crate owns task-lived CPython processes. It has no GPUI, Goose, account, +permission, or SDK dependency. The caller supplies an opaque task key, a packaged +interpreter, an immutable working directory and environment, and an owner lifetime. +The caller remains responsible for authorization before every operation. + +## Execution and lifetime + +`Runtime::bind` installs a task binding without starting Python. The first +`TaskHandle::execute` reserves one of four worker slots and starts the exact +packaged executable. A protocol handshake validates CPython 3.13.15, executable, +working directory, and protocol version before any cell executes. There is no +system-Python fallback, runtime download, interpreter probe, or automatic replay. + +Each generation has one real `__main__` namespace, a continuously running asyncio +loop on the main thread, and one foreground cell at a time. Top-level `await` is +supported. A non-`None` final expression is displayed and retained in `_`; an +ordinary coroutine value is not automatically awaited. Exceptions preserve partial +assignments. Background tasks continue between cells; synchronous blocking calls +pause the loop. The runtime is ordinary native Python execution, not a sandbox. + +`execute_guarded` invokes the caller's launch fence synchronously around admission +and spawn. The returned future never holds that fence. Dropping even an unpolled +execution future cancels the admitted unfinished call. Cancelling a completed +call's token does not retire its idle worker. Owner revocation always retires it. + +`reset` ends the worker generation while preserving the logical task binding. +`retire` permanently fences that binding, including previously prepared handles. +Both fence admission before returning their cleanup future. Neither spawns a +replacement. A later permitted call starts fresh only after exact prior cleanup. +Starting, executing, idle, retiring, and cleanup-pending workers all count toward +the capacity limit. There is no eviction, idle expiry, or cell execution deadline. + +Shutdown has one two-second cooperative grace window, followed by process-group +or Windows Job termination. Supervision owns child cleanup independently of the +request future. A five-second caller observation timeout does not release the slot +or abandon cleanup. Forced termination can skip Python cleanup handlers and lose +buffered output. Descendants that deliberately escape ordinary process containment +are outside this runtime's guarantee. + +On Unix, supervision retains one process-wrap group wait. On Windows, a concrete +Job guard assigns the suspended child before resume, terminates the Job, and +observes its active-process count reaching zero. process-wrap 9.1.0's Job wait +accepts completion-port packets that do not prove all processes have exited, so it +is not used as the Windows cleanup proof. This implementation does not change the +existing shell tool's process handling. + +## Protocol and output + +Private non-inheritable descriptors carry four-byte big-endian length-prefixed +UTF-8 JSON. Frames are limited to 1 MiB before allocation. Messages carry a worker +generation and, where applicable, execution identity. The separate control reader +can request shutdown while Python is executing a cell. + +User stdin returns EOF. Python text/binary streams produce attributed output; +raw file-descriptor, native, and subprocess writes are captured as unattributed +output. Invalid UTF-8 is replaced. Drain fences deliver already-emitted synchronous +output before the terminal frame; stdout/stderr have no total ordering guarantee. +Late output never modifies a completed outcome and is consumed once by a later +call. Retention is bounded independently of draining: + +- Source: 256 KiB per cell; traceback history: 64 cells or 1 MiB. +- Output chunks: 16 KiB; transport queues: at most 256 KiB plus reserved controls. +- Foreground text: 64 KiB, reserving 16 KiB for the result or traceback. +- Background: 64 KiB with a bounded chunk count. + +Outcomes report generation, execution ID, output, result/error, elapsed time, +dropped bytes, attributed background chunks, and state-loss notices. Custom Python +formatting methods retain native authority and may block or allocate; cancellation +and process termination cover a hung unfinished call, not a memory sandbox. + +## Distribution and checks + +Development and standalone archives use the hash-pinned Python Build Standalone +20260901 CPython 3.13.15 normal-GIL build. Nix packages retain locked nixpkgs' +CPython 3.13.15 closure. Both carry `runtime.json`, `worker.py`, and licenses. The +interpreter launches with `-I -B -u`: project imports are enabled only after worker +bootstrap imports, and bytecode writes cannot mutate signed runtime resources. +The bundle guarantees the standard library; it does not select project virtual +environments or provide a writable shared package installation. + +This is a deliberate simplification from PR #2's optional-IPython proof of concept. +CPython is the only implementation. IPython magics, SDK/controller calls, RLM, +namespace serialization, and optional engines are absent. PR #2 remains historical +evidence of a broader possible system, not the implementation specification. + +From the repository's Nix development shell: + +```sh +just python-test +cargo test -p maple-code-mode --locked +just code-mode-smoke +``` + +Raw Cargo tests consume the prepared fixture and fail if it is missing. Explicit +Nix fixtures use `MAPLE_CODE_MODE_RUNTIME_MANIFEST`. `code-mode-smoke --manifest +PATH` exercises a relocated or signed package with the production resolver and +framed worker. Native Windows/Linux/Intel validation belongs to their CI runners; +a successful macOS ARM64 run is not evidence for those platforms. diff --git a/apps/maple-agent/crates/maple-code-mode/python/test_worker.py b/apps/maple-agent/crates/maple-code-mode/python/test_worker.py new file mode 100644 index 000000000..ff62a3402 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/python/test_worker.py @@ -0,0 +1,391 @@ +"""Focused worker checks against an explicitly prepared package interpreter. + +Run through the repository environment, for example: + nix develop -c target/debug/runtime/python/bin/python3.13 -I \ + crates/maple-code-mode/python/test_worker.py \ + --python /absolute/worktree/target/debug/runtime/python/bin/python3.13 + +There is deliberately no interpreter discovery, download, or missing-fixture +skip. Rust native-worker tests additionally cover host supervision and limits. +""" + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import queue +import signal +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + + +PYTHON = None +WORKER = Path(__file__).with_name("worker.py") + + +class NativeWorker: + def __init__(self, root=None): + self.temporary = tempfile.TemporaryDirectory(prefix="maple-python-ø ") + self.root = str(Path(root or self.temporary.name).resolve()) + self.process = subprocess.Popen( + [str(PYTHON), "-I", "-B", "-u", str(WORKER), "--generation", "7"], + cwd=self.root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=os.name != "nt", + ) + self.messages = queue.Queue(maxsize=128) + self.execution_id = 0 + self.bootstrap = bytearray() + threading.Thread(target=self._reader, daemon=True).start() + threading.Thread(target=self._stderr, daemon=True).start() + self.ready = self.receive() + if self.ready.get("type") != "ready": + raise AssertionError(self.ready) + + def _stderr(self): + while chunk := self.process.stderr.read(4096): + available = max(0, 16384 - len(self.bootstrap)) + self.bootstrap.extend(chunk[:available]) + + def _reader(self): + try: + while True: + header = self.process.stdout.read(4) + if not header: + break + if len(header) != 4: + raise AssertionError("partial frame header") + length = struct.unpack(">I", header)[0] + if not 0 < length <= 1024 * 1024: + raise AssertionError("oversized worker frame") + data = self.process.stdout.read(length) + if len(data) != length: + raise AssertionError("partial frame payload") + message = json.loads(data.decode("utf-8", "strict")) + if message.get("generation") != 7: + raise AssertionError("wrong worker generation") + for name in ("text", "traceback"): + if name in message and len(message[name].encode("utf-8")) > 16384: + raise AssertionError("oversized worker text") + self.messages.put(message) + except BaseException as error: + self.messages.put(error) + finally: + self.messages.put({"type": "eof"}) + + def receive(self, timeout=5): + try: + value = self.messages.get(timeout=timeout) + except queue.Empty: + raise AssertionError( + f"worker receive timeout; exit={self.process.poll()}; " + f"bootstrap={bytes(self.bootstrap)!r}" + ) from None + if isinstance(value, BaseException): + raise value + return value + + def send(self, message): + self.send_bytes(json.dumps(message, ensure_ascii=True).encode("utf-8")) + + def send_bytes(self, data): + self.process.stdin.write(struct.pack(">I", len(data)) + data) + self.process.stdin.flush() + + def begin(self, code): + self.execution_id += 1 + self.send({ + "type": "execute", "generation": 7, + "execution_id": self.execution_id, "code": code, + }) + return self.execution_id + + def collect(self, execution_id=None): + execution_id = execution_id or self.execution_id + retained = [] + retained_bytes = 0 + output_bytes = 0 + while True: + message = self.receive() + if message["type"] == "output": + size = len(message["text"].encode("utf-8")) + output_bytes += size + if retained_bytes + size <= 128 * 1024: + retained.append(message) + retained_bytes += size + else: + retained.append(message) + if message["type"] == "done": + if message["execution_id"] != execution_id: + raise AssertionError("wrong terminal execution") + return retained, output_bytes + if message["type"] in ("fatal", "eof"): + raise AssertionError(message) + + def execute(self, code): + return self.collect(self.begin(code))[0] + + def shutdown(self): + if self.process.poll() is None: + try: + self.send({"type": "shutdown", "generation": 7}) + except (BrokenPipeError, OSError, ValueError): + pass + deadline = time.monotonic() + 4 + while self.process.poll() is None and time.monotonic() < deadline: + try: + self.messages.get(timeout=0.05) + except queue.Empty: + pass + if self.process.poll() is None: + if os.name != "nt": + os.killpg(self.process.pid, signal.SIGKILL) + else: + self.process.kill() + self.process.wait(timeout=3) + for handle in (self.process.stdin, self.process.stdout, self.process.stderr): + handle.close() + self.temporary.cleanup() + + +def result(messages): + return next((item["text"] for item in messages if item["type"] == "result"), None) + + +def output(messages, stream=None): + return "".join( + item["text"] for item in messages + if item["type"] == "output" and (stream is None or item["stream"] == stream) + ) + + +class PackagedWorkerTests(unittest.TestCase): + def setUp(self): + self.worker = NativeWorker() + self.addCleanup(self.worker.shutdown) + + def test_exact_identity_real_main_state_and_eof_stdin(self): + self.assertEqual(self.worker.ready["version"], "3.13.15") + self.assertEqual(self.worker.ready["implementation"], "cpython") + self.assertEqual(Path(self.worker.ready["executable"]).resolve(), PYTHON.resolve()) + self.assertEqual(self.worker.ready["cwd"], self.worker.root) + self.assertEqual(result(self.worker.execute("values = [2, 3, 5]\nsum(values)")), "10") + code = "import __main__, sys\n(values is __main__.values, _, sys.stdin.read(), __name__)" + self.assertEqual(result(self.worker.execute(code)), "(True, 10, '', '__main__')") + self.assertEqual(result(self.worker.execute("def saved(): return values\nsaved()")), "[2, 3, 5]") + + def test_top_level_await_loop_reuse_and_continuous_background_progress(self): + code = """import asyncio +ticks = [] +original_loop = asyncio.get_running_loop() +async def ticking(): + while True: + ticks.append(1) + await asyncio.sleep(0.005) +ticker = asyncio.create_task(ticking()) +await asyncio.sleep(0.01) +len(ticks) +""" + before = int(result(self.worker.execute(code))) + time.sleep(0.06) + messages = self.worker.execute("(len(ticks) > " + str(before) + ", asyncio.get_running_loop() is original_loop)") + self.assertEqual(result(messages), "(True, True)") + self.assertEqual(result(self.worker.execute("import time\nn = len(ticks)\ntime.sleep(0.04)\nlen(ticks) == n")), "True") + + def test_final_expression_is_not_automatically_awaited(self): + messages = self.worker.execute("async def value(): return 42\nvalue()") + self.assertIn("coroutine object value", result(messages)) + self.assertEqual(result(self.worker.execute("await _")), "42") + + def test_python_raw_subprocess_and_binary_outputs_drain_before_done(self): + code = """import os, sys, subprocess +print('python-text') +sys.stdout.buffer.write(b'binary-\\xff-\\xf0\\x9f\\x98\\x80') +os.write(1, b'raw-output') +os.write(2, b'raw-error') +subprocess.run([sys.executable, '-I', '-c', "print('child-output')"], check=True) +""" + messages = self.worker.execute(code) + text = output(messages) + for expected in ("python-text", "binary-�-😀", "raw-output", "raw-error", "child-output"): + self.assertIn(expected, text) + for item in messages: + if item["type"] == "output" and any(fragment in item["text"] for fragment in ("raw-output", "raw-error", "child-output")): + self.assertIsNone(item["execution_id"]) + self.assertEqual(messages[-1]["type"], "done") + + def test_background_output_and_unhandled_exception_keep_origin(self): + code = """import asyncio +async def later(): + await asyncio.sleep(0.04) + print('from-first-cell') + raise ValueError('background-failure') +asyncio.create_task(later()) +None +""" + self.worker.execute(code) + time.sleep(0.08) + messages = self.worker.execute("42") + self.assertIn("from-first-cell", output(messages)) + self.assertIn("background-failure", output(messages)) + for item in messages: + if item["type"] == "output": + self.assertEqual(item["execution_id"], 1) + + def test_binary_utf8_is_incremental_and_incomplete_bytes_flush_before_done(self): + messages = self.worker.execute("import sys\nsys.stdout.buffer.write(b'\\xf0\\x9f')\nsys.stdout.buffer.write(b'\\x98\\x80')\nsys.stdout.buffer.write(b'\\xf0')\nNone") + self.assertEqual(output(messages), "😀�") + self.assertTrue(all(item["execution_id"] == 1 for item in messages if item["type"] == "output")) + + def test_errors_preserve_partial_state_and_traceback_source(self): + messages = self.worker.execute("retained = 17\nraise ValueError('example')") + self.assertEqual(messages[-1]["status"], "error") + error = next(item["traceback"] for item in messages if item["type"] == "error") + self.assertIn("", error) + self.assertIn("raise ValueError('example')", error) + self.assertEqual(result(self.worker.execute("retained")), "17") + syntax = self.worker.execute("def broken(") + self.assertIn("SyntaxError", next(item["traceback"] for item in syntax if item["type"] == "error")) + + def test_output_flood_is_bounded_and_terminal_survives_backpressure(self): + self.worker.begin("import sys\nsys.stdout.write('x' * (16 * 1024 * 1024))\nNone") + time.sleep(0.1) + messages, _ = self.worker.collect() + self.assertEqual(messages[-1]["status"], "ok") + self.assertGreater(messages[-1]["dropped_stdout_bytes"], 0) + later = self.worker.execute("42") + self.assertGreaterEqual(later[-1]["dropped_stdout_bytes"], messages[-1]["dropped_stdout_bytes"]) + + def test_large_representations_and_tracebacks_are_bounded(self): + messages = self.worker.execute("[['x' * 100000] * 1000] * 1000") + self.assertLessEqual(len(result(messages).encode("utf-8")), 16384) + self.assertIn("...", result(messages)) + messages = self.worker.execute("raise ValueError('bad' * 100000)") + error = next(item["traceback"] for item in messages if item["type"] == "error") + self.assertLessEqual(len(error.encode("utf-8")), 16384) + self.assertEqual(messages[-1]["status"], "error") + + def test_project_import_and_two_workers_are_isolated(self): + Path(self.worker.root, "task_module.py").write_text("ANSWER = 93\n") + self.assertEqual(result(self.worker.execute("import task_module\nprivate = 7\ntask_module.ANSWER")), "93") + other = NativeWorker() + self.addCleanup(other.shutdown) + self.assertEqual(result(other.execute("'private' in globals()")), "False") + + def test_shutdown_cancels_active_cell_and_runs_finally(self): + self.worker.begin("import asyncio\ntry:\n await asyncio.sleep(100)\nfinally:\n print('cooperative-finally')") + time.sleep(0.04) + self.worker.send({"type": "shutdown", "generation": 7}) + messages = self.worker.collect()[0] + self.assertEqual(messages[-1]["status"], "cancelled") + self.assertIn("cooperative-finally", output(messages)) + self.worker.process.wait(timeout=3) + + @unittest.skipIf(os.name == "nt", "Unix EOF watchdog is a Unix-specific guarantee") + def test_control_eof_exits_even_with_blocking_foreground(self): + self.worker.begin("while True: pass") + time.sleep(0.04) + self.worker.process.stdin.close() + self.worker.process.wait(timeout=4) + + @unittest.skipUnless(os.name == "nt", "Win32 standard handles require native Windows") + def test_win32_standard_handle_output(self): + code = """import ctypes +k = ctypes.WinDLL('kernel32', use_last_error=True) +k.GetStdHandle.argtypes = [ctypes.c_uint32] +k.GetStdHandle.restype = ctypes.c_void_p +k.WriteFile.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p] +n = ctypes.c_uint32() +data = ctypes.create_string_buffer(b'win32-output') +assert k.WriteFile(k.GetStdHandle(-11 & 0xffffffff), data, 12, ctypes.byref(n), None) +""" + messages = self.worker.execute(code) + self.assertIn("win32-output", output(messages)) + self.assertTrue(all(item["execution_id"] is None for item in messages if item["type"] == "output")) + + +class ProtocolTests(unittest.TestCase): + def assert_fatal(self, payload=None, prefix=None): + worker = NativeWorker() + self.addCleanup(worker.shutdown) + if prefix is not None: + worker.process.stdin.write(prefix) + worker.process.stdin.flush() + else: + worker.send_bytes(payload) + self.assertEqual(worker.receive()["type"], "fatal") + + def test_oversized_length_rejected_without_payload(self): + self.assert_fatal(prefix=struct.pack(">I", 1024 * 1024 + 1)) + + def test_invalid_utf8_rejected(self): + self.assert_fatal(payload=b"\xff") + + def test_unknown_direction_wrong_generation_bool_id_and_duplicate_fields(self): + for payload in ( + b'{"type":"done","generation":7}', + b'{"type":"shutdown","generation":8}', + b'{"type":"execute","generation":7,"execution_id":true,"code":"42"}', + b'{"type":"shutdown","generation":7,"generation":7}', + b'{"type":"execute","generation":7,"execution_id":1,"code":"\\ud800"}', + ): + with self.subTest(payload=payload): + self.assert_fatal(payload=payload) + + def test_source_limit_and_nonincreasing_execution_identity(self): + self.assert_fatal(payload=json.dumps({ + "type": "execute", "generation": 7, + "execution_id": 1, "code": "x" * (256 * 1024 + 1), + }).encode()) + worker = NativeWorker() + self.addCleanup(worker.shutdown) + worker.execute("42") + worker.send({"type": "execute", "generation": 7, "execution_id": 1, "code": "0"}) + self.assertEqual(worker.receive()["type"], "fatal") + + def test_shutdown_immediately_after_admission_still_settles_once(self): + worker = NativeWorker() + self.addCleanup(worker.shutdown) + worker.begin("import asyncio\nawait asyncio.sleep(100)") + worker.send({"type": "shutdown", "generation": 7}) + messages = worker.collect()[0] + self.assertEqual(messages[-1]["status"], "cancelled") + worker.process.wait(timeout=3) + self.assertEqual(worker.receive()["type"], "eof") + + +class BootstrapUnitTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("maple_worker_test", WORKER) + cls.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.module) + + def test_source_cache_enforces_both_bounds(self): + cache = self.module._SourceCache() + for index in range(70): + cache.remember(f"", "x\n", 2) + self.assertEqual(len(cache.entries), 64) + self.assertNotIn("", self.module.linecache.cache) + for index in range(6): + cache.remember(f"", "x" * (256 * 1024), 256 * 1024) + self.assertLessEqual(cache.bytes, 1024 * 1024) + self.assertEqual(len(cache.entries), 4) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--python", type=Path, required=True) + args, remaining = parser.parse_known_args() + if not args.python.is_absolute() or not args.python.is_file(): + parser.error("--python must name an existing absolute package interpreter; run just python-prepare") + PYTHON = args.python + unittest.main(argv=[sys.argv[0], *remaining]) diff --git a/apps/maple-agent/crates/maple-code-mode/python/worker.py b/apps/maple-agent/crates/maple-code-mode/python/worker.py new file mode 100644 index 000000000..8a14f6ac9 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/python/worker.py @@ -0,0 +1,881 @@ +"""Maple's private, packaged CPython worker. This is not a Python sandbox. + +The host launches this file with the package interpreter and ``-I -B -u``. Import +all bootstrap dependencies before making the task directory importable. +""" + +import ast +import asyncio +import builtins +import codecs +import collections +import contextvars +import inspect +import io +import json +import linecache +import os +import platform +import signal +import struct +import sys +import threading +import time +import types + +if os.name == "nt": + import ctypes + import msvcrt + + +PROTOCOL_VERSION = 1 +MAX_FRAME_BYTES = 1024 * 1024 +MAX_SOURCE_BYTES = 256 * 1024 +MAX_OUTPUT_BYTES = 16 * 1024 +MAX_OUTPUT_QUEUE_BYTES = 256 * 1024 +MAX_CONTROL_SLOTS = 8 +MAX_VALUE_BYTES = 16 * 1024 +MAX_SOURCE_CELLS = 64 +MAX_SOURCE_CACHE_BYTES = 1024 * 1024 +MAX_U64 = (1 << 64) - 1 +_EXECUTION_ID = contextvars.ContextVar("maple_execution_id", default=None) + + +def _valid_id(value): + return type(value) is int and 0 < value <= MAX_U64 + + +def _utf8_size_within(text, limit, errors="replace"): + """Check user strings without first allocating an arbitrarily large encode.""" + size = 0 + for offset in range(0, len(text), 4096): + size += len(text[offset : offset + 4096].encode("utf-8", errors)) + if size > limit: + return None + return size + + +class _TextBudget: + def __init__(self, limit=MAX_VALUE_BYTES): + self.remaining = limit + self.parts = [] + self.truncated = False + + def add(self, text): + if not self.remaining: + self.truncated = True + return + # Slicing by characters first bounds the temporary encoding as well. + data = text[: self.remaining].encode("utf-8", "replace") + if len(data) > self.remaining or len(text) > self.remaining: + self.truncated = True + retained = data[: self.remaining].decode("utf-8", "ignore") + self.parts.append(retained) + self.remaining -= len(retained.encode("utf-8")) + + def finish(self): + text = "".join(self.parts) + if self.truncated: + data = text.encode("utf-8") + return data[: max(0, len(data) - 3)].decode("utf-8", "ignore") + "..." + return text + + +def _bounded_repr(value, limit=MAX_VALUE_BYTES): + """Bound ordinary containers cumulatively, including temporary strings. + + Custom repr methods still have native Python authority and can allocate or + block; the host's unfinished-call cancellation also covers this stage. + """ + out = _TextBudget(limit) + seen = set() + + def visit(item, depth): + if not out.remaining: + out.truncated = True + return + kind = type(item) + if kind is str or kind is bytes or kind is bytearray: + maximum = min(len(item), max(1, out.remaining // 4)) + out.add(repr(item[:maximum])) + if maximum < len(item): + out.add("...") + return + if kind not in (list, tuple, dict, set, frozenset): + out.add(repr(item)) + return + if id(item) in seen or depth >= 6: + out.add("...") + return + seen.add(id(item)) + opening, closing = { + list: ("[", "]"), + tuple: ("(", ")"), + dict: ("{", "}"), + set: ("{", "}") if item else ("set(", ")"), + frozenset: ("frozenset({", "})") if item else ("frozenset(", ")"), + }[kind] + out.add(opening) + iterator = iter(item.items()) if kind is dict else iter(item) + for index, child in enumerate(iterator): + if index: + out.add(", ") + if index >= 128 or not out.remaining: + out.add("...") + break + if kind is dict: + visit(child[0], depth + 1) + out.add(": ") + visit(child[1], depth + 1) + else: + visit(child, depth + 1) + if kind is tuple and len(item) == 1: + out.add(",") + out.add(closing) + seen.remove(id(item)) + + visit(value, 0) + return out.finish() + + +def _bounded_traceback(error): + """Format at most 32 frames and four causes, never frame locals. + + Avoid TracebackException's eager conversion of every exception argument and + source line. Large ordinary arguments use the same cumulative repr budget. + """ + out = _TextBudget() + chain = [] + seen = set() + current = error + while current is not None and len(chain) < 4 and id(current) not in seen: + seen.add(id(current)) + chain.append(current) + current = current.__cause__ or ( + None if current.__suppress_context__ else current.__context__ + ) + for chain_index, item in enumerate(reversed(chain)): + if chain_index: + out.add("\nDuring handling of the above exception:\n\n") + out.add("Traceback (most recent call last):\n") + tb = item.__traceback__ + frames = 0 + while tb is not None and frames < 32 and out.remaining: + code = tb.tb_frame.f_code + out.add(' File "') + out.add(code.co_filename[:512]) + out.add('", line ' + str(tb.tb_lineno) + ", in ") + out.add(code.co_name[:256] + "\n") + # Only our bounded source cache is consulted; never read an + # arbitrary source file while formatting a traceback. + cached = linecache.cache.get(code.co_filename) + if cached and type(cached) is tuple and len(cached) == 4: + lines = cached[2] + if type(lines) is list and 0 < tb.tb_lineno <= len(lines): + source = lines[tb.tb_lineno - 1] + if type(source) is str: + out.add(" " + source[:512].strip() + "\n") + tb = tb.tb_next + frames += 1 + if tb is not None: + out.add(" ... additional frames omitted ...\n") + if isinstance(item, SyntaxError): + out.add(" File " + str(item.filename)[:512] + ", line ") + out.add(str(item.lineno) + "\n") + if type(item.text) is str: + out.add(" " + item.text[:512].strip() + "\n") + out.add(type(item).__name__[:256] + ": ") + arguments = item.args + if len(arguments) == 1 and type(arguments[0]) is str: + out.add(arguments[0]) + else: + out.add(_bounded_repr(arguments, max(1, out.remaining))) + out.add("\n") + return out.finish() + + +class _Transport: + """One writer, bounded payload retention, and separately reserved controls. + + FIFO order is intentional: terminal messages cannot overtake accepted + output. At most 256 KiB plus eight small controls can precede a shutdown + result. The host continues draining even when its retained text is full. + """ + + def __init__(self, fd, generation, broken): + self.fd = fd + self.generation = generation + self.broken = broken + self.condition = threading.Condition() + self.queue = collections.deque() + self.output_bytes = 0 + self.control_slots = 0 + self.dropped = {"stdout": 0, "stderr": 0} + self.failed = False + self.thread = threading.Thread(target=self._write_loop, daemon=True) + self.thread.start() + + def output(self, stream, text, execution_id, original_bytes=None): + size = _utf8_size_within(text, MAX_OUTPUT_BYTES) + if size is None: + raise RuntimeError("internal output chunk exceeds limit") + original_bytes = size if original_bytes is None else original_bytes + # The fixed charge bounds Python-object overhead and tiny-write count. + cost = size + 256 + with self.condition: + if self.failed or self.output_bytes + cost > MAX_OUTPUT_QUEUE_BYTES: + self.dropped[stream] = min( + MAX_U64, self.dropped[stream] + original_bytes + ) + return + message = { + "type": "output", + "generation": self.generation, + "execution_id": execution_id, + "stream": stream, + "text": text, + } + self.queue.append((message, cost, None)) + self.output_bytes += cost + self.condition.notify() + + def control(self, message, delivered=None): + message = dict(message, generation=self.generation) + overflow = False + with self.condition: + if self.failed: + return False + if self.control_slots >= MAX_CONTROL_SLOTS: + # A protocol bug must not turn reserved control capacity into + # an unbounded queue or block the control-reader thread. + self.failed = True + overflow = True + else: + self.queue.append((message, 0, delivered)) + self.control_slots += 1 + self.condition.notify() + if overflow: + self.broken() + return False + return True + + def counts(self): + with self.condition: + return dict(self.dropped) + + def _write_loop(self): + try: + while True: + with self.condition: + while not self.queue: + self.condition.wait() + message, cost, delivered = self.queue.popleft() + # Every string is bounded before it reaches this thread. JSON + # escaping can expand a single bounded chunk, never a cell's + # whole output. No encoded frames accumulate in another queue. + encoded = json.dumps( + message, ensure_ascii=True, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + if len(encoded) > MAX_FRAME_BYTES: + raise RuntimeError("internal control frame exceeds limit") + packet = struct.pack(">I", len(encoded)) + encoded + view = memoryview(packet) + while view: + written = os.write(self.fd, view) + if written <= 0: + raise BrokenPipeError() + view = view[written:] + with self.condition: + if cost: + self.output_bytes -= cost + else: + self.control_slots -= 1 + if delivered is not None: + delivered.set() + except BaseException: + with self.condition: + self.failed = True + self.queue.clear() + self.output_bytes = 0 + self.control_slots = 0 + self.broken() + + +class _BinaryOutput(io.RawIOBase): + def __init__(self, transport, stream, descriptor): + self.transport = transport + self.stream = stream + self.descriptor = descriptor + self.lock = threading.RLock() + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.carried = 0 + self.execution_id = None + + def writable(self): + return True + + def fileno(self): + return self.descriptor + + def write(self, data): + view = memoryview(data).cast("B") + execution_id = _EXECUTION_ID.get() + for offset in range(0, len(view), 4096): + chunk = view[offset : offset + 4096] + with self.lock: + if execution_id != self.execution_id: + self.flush() + self.execution_id = execution_id + text = self.decoder.decode(chunk) + pending = len(self.decoder.getstate()[0]) + consumed = len(chunk) + self.carried - pending + self.carried = pending + if text: + self.transport.output(self.stream, text, execution_id, consumed) + return len(view) + + def flush(self): + with self.lock: + text = self.decoder.decode(b"", final=True) + if text: + self.transport.output( + self.stream, text, self.execution_id, self.carried + ) + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.carried = 0 + + +class _TextOutput(io.TextIOBase): + def __init__(self, transport, stream, descriptor): + self.transport = transport + self.stream = stream + self.descriptor = descriptor + self.buffer = _BinaryOutput(transport, stream, descriptor) + + @property + def encoding(self): + return "utf-8" + + @property + def errors(self): + return "replace" + + def writable(self): + return True + + def fileno(self): + return self.descriptor + + def write(self, text): + if not isinstance(text, str): + raise TypeError("write() argument must be str") + self.buffer.flush() + for offset in range(0, len(text), 4096): + chunk = text[offset : offset + 4096].encode("utf-8", "replace") + self.transport.output( + self.stream, chunk.decode("utf-8"), _EXECUTION_ID.get() + ) + return len(text) + + def flush(self): + # At most three bytes of an incomplete binary UTF-8 character remain. + # Flush them with replacement before this stream's drain fence. + self.buffer.flush() + + +class _RawCapture: + def __init__(self, reader, writer, transport, stream): + self.reader = reader + self.writer = writer + self.transport = transport + self.stream = stream + self.marker = b"\x00maple-drain:" + os.urandom(24) + b"\x00" + self.pending_lock = threading.Lock() + self.fence_lock = threading.Lock() + self.pending = None + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.carried = 0 + threading.Thread(target=self._read_loop, daemon=True).start() + + def _emit(self, data, final=False): + text = self.decoder.decode(data, final=final) + pending = len(self.decoder.getstate()[0]) + consumed = len(data) + self.carried - pending + self.carried = pending + if text: + self.transport.output(self.stream, text, None, consumed) + if final: + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + + def _read_loop(self): + carry = b"" + try: + while True: + data = os.read(self.reader, 4096) + if not data: + self._emit(carry, final=True) + return + data = carry + data + while True: + index = data.find(self.marker) + if index >= 0: + self._emit(data[:index], final=True) + with self.pending_lock: + if self.pending is not None: + self.pending.set() + data = data[index + len(self.marker) :] + continue + # Retain only an actual marker-prefix suffix. Ordinary + # output is delivered immediately even between cells. + suffix = min(len(data), len(self.marker) - 1) + while suffix and not data.endswith(self.marker[:suffix]): + suffix -= 1 + if suffix: + self._emit(data[:-suffix]) + carry = data[-suffix:] + else: + self._emit(data) + carry = b"" + break + except BaseException: + self.transport.broken() + + async def fence(self): + # One bounded operation per capture stream; daemon helpers cannot keep + # interpreter exit alive. The main loop never performs a blocking pipe + # write, so shutdown can still cancel a cell during drain. + loop = asyncio.get_running_loop() + completed = loop.create_future() + + def finish(error): + if not completed.done(): + if error is None: + completed.set_result(None) + else: + completed.set_exception(error) + + def run(): + try: + with self.fence_lock: + event = threading.Event() + with self.pending_lock: + self.pending = event + view = memoryview(self.marker) + while view: + size = os.write(self.writer, view) + if size <= 0: + raise BrokenPipeError() + view = view[size:] + event.wait() + with self.pending_lock: + self.pending = None + error = None + except BaseException as caught: + error = caught + try: + loop.call_soon_threadsafe(finish, error) + except RuntimeError: + pass + + threading.Thread(target=run, daemon=True).start() + await completed + + +class _SourceCache: + def __init__(self): + self.entries = collections.OrderedDict() + self.bytes = 0 + + def remember(self, filename, source, size): + linecache.cache[filename] = ( + size, + None, + source.splitlines(keepends=True), + filename, + ) + self.entries[filename] = size + self.bytes += size + while len(self.entries) > MAX_SOURCE_CELLS or self.bytes > MAX_SOURCE_CACHE_BYTES: + oldest, old_size = self.entries.popitem(last=False) + self.bytes -= old_size + linecache.cache.pop(oldest, None) + + +def _read_exact(fd, count, allow_eof=False): + data = bytearray() + while len(data) < count: + chunk = os.read(fd, min(65536, count - len(data))) + if not chunk: + if not data and allow_eof: + return None + raise ValueError("truncated control frame") + data.extend(chunk) + return data + + +def _unique_object(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate control field") + value[key] = item + return value + + +def _invalid_constant(_value): + raise ValueError("invalid JSON constant") + + +class _Worker: + def __init__(self, generation, control_fd, output_fd): + self.generation = generation + self.control_fd = control_fd + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.admission = threading.RLock() + self.active_id = None + self.last_id = 0 + self.last_done_id = 0 + self.retiring = False + self.foreground = None + self.shutdown_task = None + self.watchdog_started = False + self.tasks = set() + self.source_cache = _SourceCache() + self.transport = _Transport(output_fd, generation, self.owner_gone) + self.captures = [] + self.python_streams = [] + module = types.ModuleType("__main__") + module.__dict__.update( + __builtins__=builtins, + __package__=None, + __spec__=None, + __loader__=None, + ) + self.namespace = module.__dict__ + sys.modules["__main__"] = module + self.loop.set_task_factory(self._task_factory) + self.loop.set_exception_handler(self._background_exception) + + def _task_factory(self, loop, coroutine, context=None, **kwargs): + task = asyncio.Task(coroutine, loop=loop, context=context, **kwargs) + task._maple_execution_id = ( + context.get(_EXECUTION_ID) if context is not None else _EXECUTION_ID.get() + ) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard, context=contextvars.Context()) + return task + + def _background_exception(self, _loop, context): + task = context.get("task") or context.get("future") + execution_id = getattr(task, "_maple_execution_id", _EXECUTION_ID.get()) + error = context.get("exception") + try: + if error is not None: + text = "Unhandled background exception:\n" + _bounded_traceback(error) + else: + text = "Background asyncio error: " + str(context.get("message", ""))[:512] + except BaseException: + text = "Unhandled background exception (formatting failed)\n" + # Include the prefix inside the chunk bound and preserve attribution. + for offset in range(0, len(text), 4096): + self.transport.output("stderr", text[offset : offset + 4096], execution_id) + + def install_capture(self): + null = os.open(os.devnull, os.O_RDONLY) + os.dup2(null, 0) + os.close(null) + for descriptor, name in ((1, "stdout"), (2, "stderr")): + reader, writer = os.pipe() + os.set_inheritable(reader, False) + os.set_inheritable(writer, False) + os.dup2(writer, descriptor, inheritable=True) + # Keep the private marker writer independent of user close/dup2. + self.captures.append(_RawCapture(reader, writer, self.transport, name)) + if os.name == "nt": + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.SetStdHandle.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + kernel.SetStdHandle.restype = ctypes.c_int + for descriptor, identifier in ((0, -10), (1, -11), (2, -12)): + msvcrt.setmode(descriptor, os.O_BINARY) + handle = msvcrt.get_osfhandle(descriptor) + if not kernel.SetStdHandle(identifier & 0xFFFFFFFF, handle): + raise ctypes.WinError(ctypes.get_last_error()) + sys.stdin = sys.__stdin__ = io.TextIOWrapper( + io.FileIO(0, "r", closefd=False), encoding="utf-8", errors="replace" + ) + sys.stdout = sys.__stdout__ = _TextOutput(self.transport, "stdout", 1) + sys.stderr = sys.__stderr__ = _TextOutput(self.transport, "stderr", 2) + self.python_streams = [sys.stdout, sys.stderr] + + def owner_gone(self): + with self.admission: + self.retiring = True + if not self.watchdog_started: + self.watchdog_started = True + + def watchdog(): + time.sleep(2) + # The group assertion avoids ever targeting a caller's + # group when a developer invokes the script directly. + if os.name != "nt" and os.getpgrp() == os.getpid(): + try: + os.killpg(os.getpgrp(), signal.SIGKILL) + except OSError: + pass + os._exit(1) + + threading.Thread(target=watchdog, daemon=True).start() + try: + self.loop.call_soon_threadsafe(self._schedule_shutdown) + except RuntimeError: + pass + + def _fatal(self, message): + self.transport.control({"type": "fatal", "message": message[:1024]}) + with self.admission: + self.retiring = True + self.loop.call_soon_threadsafe(self._schedule_shutdown) + + def _control_loop(self): + try: + while True: + prefix = _read_exact(self.control_fd, 4, allow_eof=True) + if prefix is None: + self.owner_gone() + return + size = struct.unpack(">I", prefix)[0] + if not 0 < size <= MAX_FRAME_BYTES: + raise ValueError("control frame length outside limit") + encoded = _read_exact(self.control_fd, size) + message = json.loads( + encoded.decode("utf-8", "strict"), + object_pairs_hook=_unique_object, + parse_constant=_invalid_constant, + ) + if type(message) is not dict: + raise ValueError("control message must be an object") + if not _valid_id(message.get("generation")) or message["generation"] != self.generation: + raise ValueError("control generation mismatch") + kind = message.get("type") + if kind == "shutdown": + if set(message) != {"type", "generation"}: + raise ValueError("invalid shutdown fields") + with self.admission: + if self.retiring: + raise ValueError("duplicate shutdown") + self.retiring = True + self.loop.call_soon_threadsafe(self._schedule_shutdown) + # Continue watching EOF independently of the loop, but + # reject every subsequent control frame. + continue + if kind != "execute" or set(message) != { + "type", "generation", "execution_id", "code" + }: + raise ValueError("invalid control direction or fields") + execution_id = message["execution_id"] + source = message["code"] + if not _valid_id(execution_id) or type(source) is not str: + raise ValueError("invalid execution identity or source type") + source_size = _utf8_size_within(source, MAX_SOURCE_BYTES, errors="strict") + if source_size is None: + raise ValueError("cell source exceeds limit") + with self.admission: + if self.retiring or self.active_id is not None: + raise ValueError("execution admitted while busy or retiring") + if execution_id <= self.last_id: + raise ValueError("execution identity must increase") + self.last_id = execution_id + self.active_id = execution_id + self.loop.call_soon_threadsafe( + self._start_cell, execution_id, source, source_size + ) + except BaseException as error: + # Never echo malformed input, arbitrary exception repr, or source. + message = str(error) if type(error) is ValueError else "invalid control frame" + self._fatal(message) + + def _start_cell(self, execution_id, source, source_size): + context = contextvars.copy_context() + context.run(_EXECUTION_ID.set, execution_id) + self.foreground = self.loop.create_task( + self._cell(execution_id, source, source_size), context=context + ) + self.foreground.add_done_callback( + lambda task: self._cell_finished(task, execution_id), + context=contextvars.Context(), + ) + + def _cell_finished(self, task, execution_id): + # Cancellation can win before the coroutine executes its first line. + # Keep the same exactly-once terminal path for that admission race. + with self.admission: + if execution_id <= self.last_done_id: + return + status = "cancelled" if task.cancelled() else "error" + if not task.cancelled(): + task.exception() + self._finish_cell(execution_id, status, 0) + + def _finish_cell(self, execution_id, status, elapsed_ms): + dropped = self.transport.counts() + with self.admission: + if execution_id <= self.last_done_id: + return + self.last_done_id = execution_id + self.active_id = None + self.transport.control({ + "type": "done", "execution_id": execution_id, "status": status, + "elapsed_ms": elapsed_ms, + "dropped_stdout_bytes": dropped["stdout"], + "dropped_stderr_bytes": dropped["stderr"], + }) + + async def _drain(self): + for stream in self.python_streams: + stream.flush() + await asyncio.gather(*(capture.fence() for capture in self.captures)) + + async def _cell(self, execution_id, source, source_size): + started = time.monotonic_ns() + status = "ok" + filename = f"" + result_name = "__maple_cell_value_" + os.urandom(12).hex() + try: + if self.retiring: + raise asyncio.CancelledError() + self.source_cache.remember(filename, source, source_size) + tree = compile( + source, filename, "exec", + flags=ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + dont_inherit=True, + ) + has_value = bool(tree.body and isinstance(tree.body[-1], ast.Expr)) + if has_value: + expression = tree.body[-1] + assignment = ast.Assign( + targets=[ast.Name(id=result_name, ctx=ast.Store())], + value=expression.value, + ) + tree.body[-1] = ast.copy_location(assignment, expression) + ast.fix_missing_locations(tree) + code = compile( + tree, filename, "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, dont_inherit=True, + ) + value = eval(code, self.namespace, self.namespace) + if code.co_flags & inspect.CO_COROUTINE: + await value + if has_value: + value = self.namespace.pop(result_name, None) + if value is not None: + self.namespace["_"] = value + self.transport.control({ + "type": "result", "execution_id": execution_id, + "text": _bounded_repr(value), + }) + except asyncio.CancelledError: + status = "cancelled" + except BaseException as error: + status = "error" + try: + formatted = _bounded_traceback(error) + except BaseException: + formatted = "Python exception (traceback formatting failed)" + self.transport.control({ + "type": "error", "execution_id": execution_id, + "traceback": formatted, + }) + finally: + self.namespace.pop(result_name, None) + try: + await self._drain() + except asyncio.CancelledError: + status = "cancelled" + except BaseException: + status = "error" + self._fatal("native output drain failed") + elapsed_ms = min(MAX_U64, max(0, (time.monotonic_ns() - started) // 1_000_000)) + self._finish_cell(execution_id, status, elapsed_ms) + + def _schedule_shutdown(self): + if self.shutdown_task is None: + self.shutdown_task = self.loop.create_task(self._shutdown()) + + async def _shutdown(self): + current = asyncio.current_task() + pending = [task for task in self.tasks if task is not current and not task.done()] + for task in pending: + task.cancel() + if pending: + # The host owns the single two-second grace. This shorter bounded + # attempt leaves time for drains and normal process exit handlers. + await asyncio.wait(pending, timeout=0.75) + async_generators = self.loop.create_task(self.loop.shutdown_asyncgens()) + await asyncio.wait([async_generators], timeout=0.25) + if not async_generators.done(): + async_generators.cancel() + try: + await asyncio.wait_for(self._drain(), timeout=0.25) + except BaseException: + pass + # A delivery fence uses an existing message rather than extending the + # wire protocol. If there is no queued control, all accepted output can + # still be observed through this local transport barrier. + await self._flush_transport() + self.loop.stop() + + async def _flush_transport(self): + deadline = time.monotonic() + 0.25 + while time.monotonic() < deadline: + with self.transport.condition: + if not self.transport.output_bytes and not self.transport.control_slots: + return + await asyncio.sleep(0.005) + + def run(self): + self.install_capture() + root = os.getcwd() + if _utf8_size_within(root, MAX_VALUE_BYTES) is None or _utf8_size_within(sys.executable, MAX_VALUE_BYTES) is None: + raise RuntimeError("runtime identity exceeds metadata limit") + # -I intentionally keeps user/site environment discovery disabled; only + # the immutable launch directory is made available for project imports. + sys.path.insert(0, root) + ready = threading.Event() + self.transport.control({ + "type": "ready", "protocol_version": PROTOCOL_VERSION, + "implementation": sys.implementation.name, + "version": platform.python_version(), "executable": sys.executable, + "cwd": root, + }, delivered=ready) + ready.wait() + threading.Thread(target=self._control_loop, daemon=True).start() + try: + self.loop.run_forever() + finally: + self.loop.close() + + +def main(): + if len(sys.argv) != 3 or sys.argv[1] != "--generation": + raise SystemExit("usage: worker.py --generation POSITIVE_U64") + try: + generation = int(sys.argv[2]) + except ValueError: + raise SystemExit("generation must be a positive u64") from None + if not _valid_id(generation): + raise SystemExit("generation must be a positive u64") + control_fd = os.dup(0) + output_fd = os.dup(1) + # Windows duplicates of standard streams have a documented inheritance + # exception. Set these explicitly on both platforms before any user code. + os.set_inheritable(control_fd, False) + os.set_inheritable(output_fd, False) + if os.name == "nt": + msvcrt.setmode(control_fd, os.O_BINARY) + msvcrt.setmode(output_fd, os.O_BINARY) + _Worker(generation, control_fd, output_fd).run() + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs b/apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs new file mode 100644 index 000000000..69fd3e3e8 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs @@ -0,0 +1,76 @@ +//! Debug/package acceptance probe. Uses the shipped resolver and framed worker. +use maple_code_mode::{Config, LaunchSpec, OutcomeStatus, PackagedPython, Runtime}; +use std::{ + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio_util::sync::CancellationToken; + +struct Scratch(PathBuf); +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + let python = match args.as_slice() { + [flag, path] if flag == "--manifest" => PackagedPython::from_manifest(path)?, + [] => match std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") { + Some(path) => PackagedPython::from_manifest(path)?, + None => PackagedPython::for_application_executable(std::env::current_exe()?)?, + }, + _ => return Err("usage: code-mode-smoke [--manifest PATH]".into()), + }; + let directory = std::env::temp_dir().join(format!( + "Maple Python smoke é {}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() + )); + std::fs::create_dir(&directory)?; + let scratch = Scratch(directory.canonicalize()?); + let runtime = Runtime::new(Config::default()); + let task = runtime.bind( + "smoke", + LaunchSpec { + python, + cwd: scratch.0.clone(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + )?; + let probe = async { + let first = task.execute( + "import ssl, sqlite3, ctypes, zlib, bz2, lzma, asyncio, __main__\nassert __main__.__dict__ is globals()\nloop = asyncio.get_running_loop()\nanswer = 40\nprint('bundled stdlib imports passed')", + CancellationToken::new(), + ).await?; + if first.status != OutcomeStatus::Ok { + return Err(format!("stdlib probe failed: {:?}", first.traceback).into()); + } + let second = task + .execute( + "assert asyncio.get_running_loop() is loop\nawait asyncio.sleep(0)\nanswer + 2", + CancellationToken::new(), + ) + .await?; + if second.status != OutcomeStatus::Ok || second.value.as_deref() != Some("42") { + return Err(format!("persistent async probe failed: {:?}", second.traceback).into()); + } + let identity = first + .runtime + .ok_or("worker did not report its runtime identity")?; + println!( + "CPython {} ({}) at {}: native worker smoke passed", + identity.version, + identity.distribution, + identity.executable.display() + ); + Ok::<(), Box>(()) + }; + let result = tokio::time::timeout(Duration::from_secs(30), probe).await; + runtime.shutdown("smoke complete").await?; + result??; + Ok(()) +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/lib.rs b/apps/maple-agent/crates/maple-code-mode/src/lib.rs new file mode 100644 index 000000000..bed12ea09 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/lib.rs @@ -0,0 +1,150 @@ +//! Task-lived, bundled CPython execution. This crate owns processes, not host authority. +mod output; +mod process; +mod protocol; +mod resources; +mod worker; + +pub use output::{BackgroundChunk, BackgroundOutput}; +pub use resources::{PackagedPython, RuntimeManifest}; +pub use worker::{Execution, Runtime, TaskHandle}; + +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, ffi::OsString, path::PathBuf, time::Duration}; + +pub const MAX_WORKERS: usize = 4; +pub const MAX_SOURCE_BYTES: usize = 256 * 1024; +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; +pub const MAX_OUTPUT_CHUNK_BYTES: usize = 16 * 1024; +pub const OUTPUT_QUEUE_BYTES: usize = 256 * 1024; +pub const FOREGROUND_BYTES: usize = 64 * 1024; +pub const FINAL_BYTES: usize = 16 * 1024; +pub const BACKGROUND_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug)] +pub struct Config { + pub startup_timeout: Duration, + pub retirement_grace: Duration, + pub cleanup_timeout: Duration, +} +impl Default for Config { + fn default() -> Self { + Self { + startup_timeout: Duration::from_secs(10), + retirement_grace: Duration::from_secs(2), + cleanup_timeout: Duration::from_secs(5), + } + } +} + +/// Fully prepared, immutable launch configuration. No PATH discovery happens here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LaunchSpec { + pub python: PackagedPython, + pub cwd: PathBuf, + pub env: BTreeMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkerPhase { + Empty, + Starting, + Idle, + Executing, + Retiring, + CleanupPending, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskStatus { + pub generation: Option, + pub phase: WorkerPhase, + pub closed: bool, + pub state_loss_reason: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Holder { + pub key: String, + pub generation: u64, + pub phase: WorkerPhase, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CapacitySnapshot { + pub limit: usize, + pub holders: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ResetOutcome { + pub retired_generation: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RuntimeIdentity { + pub implementation: String, + pub version: String, + pub executable: PathBuf, + pub cwd: PathBuf, + pub distribution: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OutcomeStatus { + Ok, + Error, + Cancelled, + WorkerLost, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Outcome { + pub generation: u64, + pub execution_id: u64, + pub status: OutcomeStatus, + pub stdout: String, + pub stderr: String, + pub value: Option, + pub traceback: Option, + pub elapsed_ms: u64, + pub dropped_stdout_bytes: u64, + pub dropped_stderr_bytes: u64, + pub background: BackgroundOutput, + pub state_loss_reason: Option, + pub runtime: Option, + pub cleanup_pending: bool, +} + +#[derive(Clone, Debug)] +pub enum Error { + Busy, + Cancelled, + Retired, + CleanupPending, + Capacity { holders: Vec }, + InvalidInput(String), + LaunchMismatch, + Unavailable(String), + WorkerLost(String), +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Busy => f.write_str("Python is already executing a cell for this task"), + Self::Cancelled => f.write_str("Python execution was cancelled before admission"), + Self::Retired => f.write_str("This Python task binding has been retired"), + Self::CleanupPending => { + f.write_str("Python cleanup is pending; this worker still occupies capacity") + } + Self::Capacity { holders } => write!( + f, + "All {MAX_WORKERS} Python worker slots are occupied ({} retained); reset Python or close an owning session", + holders.len() + ), + Self::InvalidInput(message) + | Self::Unavailable(message) + | Self::WorkerLost(message) => f.write_str(message), + Self::LaunchMismatch => f.write_str( + "The task's Python launch configuration changed; retire its old binding first", + ), + } + } +} +impl std::error::Error for Error {} diff --git a/apps/maple-agent/crates/maple-code-mode/src/output.rs b/apps/maple-agent/crates/maple-code-mode/src/output.rs new file mode 100644 index 000000000..fad45fee1 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/output.rs @@ -0,0 +1,115 @@ +use crate::protocol::Stream; +use crate::{BACKGROUND_BYTES, FINAL_BYTES, FOREGROUND_BYTES}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +pub(crate) fn prefix(text: &str, limit: usize) -> &str { + let mut end = text.len().min(limit); + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BackgroundChunk { + pub execution_id: Option, + pub stream: String, + pub text: String, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct BackgroundOutput { + pub chunks: Vec, + pub dropped_stdout_bytes: u64, + pub dropped_stderr_bytes: u64, +} +#[derive(Default)] +pub(crate) struct Capture { + pub stdout: String, + pub stderr: String, + pub dropped_stdout: u64, + pub dropped_stderr: u64, +} +impl Capture { + pub fn push(&mut self, stream: Stream, text: &str) { + let available = + (FOREGROUND_BYTES - FINAL_BYTES).saturating_sub(self.stdout.len() + self.stderr.len()); + let kept = prefix(text, available); + let (output, dropped) = match stream { + Stream::Stdout => (&mut self.stdout, &mut self.dropped_stdout), + Stream::Stderr => (&mut self.stderr, &mut self.dropped_stderr), + }; + output.push_str(kept); + *dropped = dropped.saturating_add((text.len() - kept.len()) as u64); + } +} +#[derive(Default)] +pub(crate) struct BackgroundRing { + chunks: VecDeque, + bytes: usize, + dropped_stdout: u64, + dropped_stderr: u64, +} +impl BackgroundRing { + pub fn push(&mut self, execution_id: Option, stream: Stream, text: String) { + if text.is_empty() { + return; + } + self.bytes += text.len(); + self.chunks.push_back(BackgroundChunk { + execution_id, + stream: stream.as_str().into(), + text, + }); + while self.bytes > BACKGROUND_BYTES || self.chunks.len() > 256 { + let removed = self.chunks.pop_front().unwrap(); + self.bytes -= removed.text.len(); + let dropped = if removed.stream == "stdout" { + &mut self.dropped_stdout + } else { + &mut self.dropped_stderr + }; + *dropped = dropped.saturating_add(removed.text.len() as u64); + } + } + pub fn take(&mut self) -> BackgroundOutput { + self.bytes = 0; + BackgroundOutput { + chunks: self.chunks.drain(..).collect(), + dropped_stdout_bytes: std::mem::take(&mut self.dropped_stdout), + dropped_stderr_bytes: std::mem::take(&mut self.dropped_stderr), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn aggregate_capture_reserves_final_space_at_utf8_boundary() { + let mut capture = Capture::default(); + let text = "😀".repeat(20_000); + capture.push(Stream::Stdout, &text); + capture.push(Stream::Stderr, &text); + assert_eq!( + capture.stdout.len() + capture.stderr.len(), + FOREGROUND_BYTES - FINAL_BYTES + ); + assert_eq!( + capture.dropped_stdout + capture.dropped_stderr, + (text.len() * 2 - (FOREGROUND_BYTES - FINAL_BYTES)) as u64 + ); + } + #[test] + fn ring_limits_tiny_chunk_metadata_and_is_consumed_once() { + let mut ring = BackgroundRing::default(); + for id in 0..1_000 { + ring.push(Some(id), Stream::Stdout, "x".into()); + } + let output = ring.take(); + assert_eq!(output.chunks.len(), 256); + assert_eq!(output.dropped_stdout_bytes, 744); + assert!(ring.take().chunks.is_empty()); + assert_eq!(ring.take().dropped_stdout_bytes, 0); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/process.rs b/apps/maple-agent/crates/maple-code-mode/src/process.rs new file mode 100644 index 000000000..f1f12401e --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/process.rs @@ -0,0 +1,689 @@ +//! Concrete ownership of one Python worker and its ordinary descendants. +//! +//! Caller deadlines observe a watch channel. They never cancel the supervisor's +//! platform cleanup wait or turn a reaped leader into a completed cleanup. + +use std::{future::pending, io, process::Stdio, time::Duration}; + +use process_wrap::tokio::{ChildWrapper, CommandWrap, KillOnDrop}; +use tokio::{ + process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}, + sync::watch, + time::Instant, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ProcessExit { + pub code: Option, + /// The worker needed termination rather than exiting during the grace period. + pub forced: bool, + /// Windows assignment/resume failed, but subsequent cleanup is confirmed. + pub startup_error: Option, +} + +pub(crate) struct SpawnedProcess { + pub stdin: ChildStdin, + pub stdout: ChildStdout, + pub stderr: ChildStderr, + pub control: ProcessControl, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProcessControl { + pid: u32, + grace: Duration, + retirement: watch::Sender, + cleanup: watch::Receiver, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Retirement { + deadline: Option, +} + +#[derive(Clone, Debug)] +enum Cleanup { + Pending, + Complete(ProcessExit), + /// Ownership and capacity must be retained after this observation. + Failed(String), +} + +impl ProcessControl { + pub(crate) fn pid(&self) -> u32 { + self.pid + } + + /// Start the single grace window. Repeated requests never extend it. + /// The worker layer sends the protocol shutdown request separately. + pub(crate) fn retire(&self) { + self.retirement.send_if_modified(|state| { + if state.deadline.is_none() { + state.deadline = Some(Instant::now() + self.grace); + true + } else { + false + } + }); + } + + /// Only complete platform cleanup is an exit snapshot. + #[cfg(all(test, unix))] + pub(crate) fn exited(&self) -> Option { + match &*self.cleanup.borrow() { + Cleanup::Complete(exit) => Some(exit.clone()), + Cleanup::Pending | Cleanup::Failed(_) => None, + } + } + + /// An error means cleanup remains unconfirmed, not that capacity is free. + /// A failed startup with confirmed cleanup is `Ok` with `startup_error` set. + pub(crate) async fn cleanup(&self) -> Result { + let mut cleanup = self.cleanup.clone(); + loop { + match cleanup.borrow_and_update().clone() { + Cleanup::Complete(exit) => return Ok(exit), + Cleanup::Failed(error) => return Err(error), + Cleanup::Pending => {} + } + cleanup.changed().await.map_err(|_| { + "Python process supervisor stopped before confirming cleanup".to_owned() + })?; + } + } +} + +pub(crate) fn spawn(mut command: Command, grace: Duration) -> io::Result { + // Check this before spawning: installing supervision must never panic because + // the caller invoked the synchronous launch fence outside a Tokio runtime. + let runtime = tokio::runtime::Handle::try_current().map_err(io::Error::other)?; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut command = CommandWrap::from(command); + command.wrap(KillOnDrop); + #[cfg(unix)] + command.wrap(process_wrap::tokio::ProcessGroup::leader()); + + #[cfg(windows)] + let job = { + use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; + // Suspended creation makes assignment precede all worker execution. + command.wrap(process_wrap::tokio::CreationFlags( + CREATE_NO_WINDOW | CREATE_SUSPENDED, + )); + windows_job::Job::new()? + }; + + let child = command.spawn()?; + // These are invariants of a freshly spawned, unpolled Tokio child whose + // three streams were configured above. No fallible setup after spawn may + // return an unowned child to the caller. + let pid = child.id().expect("fresh Python worker has a process ID"); + let mut owner = OwnedProcess { + child, + #[cfg(unix)] + group: GroupGuard { pid, armed: true }, + #[cfg(windows)] + job, + }; + let stdin = owner.child.stdin().take().expect("Python stdin is piped"); + let stdout = owner.child.stdout().take().expect("Python stdout is piped"); + let stderr = owner.child.stderr().take().expect("Python stderr is piped"); + + #[cfg(unix)] + let startup_error = None; + #[cfg(windows)] + let startup_error = owner + .job + .assign_and_resume(owner.child.inner_child(), pid) + .err() + .map(|error| format!("Python Job assignment or resume failed: {error}")); + + let control = install(owner, pid, grace, startup_error, &runtime); + Ok(SpawnedProcess { + stdin, + stdout, + stderr, + control, + }) +} + +fn install( + owner: OwnedProcess, + pid: u32, + grace: Duration, + startup_error: Option, + runtime: &tokio::runtime::Handle, +) -> ProcessControl { + let (retirement, requests) = watch::channel(Retirement::default()); + let (completion, cleanup) = watch::channel(Cleanup::Pending); + // Dropping this JoinHandle detaches, never aborts. The future already owns + // the armed group/job and child before the launch fence can be released. + runtime.spawn(supervise(owner, requests, completion, grace, startup_error)); + ProcessControl { + pid, + grace, + retirement, + cleanup, + } +} + +struct OwnedProcess { + // Drop the group/job protection before releasing the direct-child handle. + #[cfg(unix)] + group: GroupGuard, + #[cfg(windows)] + job: windows_job::Job, + child: Box, +} + +impl OwnedProcess { + fn raw_child(&mut self) -> &mut Child { + // SAFETY: The only production wrappers are KillOnDrop plus Unix + // ProcessGroup (Windows has no child wrapper). Tokio's cancellation-safe + // leader wait leaves its cached status available to the ONE later group + // wait. We do not mutate wrapper state, unwrap it, or call try_wait(). + unsafe { self.child.inner_child_mut() } + } + + fn terminate(&mut self) -> io::Result<()> { + #[cfg(unix)] + let descendants = self.group.terminate(); + #[cfg(windows)] + let descendants = self.job.terminate(); + // Also reaches the direct worker if user code deliberately left the + // Unix group, or Windows assignment failed while it was suspended. + let leader = self.raw_child().start_kill(); + descendants.and(leader) + } + + async fn wait_cleanup(&mut self) -> io::Result { + // Never race, timeout, drop and restart this future. process-wrap 9.1.0 + // caches leader status before finishing ProcessGroupChild::wait(). + let status = self.child.wait().await?; + #[cfg(windows)] + self.job.wait_empty().await?; + Ok(status) + } + + fn disarm(&mut self) { + #[cfg(unix)] + { + self.group.armed = false; + } + // Windows closes an already-empty Job, keeping kill-on-close enabled. + } +} + +async fn supervise( + mut owner: OwnedProcess, + mut requests: watch::Receiver, + completion: watch::Sender, + grace: Duration, + startup_error: Option, +) { + let result = async { + let observed = if startup_error.is_some() { + Ok(true) + } else { + observe_leader(&mut owner, &mut requests, grace).await + }; + // A successful or spontaneous leader exit still terminates remaining + // ordinary descendants. Successful foreground execution is not a reason + // to disarm this worker's process ownership. + let termination = owner.terminate(); + let forced = observed.map_err(|error| format!("Python leader wait failed: {error}"))?; + termination.map_err(|error| format!("Python group/Job termination failed: {error}"))?; + let status = owner + .wait_cleanup() + .await + .map_err(|error| format!("Python platform cleanup wait failed: {error}"))?; + Ok::<_, String>(ProcessExit { + code: status.code(), + forced, + startup_error, + }) + } + .await; + + match result { + Ok(exit) => { + owner.disarm(); + completion.send_replace(Cleanup::Complete(exit)); + } + Err(error) => { + log::error!( + "Python cleanup remains pending (pid {:?}): {error}", + owner.child.id() + ); + completion.send_replace(Cleanup::Failed(error)); + // A failed group/Job wait cannot safely be restarted using cached + // leader status. Keep the actual owner alive, along with its armed + // drop protection. The service must also retain the capacity permit. + // Runtime shutdown drops this future and invokes that protection; + // it does not convert a cleanup failure to a confirmed exit. + pending::<()>().await; + } + } + drop(owner); +} + +async fn observe_leader( + owner: &mut OwnedProcess, + requests: &mut watch::Receiver, + grace: Duration, +) -> io::Result { + let mut closed = false; + let mut dropped_owner_deadline = None; + loop { + let request = *requests.borrow_and_update(); + let deadline = request.deadline.or(dropped_owner_deadline); + tokio::select! { + biased; + // Only the raw Tokio wait is cancelled by a request. It is explicitly + // cancellation-safe, unlike the later process-wrap group wait. + exit = owner.raw_child().wait() => return exit.map(|_| false), + change = requests.changed(), if !closed => { + if change.is_err() { + closed = true; + dropped_owner_deadline = Some(Instant::now() + grace); + } + } + _ = wait_deadline(deadline) => return Ok(true), + } + } +} + +async fn wait_deadline(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => pending().await, + } +} + +#[cfg(unix)] +struct GroupGuard { + pid: u32, + armed: bool, +} + +#[cfg(unix)] +impl GroupGuard { + fn terminate(&mut self) -> io::Result<()> { + // SAFETY: This is the positive PID of our freshly created group leader; + // negation selects that group, never the host's group or every process. + let result = unsafe { libc::kill(-(self.pid as i32), libc::SIGKILL) }; + if result == 0 { + // Do not retain a numeric PGID for another kill after its processes + // have exited: the kernel can reuse it. Completion still requires + // the separately retained wait, even if that wait later fails. + self.armed = false; + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + self.armed = false; + Ok(()) + } else { + Err(error) + } + } +} + +#[cfg(unix)] +impl Drop for GroupGuard { + fn drop(&mut self) { + if self.armed { + let _ = self.terminate(); + } + } +} + +#[cfg(windows)] +mod windows_job { + use std::{io, mem::size_of, time::Duration}; + + use tokio::process::Child; + use windows::Win32::{ + Foundation::{CloseHandle, ERROR_NO_MORE_FILES, HANDLE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, + Thread32Next, + }, + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectBasicAccountingInformation, JobObjectExtendedLimitInformation, + QueryInformationJobObject, SetInformationJobObject, TerminateJobObject, + }, + Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}, + }, + }; + + struct Handle(HANDLE); + + // SAFETY: This owned OS handle has no thread affinity. All accesses are + // serialized by its owning supervisor, and it is closed exactly once. + unsafe impl Send for Handle {} + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: The handle was returned by a successful Win32 creation call. + let _ = unsafe { CloseHandle(self.0) }; + } + } + + pub(super) struct Job(Handle); + + impl Job { + pub(super) fn new() -> io::Result { + // SAFETY: No external pointers or inherited handles are supplied. + let job = Self(Handle(unsafe { CreateJobObjectW(None, None) }?)); + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: The pointer and byte count describe this initialized struct. + unsafe { + SetInformationJobObject( + job.0.0, + JobObjectExtendedLimitInformation, + &information as *const _ as _, + size_of::() as u32, + ) + }?; + Ok(job) + } + + pub(super) fn assign_and_resume(&self, child: &Child, pid: u32) -> io::Result<()> { + let process = child + .raw_handle() + .ok_or_else(|| io::Error::other("suspended worker has no process handle"))?; + // SAFETY: Both handles remain owned throughout assignment. The child + // was created suspended, so it cannot create a descendant before this. + unsafe { AssignProcessToJobObject(self.0.0, HANDLE(process)) }?; + resume_initial_thread(pid) + } + + pub(super) fn terminate(&self) -> io::Result<()> { + // SAFETY: This is our owned Job, containing only this worker tree. + unsafe { TerminateJobObject(self.0.0, 1) }.map_err(io::Error::other) + } + + pub(super) async fn wait_empty(&mut self) -> io::Result<()> { + // process-wrap 9.1.0's Job wait accepts any completion-port packet, + // including NEW_PROCESS, as completion. Instead, after termination + // and direct-child wait, observe this owned Job's actual active count. + // Zero proves the Job is empty; errors preserve pending ownership. + loop { + let mut information = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: The output buffer is valid for the declared class/size. + unsafe { + QueryInformationJobObject( + Some(self.0.0), + JobObjectBasicAccountingInformation, + &mut information as *mut _ as _, + size_of::() as u32, + None, + ) + }?; + if information.ActiveProcesses == 0 { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + + fn resume_initial_thread(pid: u32) -> io::Result<()> { + // Tokio does not expose the primary thread handle. A suspended fresh + // process cannot start additional threads before this assignment/resume. + // Enumerate the snapshot before resuming to avoid touching later threads. + let snapshot = Handle(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?); + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + unsafe { Thread32First(snapshot.0, &mut entry) }?; + let mut threads = Vec::new(); + loop { + if entry.th32OwnerProcessID == pid { + threads.push(Handle(unsafe { + OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) + }?)); + } + if let Err(error) = unsafe { Thread32Next(snapshot.0, &mut entry) } { + if error.code() == ERROR_NO_MORE_FILES.to_hresult() { + break; + } + return Err(io::Error::other(error)); + } + } + if threads.is_empty() { + return Err(io::Error::other("suspended worker has no thread to resume")); + } + for thread in threads { + // SAFETY: These thread handles belong to the suspended worker only. + if unsafe { ResumeThread(thread.0) } == u32::MAX { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::{ + future::Future, + pin::Pin, + process::ExitStatus, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + }; + + use tokio::{io::AsyncReadExt, sync::Notify, time::timeout}; + + use super::*; + + #[derive(Debug)] + struct DelayedWait { + inner: Box, + cached: Option, + started: Arc, + release: Arc, + calls: Arc, + dropped: Arc, + fail: bool, + } + + impl Drop for DelayedWait { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + + impl ChildWrapper for DelayedWait { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner.as_mut() + } + + fn into_inner(self: Box) -> Box { + panic!("the supervisor must retain its wrapper") + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { + // Deliberately reproduce process-wrap's early leader cache. A + // cancelled/restarted wait would skip the remaining cleanup. + if let Some(status) = self.cached { + return Ok(status); + } + let status = self.inner.wait().await?; + self.cached = Some(status); + self.started.notify_one(); + self.release.notified().await; + if self.fail { + return Err(io::Error::other("injected remaining platform wait failure")); + } + Ok(status) + }) + } + } + + struct WaitProbe { + control: ProcessControl, + started: Arc, + release: Arc, + calls: Arc, + dropped: Arc, + } + + fn delayed_wait(fail: bool) -> WaitProbe { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exit 0"); + let mut command = CommandWrap::from(command); + command + .wrap(KillOnDrop) + .wrap(process_wrap::tokio::ProcessGroup::leader()); + let child = command.spawn().unwrap(); + let pid = child.id().unwrap(); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let owner = OwnedProcess { + child: Box::new(DelayedWait { + inner: child, + cached: None, + started: started.clone(), + release: release.clone(), + calls: calls.clone(), + dropped: dropped.clone(), + fail, + }), + group: GroupGuard { pid, armed: true }, + }; + let control = install( + owner, + pid, + Duration::from_millis(20), + None, + &tokio::runtime::Handle::current(), + ); + WaitProbe { + control, + started, + release, + calls, + dropped, + } + } + + #[tokio::test] + async fn caller_timeouts_do_not_restart_the_remaining_platform_wait() { + let probe = delayed_wait(false); + timeout(Duration::from_secs(3), probe.started.notified()) + .await + .unwrap(); + for _ in 0..2 { + assert!( + timeout(Duration::from_millis(20), probe.control.cleanup()) + .await + .is_err() + ); + assert!(probe.control.exited().is_none()); + } + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + assert!(!probe.dropped.load(Ordering::SeqCst)); + probe.release.notify_one(); + let exit = timeout(Duration::from_secs(3), probe.control.cleanup()) + .await + .unwrap() + .unwrap(); + assert_eq!(exit.code, Some(0)); + assert!(!exit.forced); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn failed_remaining_wait_retains_owner_and_never_reports_cached_leader_exit() { + let probe = delayed_wait(true); + timeout(Duration::from_secs(3), probe.started.notified()) + .await + .unwrap(); + probe.release.notify_one(); + let error = timeout(Duration::from_secs(3), probe.control.cleanup()) + .await + .unwrap() + .unwrap_err(); + assert!(error.contains("injected remaining platform wait failure")); + assert!(probe.control.exited().is_none()); + probe.control.retire(); + tokio::task::yield_now().await; + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + assert!(!probe.dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn retirement_timeout_terminates_and_reaps_the_worker() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exec sleep 30"); + let process = spawn(command, Duration::from_millis(40)).unwrap(); + process.control.retire(); + let exit = timeout(Duration::from_secs(3), process.control.cleanup()) + .await + .unwrap() + .unwrap(); + assert!(exit.forced); + assert_eq!(exit.code, None); + assert!(exit.startup_error.is_none()); + } + + #[tokio::test] + async fn spontaneous_leader_exit_terminates_ordinary_descendant_pipe_holders() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("sleep 30 & exit 0"); + let mut process = spawn(command, Duration::from_millis(40)).unwrap(); + let exit = timeout(Duration::from_secs(3), process.control.cleanup()) + .await + .unwrap() + .unwrap(); + assert_eq!(exit.code, Some(0)); + assert!(!exit.forced); + // EOF proves the ordinary descendant no longer holds this inherited + // pipe. It does not assert we reaped a process that is not our child. + let mut output = Vec::new(); + timeout( + Duration::from_secs(3), + process.stdout.read_to_end(&mut output), + ) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn dropping_every_control_still_terminates_the_worker() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exec sleep 30"); + let mut process = spawn(command, Duration::from_millis(40)).unwrap(); + drop(process.control); + let mut output = Vec::new(); + timeout( + Duration::from_secs(3), + process.stdout.read_to_end(&mut output), + ) + .await + .unwrap() + .unwrap(); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/protocol.rs b/apps/maple-agent/crates/maple-code-mode/src/protocol.rs new file mode 100644 index 000000000..44b35df45 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/protocol.rs @@ -0,0 +1,199 @@ +use crate::{FINAL_BYTES, MAX_FRAME_BYTES, MAX_OUTPUT_CHUNK_BYTES}; +use serde::{Deserialize, Serialize}; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Stream { + Stdout, + Stderr, +} +impl Stream { + pub fn as_str(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum HostFrame { + Execute { + generation: u64, + execution_id: u64, + code: String, + }, + Shutdown { + generation: u64, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum WorkerFrame { + Ready { + generation: u64, + protocol_version: u32, + implementation: String, + version: String, + executable: String, + cwd: String, + }, + Output { + generation: u64, + #[serde(deserialize_with = "required_execution_id")] + execution_id: Option, + stream: Stream, + text: String, + }, + Result { + generation: u64, + execution_id: u64, + text: String, + }, + Error { + generation: u64, + execution_id: u64, + traceback: String, + }, + Done { + generation: u64, + execution_id: u64, + status: DoneStatus, + elapsed_ms: u64, + dropped_stdout_bytes: u64, + dropped_stderr_bytes: u64, + }, + Fatal { + generation: u64, + message: String, + }, +} +fn required_execution_id<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DoneStatus { + Ok, + Error, + Cancelled, +} +impl WorkerFrame { + pub fn generation(&self) -> u64 { + match self { + Self::Ready { generation, .. } + | Self::Output { generation, .. } + | Self::Result { generation, .. } + | Self::Error { generation, .. } + | Self::Done { generation, .. } + | Self::Fatal { generation, .. } => *generation, + } + } + fn validate_bounds(&self) -> io::Result<()> { + let valid = match self { + Self::Output { text, .. } => text.len() <= MAX_OUTPUT_CHUNK_BYTES, + Self::Result { text, .. } => text.len() <= FINAL_BYTES, + Self::Error { traceback, .. } => traceback.len() <= FINAL_BYTES, + Self::Fatal { message, .. } => message.len() <= FINAL_BYTES, + Self::Ready { + implementation, + version, + executable, + cwd, + .. + } => { + implementation.len() <= 32 + && version.len() <= 32 + && executable.len() <= 16 * 1024 + && cwd.len() <= 16 * 1024 + } + Self::Done { .. } => true, + }; + if valid { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Python frame field exceeds its byte limit", + )) + } + } +} + +pub(crate) fn encode(frame: &HostFrame) -> io::Result> { + let encoded = serde_json::to_vec(frame).map_err(io::Error::other)?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Encoded Python request exceeds 1 MiB", + )); + } + Ok(encoded) +} +pub(crate) async fn write_frame( + writer: &mut (impl AsyncWrite + Unpin), + frame: &HostFrame, +) -> io::Result<()> { + let encoded = encode(frame)?; + writer.write_u32(encoded.len() as u32).await?; + writer.write_all(&encoded).await?; + writer.flush().await +} +pub(crate) async fn read_frame(reader: &mut (impl AsyncRead + Unpin)) -> io::Result { + let size = reader.read_u32().await? as usize; + if size == 0 || size > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Python frame length outside 1..=1 MiB", + )); + } + let mut bytes = vec![0; size]; + reader.read_exact(&mut bytes).await?; + let frame: WorkerFrame = serde_json::from_slice(&bytes).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid Python protocol frame: {error}"), + ) + })?; + frame.validate_bounds()?; + Ok(frame) +} +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn rejects_length_before_reading_payload() { + let bytes = ((MAX_FRAME_BYTES + 1) as u32).to_be_bytes(); + assert_eq!( + read_frame(&mut &bytes[..]).await.unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + } + #[tokio::test] + async fn rejects_wrong_direction_and_invalid_utf8() { + for bytes in [br#"{"type":"shutdown","generation":1}"#.to_vec(), vec![255]] { + let mut framed = (bytes.len() as u32).to_be_bytes().to_vec(); + framed.extend(bytes); + assert!(read_frame(&mut &framed[..]).await.is_err()); + } + } + #[tokio::test] + async fn output_requires_explicit_attribution_and_respects_chunk_bounds() { + for frame in [ + serde_json::json!({"type":"output","generation":1,"stream":"stdout","text":"missing identity"}), + serde_json::json!({"type":"output","generation":1,"execution_id":null,"stream":"stdout","text":"x".repeat(MAX_OUTPUT_CHUNK_BYTES + 1)}), + ] { + let bytes = serde_json::to_vec(&frame).unwrap(); + let mut encoded = (bytes.len() as u32).to_be_bytes().to_vec(); + encoded.extend(bytes); + assert!(read_frame(&mut &encoded[..]).await.is_err()); + } + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/resources.rs b/apps/maple-agent/crates/maple-code-mode/src/resources.rs new file mode 100644 index 000000000..7541b6765 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/resources.rs @@ -0,0 +1,161 @@ +use crate::Error; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + io::Read, + path::{Component, Path, PathBuf}, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeManifest { + pub protocol_version: u32, + pub implementation: String, + pub version: String, + pub distribution: String, + pub executable: PathBuf, + pub worker: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackagedPython { + pub manifest: PathBuf, + pub executable: PathBuf, + pub worker: PathBuf, + pub implementation: String, + pub version: String, + pub distribution: String, +} +impl PackagedPython { + pub fn from_manifest(path: impl AsRef) -> Result { + let path = path.as_ref(); + let missing = |error| { + Error::Unavailable(format!( + "Bundled Python is unavailable at {}: {error}. Run `just python-prepare` or reinstall Maple.", + path.display() + )) + }; + let mut bytes = Vec::new(); + fs::File::open(path) + .map_err(missing)? + .take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(missing)?; + if bytes.len() > 64 * 1024 { + return Err(Error::Unavailable( + "Python runtime manifest exceeds 64 KiB".into(), + )); + } + let metadata: RuntimeManifest = serde_json::from_slice(&bytes).map_err(|error| { + Error::Unavailable(format!("Invalid Python runtime manifest: {error}")) + })?; + if metadata.protocol_version != 1 + || metadata.implementation != "cpython" + || metadata.version != "3.13.15" + || metadata.distribution.is_empty() + || metadata.distribution.len() > 256 + { + return Err(Error::Unavailable("Python runtime manifest does not declare the supported CPython 3.13.15 protocol 1 distribution".into())); + } + let manifest = fs::canonicalize(path).map_err(missing)?; + let directory = manifest.parent().expect("absolute manifest has a parent"); + fn resolve(directory: &Path, path: &Path, absolute: bool) -> Result { + if path.as_os_str().is_empty() + || (!absolute && path.is_absolute()) + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(Error::Unavailable( + "Invalid packaged Python resource path".into(), + )); + } + let path = if path.is_absolute() { + path.to_path_buf() + } else { + directory.join(path) + }; + let canonical = fs::canonicalize(&path).map_err(|error| Error::Unavailable(format!("Missing Python resource {}: {error}. Run `just python-prepare` or reinstall Maple.", path.display())))?; + if !canonical.is_file() { + return Err(Error::Unavailable(format!( + "Python resource is not a file: {}", + path.display() + ))); + } + Ok(canonical) + } + let executable = resolve( + directory, + &metadata.executable, + metadata.distribution.starts_with("nix"), + )?; + let worker = resolve(directory, &metadata.worker, false)?; + Ok(Self { + manifest, + executable, + worker, + implementation: metadata.implementation, + version: metadata.version, + distribution: metadata.distribution, + }) + } + + /// Select exactly one known package layout. Never search PATH or task CWD. + pub fn for_application_executable(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.is_absolute() { + return Err(Error::Unavailable( + "Maple executable path must be absolute".into(), + )); + } + let directory = path + .parent() + .ok_or_else(|| Error::Unavailable("Maple executable has no parent directory".into()))?; + let manifest = if directory.file_name().is_some_and(|name| name == "MacOS") + && directory + .parent() + .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "Contents")) + { + directory + .parent() + .unwrap() + .join("Resources/python/runtime.json") + } else if directory.file_name().is_some_and(|name| name == "bin") + && directory.parent().is_some_and(|parent| { + parent.starts_with("/nix/store") || parent.join("share/maple-gpui/python").is_dir() + }) + { + directory + .parent() + .unwrap() + .join("share/maple-gpui/python/runtime.json") + } else { + directory.join("runtime/python/runtime.json") + }; + Self::from_manifest(manifest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_resources_never_fall_back_to_installed_python() { + let dir = tempfile::tempdir().unwrap(); + let error = + PackagedPython::for_application_executable(dir.path().join("maple")).unwrap_err(); + assert!(error.to_string().contains("just python-prepare")); + } + #[test] + fn rejects_unsupported_manifest_before_resources() { + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("runtime.json"); + fs::write(&manifest, r#"{"protocol_version":1,"implementation":"cpython","version":"3.14.0","distribution":"pbs","executable":"bin/python","worker":"worker.py"}"#).unwrap(); + assert!( + PackagedPython::from_manifest(manifest) + .unwrap_err() + .to_string() + .contains("3.13.15") + ); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/worker.rs b/apps/maple-agent/crates/maple-code-mode/src/worker.rs new file mode 100644 index 000000000..ecf6163fb --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/worker.rs @@ -0,0 +1,1337 @@ +use crate::{ + output::{self, BackgroundRing, Capture}, + process::{self, ProcessControl}, + protocol::{self, DoneStatus, HostFrame, Stream, WorkerFrame}, + *, +}; +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; +use tokio::{ + io::AsyncReadExt, + sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +pub type Execution = Pin> + Send>>; +type Cleanup = Pin> + Send>>; +type CleanupState = Option>; + +#[derive(Clone)] +pub struct Runtime { + inner: Arc, + _owner: Arc, +} +struct Owner(Weak); +impl Drop for Owner { + fn drop(&mut self) { + if let Some(inner) = self.0.upgrade() { + inner.retire_all("Python runtime was dropped"); + } + } +} +struct Inner { + config: Config, + state: Mutex, +} +#[derive(Default)] +struct State { + closed: bool, + next_binding: u64, + next_generation: u64, + bindings: HashMap, + workers: HashMap, +} +struct Binding { + id: u64, + launch: LaunchSpec, + lifetime: CancellationToken, + detached: CancellationToken, + closed: bool, + generation: Option, + state_loss_reason: Option, +} +struct Worker { + key: String, + binding: u64, + phase: WorkerPhase, + next_execution: u64, + sender: mpsc::Sender, + retire: CancellationToken, + _control: ProcessControl, + cleanup: watch::Receiver, +} +#[derive(Clone)] +pub struct TaskHandle { + inner: Arc, + key: String, + binding: u64, +} +struct Call { + execution_id: u64, + code: String, + cancel: CancellationToken, + result: oneshot::Sender>, + capture: Capture, + value: Option, + traceback: Option, + loss: Option, + identity: Option, + started: Instant, +} +struct CancelOnDrop(CancellationToken); +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancel(); + } +} + +impl Runtime { + pub fn new(config: Config) -> Self { + let inner = Arc::new(Inner { + config, + state: Mutex::new(State::default()), + }); + Self { + _owner: Arc::new(Owner(Arc::downgrade(&inner))), + inner, + } + } + /// Installs an immutable binding without starting an interpreter. + pub fn bind( + &self, + key: impl Into, + mut launch: LaunchSpec, + lifetime: CancellationToken, + ) -> Result { + let key = key.into(); + if key.is_empty() || key.len() > 1024 { + return Err(Error::InvalidInput( + "Python task key must contain 1..=1024 bytes".into(), + )); + } + if lifetime.is_cancelled() { + return Err(Error::Retired); + } + if !launch.cwd.is_absolute() { + return Err(Error::InvalidInput( + "Python task root must be absolute".into(), + )); + } + launch.cwd = std::fs::canonicalize(&launch.cwd).map_err(|error| { + Error::Unavailable(format!("Python task root is unavailable: {error}")) + })?; + if !launch.cwd.is_dir() { + return Err(Error::InvalidInput( + "Python task root is not a directory".into(), + )); + } + let mut state = self.inner.state.lock().unwrap(); + if state.closed { + return Err(Error::Retired); + } + let prior_loss = if let Some(binding) = state.bindings.get(&key) { + if !binding.closed && !binding.lifetime.is_cancelled() { + if binding.launch != launch { + return Err(Error::LaunchMismatch); + } + return Ok(TaskHandle { + inner: self.inner.clone(), + key, + binding: binding.id, + }); + } + if binding.generation.is_some() { + return Err(Error::CleanupPending); + } + binding.state_loss_reason.clone() + } else { + None + }; + state.next_binding += 1; + let id = state.next_binding; + let detached = CancellationToken::new(); + state.bindings.insert( + key.clone(), + Binding { + id, + launch, + lifetime: lifetime.clone(), + detached: detached.clone(), + closed: false, + generation: None, + state_loss_reason: prior_loss, + }, + ); + drop(state); + let handle = TaskHandle { + inner: self.inner.clone(), + key: key.clone(), + binding: id, + }; + let weak = Arc::downgrade(&self.inner); + tokio::spawn(async move { + tokio::select! { + _ = detached.cancelled() => {}, + _ = lifetime.cancelled() => if let Some(inner) = weak.upgrade() { inner.retire_binding(&key, id, "Python owner context was retired", true); }, + } + }); + Ok(handle) + } + pub fn snapshot(&self) -> CapacitySnapshot { + self.inner.snapshot() + } + /// Fences admission synchronously; the returned future only observes cleanup. + pub fn shutdown( + &self, + reason: impl Into, + ) -> impl Future> + Send + 'static { + let receivers = self.inner.retire_all(&reason.into()); + let timeout = self.inner.config.cleanup_timeout; + async move { + let wait = async { + for receiver in receivers { + observe_cleanup(receiver).await?; + } + Ok(()) + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_| Error::CleanupPending)? + } + } +} +impl Default for Runtime { + fn default() -> Self { + Self::new(Config::default()) + } +} + +impl TaskHandle { + pub fn status(&self) -> TaskStatus { + let state = self.inner.state.lock().unwrap(); + let Some(binding) = state + .bindings + .get(&self.key) + .filter(|binding| binding.id == self.binding) + else { + return TaskStatus { + generation: None, + phase: WorkerPhase::Empty, + closed: true, + state_loss_reason: None, + }; + }; + TaskStatus { + generation: binding.generation, + phase: binding + .generation + .and_then(|generation| { + state + .workers + .get(&generation) + .map(|worker| worker.phase.clone()) + }) + .unwrap_or(WorkerPhase::Empty), + closed: binding.closed || binding.lifetime.is_cancelled(), + state_loss_reason: binding.state_loss_reason.clone(), + } + } + /// Admission happens synchronously, even if the returned future is never polled. + pub fn execute(&self, code: impl Into, cancel: CancellationToken) -> Execution { + self.execute_guarded(code, cancel, || Ok(())) + } + /// The host guard spans the admission check and synchronous process spawn only. + /// No guard (including a borrowed, non-Send mutex guard) enters the future. + pub fn execute_guarded( + &self, + code: impl Into, + cancel: CancellationToken, + guard: impl FnOnce() -> Result, + ) -> Execution { + let code = code.into(); + let admitted = (|| { + if cancel.is_cancelled() { + return Err(Error::Cancelled); + } + if code.trim().is_empty() { + return Err(Error::InvalidInput("Python code must not be empty".into())); + } + if code.len() > MAX_SOURCE_BYTES { + return Err(Error::InvalidInput("Python code exceeds 256 KiB".into())); + } + // Validate worst-case JSON expansion before acquiring authority or capacity. + protocol::encode(&HostFrame::Execute { + generation: u64::MAX, + execution_id: u64::MAX, + code: code.clone(), + }) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let _guard = guard()?; + self.admit(code, cancel) + })(); + match admitted { + Err(error) => Box::pin(async move { Err(error) }), + Ok((receiver, cancellation)) => { + // Captured now: dropping an unpolled future cancels admitted work too. + let guard = CancelOnDrop(cancellation); + Box::pin(async move { + let _guard = guard; + receiver.await.unwrap_or_else(|_| { + Err(Error::WorkerLost( + "Python execution supervisor stopped unexpectedly".into(), + )) + }) + }) + } + } + } + fn admit( + &self, + code: String, + cancel: CancellationToken, + ) -> Result<(oneshot::Receiver>, CancellationToken), Error> { + let mut state = self.inner.state.lock().unwrap(); + if cancel.is_cancelled() { + return Err(Error::Cancelled); + } + if state.closed { + return Err(Error::Retired); + } + let binding = state + .bindings + .get(&self.key) + .filter(|binding| binding.id == self.binding) + .ok_or(Error::Retired)?; + if binding.closed || binding.lifetime.is_cancelled() { + return Err(Error::Retired); + } + let generation = binding.generation; + let launch = binding.launch.clone(); + let loss = binding.state_loss_reason.clone(); + let cancellation = cancel.child_token(); + let (tx, rx) = oneshot::channel(); + let mut call = Call { + execution_id: 0, + code, + cancel: cancellation.clone(), + result: tx, + capture: Capture::default(), + value: None, + traceback: None, + loss, + identity: None, + started: Instant::now(), + }; + if let Some(generation) = generation { + let worker = state + .workers + .get_mut(&generation) + .ok_or(Error::CleanupPending)?; + match worker.phase { + WorkerPhase::Idle => {} + WorkerPhase::Starting | WorkerPhase::Executing => return Err(Error::Busy), + _ => return Err(Error::CleanupPending), + } + worker.next_execution += 1; + call.execution_id = worker.next_execution; + worker + .sender + .try_send(call) + .map_err(|_| Error::WorkerLost("Python command receiver is unavailable".into()))?; + worker.phase = WorkerPhase::Executing; + } else { + if state.workers.len() >= MAX_WORKERS { + return Err(Error::Capacity { + holders: holders(&state), + }); + } + state.next_generation += 1; + let generation = state.next_generation; + call.execution_id = 1; + let mut command = tokio::process::Command::new(&launch.python.executable); + command + .args(["-I", "-B", "-u"]) + .arg(&launch.python.worker) + .arg("--generation") + .arg(generation.to_string()) + .current_dir(&launch.cwd) + .env_clear() + .envs(&launch.env); + let spawned = + process::spawn(command, self.inner.config.retirement_grace).map_err(|error| { + Error::Unavailable(format!("Could not start bundled Python: {error}")) + })?; + let control = spawned.control.clone(); + let (sender, receiver) = mpsc::channel(1); + let retire = CancellationToken::new(); + let (cleanup_tx, cleanup_rx) = watch::channel(None); + state.workers.insert( + generation, + Worker { + key: self.key.clone(), + binding: self.binding, + phase: WorkerPhase::Starting, + next_execution: 1, + sender, + retire: retire.clone(), + _control: control, + cleanup: cleanup_rx, + }, + ); + state.bindings.get_mut(&self.key).unwrap().generation = Some(generation); + tokio::spawn( + WorkerStart { + inner: self.inner.clone(), + generation, + launch, + spawned, + receiver, + retire, + cleanup_tx, + call, + } + .run(), + ); + } + state.bindings.get_mut(&self.key).unwrap().state_loss_reason = None; + Ok((rx, cancellation)) + } + /// Ends the current generation and keeps this logical task binding usable. + /// Fencing happens before this method returns; no replacement is spawned. + pub fn reset(&self, reason: impl Into) -> Cleanup { + self.cleanup(reason.into(), false) + } + /// Closes this exact binding forever, synchronously, including prepared handles. + pub fn retire(&self, reason: impl Into) -> Cleanup { + self.cleanup(reason.into(), true) + } + fn cleanup(&self, reason: String, close: bool) -> Cleanup { + let observation = self + .inner + .retire_binding(&self.key, self.binding, &reason, close); + let timeout = self.inner.config.cleanup_timeout; + Box::pin(async move { + let (generation, receiver) = observation; + if let Some(receiver) = receiver { + tokio::time::timeout(timeout, observe_cleanup(receiver)) + .await + .map_err(|_| Error::CleanupPending)??; + } + Ok(ResetOutcome { + retired_generation: generation, + }) + }) + } +} +fn holders(state: &State) -> Vec { + let mut holders: Vec<_> = state + .workers + .iter() + .map(|(&generation, worker)| Holder { + key: worker.key.clone(), + generation, + phase: worker.phase.clone(), + }) + .collect(); + holders.sort_by_key(|holder| holder.generation); + holders +} +impl Inner { + fn snapshot(&self) -> CapacitySnapshot { + CapacitySnapshot { + limit: MAX_WORKERS, + holders: holders(&self.state.lock().unwrap()), + } + } + fn retire_binding( + &self, + key: &str, + id: u64, + reason: &str, + close: bool, + ) -> (Option, Option>) { + let mut state = self.state.lock().unwrap(); + let Some(binding) = state + .bindings + .get_mut(key) + .filter(|binding| binding.id == id) + else { + return (None, None); + }; + if close { + binding.closed = true; + binding.detached.cancel(); + } + let generation = binding.generation; + if close && generation.is_none() { + state.bindings.remove(key); + return (None, None); + } + if generation.is_some() { + binding.state_loss_reason = Some(output::prefix(reason, 1024).into()); + } + let receiver = generation + .and_then(|generation| state.workers.get_mut(&generation)) + .map(|worker| { + if !matches!(worker.phase, WorkerPhase::CleanupPending) { + worker.phase = WorkerPhase::Retiring; + } + worker.retire.cancel(); + worker.cleanup.clone() + }); + (generation, receiver) + } + fn retire_all(&self, reason: &str) -> Vec> { + let mut state = self.state.lock().unwrap(); + state.closed = true; + for binding in state.bindings.values_mut() { + binding.closed = true; + binding.detached.cancel(); + if binding.generation.is_some() { + binding.state_loss_reason = Some(output::prefix(reason, 1024).into()); + } + } + state + .bindings + .retain(|_, binding| binding.generation.is_some()); + state + .workers + .values_mut() + .map(|worker| { + worker.phase = WorkerPhase::Retiring; + worker.retire.cancel(); + worker.cleanup.clone() + }) + .collect() + } + fn fence_generation(&self, generation: u64, reason: &str) { + let mut state = self.state.lock().unwrap(); + let Some(worker) = state.workers.get_mut(&generation) else { + return; + }; + worker.phase = WorkerPhase::Retiring; + worker.retire.cancel(); + let key = worker.key.clone(); + let id = worker.binding; + if let Some(binding) = state + .bindings + .get_mut(&key) + .filter(|binding| binding.id == id && binding.state_loss_reason.is_none()) + { + binding.state_loss_reason = Some(output::prefix(reason, 1024).into()); + } + } +} +async fn observe_cleanup(mut receiver: watch::Receiver) -> Result<(), Error> { + loop { + if let Some(result) = receiver.borrow_and_update().clone() { + return result.map_err(|_| Error::CleanupPending); + } + receiver + .changed() + .await + .map_err(|_| Error::CleanupPending)?; + } +} + +struct OutputEvent { + execution_id: Option, + stream: Stream, + text: String, +} +// A single FIFO preserves terminal/output wire ordering. Output permits bound +// text separately, leaving channel slots reserved for control frames. +struct WorkerEvent { + frame: Result, + drops: TransportDrops, + _output_budget: Option, +} +#[derive(Default, Clone, Copy)] +struct TransportDrops { + stdout: u64, + stderr: u64, +} +impl TransportDrops { + fn add(&mut self, stream: Stream, count: usize) { + let counter = match stream { + Stream::Stdout => &mut self.stdout, + Stream::Stderr => &mut self.stderr, + }; + *counter = counter.saturating_add(count as u64); + } +} +async fn queue_frame( + frame: Result, + events: &mpsc::Sender, + budget: &Arc, + drops: &mut TransportDrops, +) -> bool { + if let Ok(WorkerFrame::Output { stream, text, .. }) = &frame { + let stream = *stream; + let bytes = text.len(); + let Ok(permit) = budget.clone().try_acquire_owned() else { + drops.add(stream, bytes); + return true; + }; + let event = WorkerEvent { + frame, + drops: *drops, + _output_budget: Some(permit), + }; + match events.try_send(event) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Full(_)) => { + drops.add(stream, bytes); + true + } + Err(mpsc::error::TrySendError::Closed(_)) => false, + } + } else { + events + .send(WorkerEvent { + frame, + drops: *drops, + _output_budget: None, + }) + .await + .is_ok() + } +} +async fn read_frames( + mut stdout: tokio::process::ChildStdout, + generation: u64, + events: mpsc::Sender, + sent_execution: Arc, +) { + let budget = Arc::new(Semaphore::new(OUTPUT_QUEUE_BYTES / MAX_OUTPUT_CHUNK_BYTES)); + let mut drops = TransportDrops::default(); + let mut handshake_seen = false; + loop { + let mut frame = protocol::read_frame(&mut stdout) + .await + .map_err(|error| output::prefix(&error.to_string(), FINAL_BYTES).to_owned()) + .and_then(|frame| { + if frame.generation() == generation { + Ok(frame) + } else { + Err("Python worker sent a stale generation".into()) + } + }); + if let Ok(WorkerFrame::Output { execution_id, .. }) = &frame + && (!handshake_seen + || execution_id + .is_some_and(|id| id == 0 || id > sent_execution.load(Ordering::Acquire))) + { + frame = Err( + "Python output preceded readiness or used an unknown execution identity".into(), + ); + } + if matches!(&frame, Ok(WorkerFrame::Ready { .. })) { + handshake_seen = true; + } + let failed = frame.is_err(); + if !queue_frame(frame, &events, &budget, &mut drops).await || failed { + return; + } + } +} + +struct WorkerStart { + inner: Arc, + generation: u64, + launch: LaunchSpec, + spawned: process::SpawnedProcess, + receiver: mpsc::Receiver, + retire: CancellationToken, + cleanup_tx: watch::Sender, + call: Call, +} +struct Actor { + current: Option, + background: BackgroundRing, + ready: bool, + last_execution: u64, + transport_drops: TransportDrops, + reported_transport_drops: TransportDrops, + worker_drops: (u64, u64), +} +impl Actor { + fn output(&mut self, event: OutputEvent) -> Result<(), String> { + if !self.ready { + return Err("Python output arrived before the ready handshake".into()); + } + if event + .execution_id + .is_some_and(|id| id == 0 || id > self.last_execution) + { + return Err("Python output has an unknown execution identity".into()); + } + if let Some(call) = self + .current + .as_mut() + .filter(|call| Some(call.execution_id) == event.execution_id) + { + call.capture.push(event.stream, &event.text); + } else { + self.background + .push(event.execution_id, event.stream, event.text); + } + Ok(()) + } + fn terminal( + &mut self, + generation: u64, + status: OutcomeStatus, + elapsed_ms: u64, + ) -> Option<(Call, Outcome)> { + let mut call = self.current.take()?; + let stdout = self + .transport_drops + .stdout + .saturating_sub(self.reported_transport_drops.stdout); + let stderr = self + .transport_drops + .stderr + .saturating_sub(self.reported_transport_drops.stderr); + self.reported_transport_drops = self.transport_drops; + let outcome = Outcome { + generation, + execution_id: call.execution_id, + status, + stdout: std::mem::take(&mut call.capture.stdout), + stderr: std::mem::take(&mut call.capture.stderr), + value: call.value.take(), + traceback: call.traceback.take(), + elapsed_ms, + dropped_stdout_bytes: call.capture.dropped_stdout.saturating_add(stdout), + dropped_stderr_bytes: call.capture.dropped_stderr.saturating_add(stderr), + background: self.background.take(), + state_loss_reason: call.loss.take(), + runtime: call.identity.take(), + cleanup_pending: false, + }; + Some((call, outcome)) + } +} +impl WorkerStart { + async fn run(self) { + let Self { + inner, + generation, + launch, + spawned, + mut receiver, + retire, + cleanup_tx, + call, + } = self; + let process::SpawnedProcess { + mut stdin, + stdout, + mut stderr, + control, + } = spawned; + log::debug!( + "Python worker generation {generation} started (pid {})", + control.pid() + ); + let (events_tx, mut events_rx) = + mpsc::channel(OUTPUT_QUEUE_BYTES / MAX_OUTPUT_CHUNK_BYTES + 16); + let sent_execution = Arc::new(AtomicU64::new(0)); + let reader = tokio::spawn(read_frames( + stdout, + generation, + events_tx.clone(), + sent_execution.clone(), + )); + let (writer_tx, mut writer_rx) = mpsc::channel::(2); + let writer = tokio::spawn(async move { + while let Some(frame) = writer_rx.recv().await { + if let HostFrame::Execute { execution_id, .. } = &frame { + sent_execution.store(*execution_id, Ordering::Release); + } + if let Err(error) = protocol::write_frame(&mut stdin, &frame).await { + let _ = events_tx + .send(WorkerEvent { + frame: Err(format!("Python protocol write failed: {error}")), + drops: TransportDrops::default(), + _output_budget: None, + }) + .await; + break; + } + } + }); + let bootstrap = Arc::new(Mutex::new(String::new())); + let bootstrap_buffer = bootstrap.clone(); + let stderr_reader = tokio::spawn(async move { + let mut bytes = [0; 8192]; + while let Ok(size) = stderr.read(&mut bytes).await { + if size == 0 { + break; + } + let mut buffer = bootstrap_buffer.lock().unwrap(); + let available = FINAL_BYTES.saturating_sub(buffer.len()); + buffer.push_str(output::prefix( + &String::from_utf8_lossy(&bytes[..size]), + available, + )); + } + }); + let mut actor = Actor { + current: Some(call), + background: BackgroundRing::default(), + ready: false, + last_execution: 1, + transport_drops: TransportDrops::default(), + reported_transport_drops: TransportDrops::default(), + worker_drops: (0, 0), + }; + let deadline = tokio::time::sleep(inner.config.startup_timeout); + tokio::pin!(deadline); + let mut loss_status = OutcomeStatus::WorkerLost; + let failure: String = loop { + let cancellation = actor + .current + .as_ref() + .map(|call| call.cancel.clone()) + .unwrap_or_default(); + tokio::select! { + _ = retire.cancelled() => { loss_status = OutcomeStatus::Cancelled; break "Python worker was retired; its state was lost".into(); } + _ = cancellation.cancelled(), if actor.current.is_some() => { loss_status = OutcomeStatus::Cancelled; break "An unfinished Python cell was cancelled; its worker state was lost".into(); } + _ = &mut deadline, if !actor.ready => break "Bundled Python did not complete its ready handshake before the startup deadline".into(), + result = control.cleanup() => { + break match result { + Ok(exit) => exit.startup_error.unwrap_or_else(|| format!("Python worker exited unexpectedly (code {:?}); its state was lost", exit.code)), + Err(message) => format!("Python process cleanup failed: {}", output::prefix(&message, 2048)), + }; + } + event = events_rx.recv() => { + let Some(event) = event else { break "Python protocol reader stopped".into(); }; + actor.transport_drops.stdout = actor.transport_drops.stdout.max(event.drops.stdout); + actor.transport_drops.stderr = actor.transport_drops.stderr.max(event.drops.stderr); + let frame = match event.frame { Ok(frame) => frame, Err(error) => break error }; + match frame { + WorkerFrame::Ready { protocol_version, implementation, version, executable, cwd, .. } => { + if actor.ready { break "Python sent a duplicate ready handshake".into(); } + let executable = std::fs::canonicalize(executable); + let cwd = std::fs::canonicalize(cwd); + if protocol_version != 1 || implementation != launch.python.implementation || version != launch.python.version || executable.as_ref().ok() != Some(&launch.python.executable) || cwd.as_ref().ok() != Some(&launch.cwd) { + break "Bundled Python handshake did not match its declared protocol, implementation, version, executable, and task root".into(); + } + actor.ready = true; + if let Some(call) = actor.current.as_mut() { + call.identity = Some(RuntimeIdentity { implementation, version, executable: launch.python.executable.clone(), cwd: launch.cwd.clone(), distribution: launch.python.distribution.clone() }); + if let Err(error) = enqueue_execution(&inner, generation, call, &writer_tx) { + if matches!(error, Error::Cancelled | Error::Retired) { loss_status = OutcomeStatus::Cancelled; } + break error.to_string(); + } + } + } + WorkerFrame::Result { execution_id, text, .. } => { + let Some(call) = actor.current.as_mut().filter(|call| actor.ready && call.execution_id == execution_id) else { break "Python result has no matching active execution".into(); }; + if call.value.is_some() || call.traceback.is_some() { break "Python sent duplicate final content".into(); } + call.value = Some(text); + } + WorkerFrame::Error { execution_id, traceback, .. } => { + let Some(call) = actor.current.as_mut().filter(|call| actor.ready && call.execution_id == execution_id) else { break "Python error has no matching active execution".into(); }; + if call.value.is_some() || call.traceback.is_some() { break "Python sent duplicate final content".into(); } + call.traceback = Some(traceback); + } + WorkerFrame::Done { execution_id, status, elapsed_ms, dropped_stdout_bytes, dropped_stderr_bytes, .. } => { + let Some(call) = actor.current.as_ref().filter(|call| actor.ready && call.execution_id == execution_id) else { break "Python terminal has no matching active execution".into(); }; + if matches!(status, DoneStatus::Ok) && call.traceback.is_some() || matches!(status, DoneStatus::Error) && call.traceback.is_none() { break "Python terminal status contradicts its final content".into(); } + if dropped_stdout_bytes < actor.worker_drops.0 || dropped_stderr_bytes < actor.worker_drops.1 { break "Python output drop counters moved backwards".into(); } + let worker_delta = (dropped_stdout_bytes - actor.worker_drops.0, dropped_stderr_bytes - actor.worker_drops.1); + actor.worker_drops = (dropped_stdout_bytes, dropped_stderr_bytes); + // This lock is the completion-versus-retirement linearization point. + // Remove the call (and its cancellation watcher) before making it idle. + let mut state = inner.state.lock().unwrap(); + if retire.is_cancelled() || call.cancel.is_cancelled() || matches!(status, DoneStatus::Cancelled) { + loss_status = OutcomeStatus::Cancelled; + break "An unfinished Python cell was cancelled; its worker state was lost".into(); + } + let outcome_status = match status { DoneStatus::Ok => OutcomeStatus::Ok, DoneStatus::Error => OutcomeStatus::Error, DoneStatus::Cancelled => unreachable!() }; + let (call, mut outcome) = actor.terminal(generation, outcome_status, elapsed_ms).unwrap(); + outcome.dropped_stdout_bytes = outcome.dropped_stdout_bytes.saturating_add(worker_delta.0); + outcome.dropped_stderr_bytes = outcome.dropped_stderr_bytes.saturating_add(worker_delta.1); + if let Some(worker) = state.workers.get_mut(&generation) { worker.phase = WorkerPhase::Idle; } + let _ = call.result.send(Ok(outcome)); + } + WorkerFrame::Fatal { message, .. } => break format!("Python protocol failed: {message}"), + WorkerFrame::Output { execution_id, stream, text, .. } => { + if let Err(error) = actor.output(OutputEvent { execution_id, stream, text }) { break error; } + } + } + } + next = receiver.recv(), if actor.current.is_none() => { + let Some(mut call) = next else { break "Python task command channel closed".into(); }; + actor.last_execution = call.execution_id; + if call.cancel.is_cancelled() { + actor.current = Some(call); loss_status = OutcomeStatus::Cancelled; + break "An unfinished Python cell was cancelled; its worker state was lost".into(); + } + let dispatch = enqueue_execution(&inner, generation, &mut call, &writer_tx); + actor.current = Some(call); + if let Err(error) = dispatch { + if matches!(error, Error::Cancelled | Error::Retired) { loss_status = OutcomeStatus::Cancelled; } + break error.to_string(); + } + } + } + }; + inner.fence_generation(generation, &failure); + // The supervisor starts its sole grace window independently of pipe writes. + control.retire(); + let _ = writer_tx.try_send(HostFrame::Shutdown { generation }); + drop(writer_tx); + if actor.current.is_none() { + actor.current = receiver.try_recv().ok(); + } + let elapsed = actor + .current + .as_ref() + .map(|call| call.started.elapsed().as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0); + let pending = + actor + .terminal(generation, loss_status, elapsed) + .map(|(call, mut outcome)| { + outcome.state_loss_reason = Some(output::prefix(&failure, 2048).into()); + if outcome.traceback.is_none() && outcome.status == OutcomeStatus::WorkerLost { + let diagnostics = bootstrap.lock().unwrap(); + outcome.traceback = Some( + output::prefix(&format!("{failure}\n{diagnostics}"), FINAL_BYTES) + .into(), + ); + } + (call.result, outcome) + }); + finish_cleanup( + inner, + generation, + control, + cleanup_tx, + pending, + [reader, writer, stderr_reader], + ) + .await; + } +} + +// Recheck known cancellation immediately before dispatch, including the interval +// between process spawn and readiness. This is also the exact binding retirement +// fence: reset/retire use this same lock. Cancellation after dispatch may have +// partial effects and is handled by supervised retirement. +fn enqueue_execution( + inner: &Inner, + generation: u64, + call: &mut Call, + writer: &mpsc::Sender, +) -> Result<(), Error> { + let mut state = inner.state.lock().unwrap(); + if call.cancel.is_cancelled() { + return Err(Error::Cancelled); + } + let worker = state.workers.get(&generation).ok_or(Error::Retired)?; + let binding = state + .bindings + .get(&worker.key) + .filter(|binding| binding.id == worker.binding) + .ok_or(Error::Retired)?; + if state.closed + || worker.retire.is_cancelled() + || binding.closed + || binding.lifetime.is_cancelled() + { + return Err(Error::Retired); + } + writer + .try_send(HostFrame::Execute { + generation, + execution_id: call.execution_id, + code: std::mem::take(&mut call.code), + }) + .map_err(|_| Error::WorkerLost("Python command writer is unavailable".into()))?; + state.workers.get_mut(&generation).unwrap().phase = WorkerPhase::Executing; + Ok(()) +} + +async fn finish_cleanup( + inner: Arc, + generation: u64, + control: ProcessControl, + cleanup_tx: watch::Sender, + mut pending: Option<(oneshot::Sender>, Outcome)>, + tasks: [JoinHandle<()>; 3], +) { + let cleanup = control.cleanup(); + tokio::pin!(cleanup); + let result = tokio::select! { + result = &mut cleanup => result, + _ = tokio::time::sleep(inner.config.cleanup_timeout) => { + if let Some(worker) = inner.state.lock().unwrap().workers.get_mut(&generation) { worker.phase = WorkerPhase::CleanupPending; } + if let Some((sender, mut outcome)) = pending.take() { outcome.cleanup_pending = true; let _ = sender.send(Ok(outcome)); } + cleanup.await + } + }; + match &result { + Ok(_) => { + let mut state = inner.state.lock().unwrap(); + if let Some(worker) = state.workers.remove(&generation) + && let Some(binding) = state.bindings.get_mut(&worker.key).filter(|binding| { + binding.id == worker.binding && binding.generation == Some(generation) + }) + { + if binding.closed { + state.bindings.remove(&worker.key); + } else { + binding.generation = None; + } + } + let _ = cleanup_tx.send(Some(Ok(()))); + } + Err(error) => { + if let Some(worker) = inner.state.lock().unwrap().workers.get_mut(&generation) { + worker.phase = WorkerPhase::CleanupPending; + } + log::error!( + "Python generation {generation} cleanup remains pending: {}", + output::prefix(error, 2048) + ); + let _ = cleanup_tx.send(Some(Err(output::prefix(error, 2048).into()))); + } + } + if let Some((sender, mut outcome)) = pending { + outcome.cleanup_pending = result.is_err(); + let _ = sender.send(Ok(outcome)); + } + for task in tasks { + task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn launch(root: &std::path::Path) -> LaunchSpec { + LaunchSpec { + python: PackagedPython { + manifest: root.join("runtime.json"), + executable: root.join("missing-python"), + worker: root.join("worker.py"), + implementation: "cpython".into(), + version: "3.13.15".into(), + distribution: "test".into(), + }, + cwd: root.to_path_buf(), + env: Default::default(), + } + } + #[tokio::test] + async fn preadmission_cancel_and_denied_guard_have_no_runtime_side_effects() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + let called = std::cell::Cell::new(false); + assert!(matches!( + task.execute_guarded("42", cancelled, || { + called.set(true); + Ok(()) + }) + .await, + Err(Error::Cancelled) + )); + assert!(!called.get()); + assert!(matches!( + task.execute_guarded("42", CancellationToken::new(), || Err::<(), _>( + Error::Retired + )) + .await, + Err(Error::Retired) + )); + assert!(runtime.snapshot().holders.is_empty()); + assert_eq!(task.status().generation, None); + } + #[tokio::test] + async fn borrowed_non_send_host_guard_is_dropped_before_future_is_returned() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + let fence = Mutex::new(()); + let execution = + task.execute_guarded("42", CancellationToken::new(), || Ok(fence.lock().unwrap())); + fn assert_send(_: &T) {} + assert_send(&execution); + assert!(fence.try_lock().is_ok()); + assert!(matches!(execution.await, Err(Error::Unavailable(_)))); + assert!(runtime.snapshot().holders.is_empty()); + } + #[tokio::test] + async fn exact_binding_retirement_is_synchronous_and_cannot_be_undone_by_old_handles() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + let cleanup = task.retire("archived"); + assert!(task.status().closed); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + let replacement = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + cleanup.await.unwrap(); + drop(task.retire("old delayed capability")); + assert!(!replacement.status().closed); + assert_eq!( + replacement + .reset("already empty") + .await + .unwrap() + .retired_generation, + None + ); + } + #[tokio::test] + async fn launch_mismatch_and_cancelled_lifetime_reject_before_spawn() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let lifetime = CancellationToken::new(); + let task = runtime + .bind("task", launch(first.path()), lifetime.clone()) + .unwrap(); + assert!(matches!( + runtime.bind("task", launch(second.path()), CancellationToken::new()), + Err(Error::LaunchMismatch) + )); + lifetime.cancel(); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + assert!(runtime.snapshot().holders.is_empty()); + } + #[tokio::test] + async fn dropping_runtime_owner_fences_outliving_task_handles() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + drop(runtime); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + } + #[test] + fn attributed_late_and_raw_output_never_modify_foreground_capture() { + let (tx, _rx) = oneshot::channel(); + let call = Call { + execution_id: 2, + code: String::new(), + cancel: CancellationToken::new(), + result: tx, + capture: Capture::default(), + value: None, + traceback: None, + loss: None, + identity: None, + started: Instant::now(), + }; + let mut actor = Actor { + current: Some(call), + background: BackgroundRing::default(), + ready: true, + last_execution: 2, + transport_drops: TransportDrops::default(), + reported_transport_drops: TransportDrops::default(), + worker_drops: (0, 0), + }; + for (execution_id, text) in [(Some(1), "late"), (None, "raw"), (Some(2), "foreground")] { + actor + .output(OutputEvent { + execution_id, + stream: Stream::Stdout, + text: text.into(), + }) + .unwrap(); + } + let (_, outcome) = actor.terminal(1, OutcomeStatus::Ok, 0).unwrap(); + assert_eq!(outcome.stdout, "foreground"); + assert_eq!(outcome.background.chunks.len(), 2); + assert!(actor.background.take().chunks.is_empty()); + assert!( + actor + .output(OutputEvent { + execution_id: Some(3), + stream: Stream::Stdout, + text: "future".into() + }) + .is_err() + ); + } + #[tokio::test] + async fn retired_empty_bindings_release_their_environments() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + for id in 0..100 { + let task = runtime + .bind( + format!("closed-{id}"), + launch(root.path()), + CancellationToken::new(), + ) + .unwrap(); + task.retire("context ended").await.unwrap(); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + } + assert!(runtime.inner.state.lock().unwrap().bindings.is_empty()); + } + #[tokio::test] + async fn fifo_keeps_late_output_after_the_completed_response() { + let (sender, mut receiver) = mpsc::channel(32); + let budget = Arc::new(Semaphore::new(16)); + let mut drops = TransportDrops::default(); + let (result, _receiver) = oneshot::channel(); + let call = Call { + execution_id: 1, + code: String::new(), + cancel: CancellationToken::new(), + result, + capture: Capture::default(), + value: None, + traceback: None, + loss: None, + identity: None, + started: Instant::now(), + }; + let mut actor = Actor { + current: Some(call), + background: BackgroundRing::default(), + ready: true, + last_execution: 1, + transport_drops: TransportDrops::default(), + reported_transport_drops: TransportDrops::default(), + worker_drops: (0, 0), + }; + let output = |text: &str| { + Ok(WorkerFrame::Output { + generation: 1, + execution_id: Some(1), + stream: Stream::Stdout, + text: text.into(), + }) + }; + assert!(queue_frame(output("before"), &sender, &budget, &mut drops).await); + assert!( + queue_frame( + Ok(WorkerFrame::Done { + generation: 1, + execution_id: 1, + status: DoneStatus::Ok, + elapsed_ms: 1, + dropped_stdout_bytes: 0, + dropped_stderr_bytes: 0 + }), + &sender, + &budget, + &mut drops + ) + .await + ); + assert!(queue_frame(output("late"), &sender, &budget, &mut drops).await); + let mut completed = None; + for _ in 0..3 { + let event = receiver.recv().await.unwrap(); + match event.frame.unwrap() { + WorkerFrame::Output { + execution_id, + stream, + text, + .. + } => actor + .output(OutputEvent { + execution_id, + stream, + text, + }) + .unwrap(), + WorkerFrame::Done { .. } => { + completed = actor + .terminal(1, OutcomeStatus::Ok, 1) + .map(|(_, outcome)| outcome) + } + _ => panic!("unexpected frame"), + } + } + let completed = completed.unwrap(); + assert_eq!(completed.stdout, "before"); + assert!(completed.background.chunks.is_empty()); + assert_eq!(actor.background.take().chunks[0].text, "late"); + } + #[tokio::test] + async fn saturated_output_budget_preserves_control_and_terminal_drop_boundary() { + let (sender, mut receiver) = mpsc::channel(32); + let budget = Arc::new(Semaphore::new(1)); + let mut drops = TransportDrops::default(); + let output = || { + Ok(WorkerFrame::Output { + generation: 1, + execution_id: None, + stream: Stream::Stderr, + text: "x".into(), + }) + }; + assert!(queue_frame(output(), &sender, &budget, &mut drops).await); + assert!( + queue_frame( + Ok(WorkerFrame::Done { + generation: 1, + execution_id: 1, + status: DoneStatus::Ok, + elapsed_ms: 0, + dropped_stdout_bytes: 0, + dropped_stderr_bytes: 0 + }), + &sender, + &budget, + &mut drops + ) + .await + ); + assert!(queue_frame(output(), &sender, &budget, &mut drops).await); + assert_eq!(drops.stderr, 1); + let retained_output = receiver.recv().await.unwrap(); + let terminal = receiver.recv().await.unwrap(); + assert!(matches!(terminal.frame, Ok(WorkerFrame::Done { .. }))); + assert_eq!( + terminal.drops.stderr, 0, + "post-terminal drops belong to a later observation" + ); + drop(retained_output); + assert_eq!(budget.available_permits(), 1); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs b/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs new file mode 100644 index 000000000..11f28beea --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs @@ -0,0 +1,418 @@ +//! These tests require the exact staged distribution; missing Python is a failure. +//! `just test` prepares it, while raw Cargo invocations only consume the fixture. +use maple_code_mode::{ + Config, Error, LaunchSpec, MAX_SOURCE_BYTES, MAX_WORKERS, Outcome, OutcomeStatus, + PackagedPython, Runtime, TaskHandle, +}; +use std::{path::PathBuf, time::Duration}; +use tokio_util::sync::CancellationToken; + +fn packaged_python() -> PackagedPython { + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + PackagedPython::from_manifest(manifest).expect("run `just python-prepare` first") +} + +fn runtime() -> Runtime { + Runtime::new(Config { + retirement_grace: Duration::from_millis(200), + ..Config::default() + }) +} + +fn bind(runtime: &Runtime, name: &str, root: &std::path::Path) -> TaskHandle { + runtime + .bind( + name, + LaunchSpec { + python: packaged_python(), + cwd: root.canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + ) + .unwrap() +} + +async fn cell(task: &TaskHandle, code: &str) -> Outcome { + tokio::time::timeout( + Duration::from_secs(15), + task.execute(code, CancellationToken::new()), + ) + .await + .expect("native cell exceeded test deadline") + .expect("native worker transport failed") +} + +fn all_output(outcome: &Outcome) -> String { + let mut text = format!("{}{}", outcome.stdout, outcome.stderr); + for chunk in &outcome.background.chunks { + text.push_str(&chunk.text); + } + text +} + +#[tokio::test] +async fn persistent_main_namespace_and_task_isolation() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let first = bind(&runtime, "first", root.path()); + let second = bind(&runtime, "second", root.path()); + let initial = cell( + &first, + "import __main__\nanswer = 40\ndef add(x):\n return answer + x\nassert __main__.__dict__ is globals()\nadd(2)", + ) + .await; + assert_eq!(initial.status, OutcomeStatus::Ok); + assert_eq!(initial.value.as_deref(), Some("42")); + assert_eq!(cell(&first, "_ + 1").await.value.as_deref(), Some("43")); + assert_eq!(cell(&first, "None").await.value, None); + assert_eq!(cell(&first, "_").await.value.as_deref(), Some("43")); + assert_eq!( + cell(&second, "'answer' in globals()") + .await + .value + .as_deref(), + Some("False") + ); + let identity = initial.runtime.unwrap(); + assert_eq!(identity.implementation, "cpython"); + assert_eq!(identity.version, "3.13.15"); + assert_eq!(identity.cwd, root.path().canonicalize().unwrap()); + assert_eq!(identity.executable, packaged_python().executable); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn errors_keep_partial_state_and_original_cell_source() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "errors", root.path()); + let failed = cell(&task, "saved = 7\nraise ValueError('intentional')").await; + assert_eq!(failed.status, OutcomeStatus::Error); + let traceback = failed.traceback.unwrap(); + assert!(traceback.contains("ValueError"), "{traceback}"); + assert!( + traceback.contains("raise ValueError('intentional')"), + "{traceback}" + ); + let syntax = cell(&task, "if :").await; + assert_eq!(syntax.status, OutcomeStatus::Error); + assert!(syntax.traceback.unwrap().contains("SyntaxError")); + assert_eq!(cell(&task, "saved").await.value.as_deref(), Some("7")); + assert_eq!(task.status().generation, Some(failed.generation)); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn continuous_loop_runs_between_cells_and_does_not_autoawait_values() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "async", root.path()); + let first = cell( + &task, + "import asyncio, pathlib\nloop = asyncio.get_running_loop()\nqueue = asyncio.Queue()\nasync def background():\n await asyncio.sleep(0.05)\n print('late output')\n pathlib.Path('background-finished').write_text('done')\n await queue.put(42)\njob = asyncio.create_task(background())", + ) + .await; + assert_eq!(first.status, OutcomeStatus::Ok); + tokio::time::timeout(Duration::from_secs(5), async { + while !root.path().join("background-finished").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("background task did not progress while foreground was idle"); + let second = cell( + &task, + "assert asyncio.get_running_loop() is loop\nawait job\nawait queue.get()", + ) + .await; + assert_eq!(second.value.as_deref(), Some("42")); + assert!(second.background.chunks.iter().any(|chunk| { + chunk.execution_id == Some(first.execution_id) && chunk.text.contains("late output") + })); + let third = cell( + &task, + "ran = False\nasync def ordinary_coroutine():\n global ran\n ran = True\n return 9\npending = ordinary_coroutine()\npending", + ) + .await; + assert!(third.value.as_ref().unwrap().contains("coroutine object")); + assert!(!all_output(&third).contains("late output")); + assert_eq!( + cell(&task, "pending.close()\nran").await.value.as_deref(), + Some("False") + ); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn project_imports_and_binary_native_subprocess_output_are_safe() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("project_helper.py"), "answer = 42\n").unwrap(); + std::fs::write( + root.path().join("json.py"), + "raise RuntimeError('shadowed bootstrap')\n", + ) + .unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "stdio", root.path()); + let output = cell( + &task, + "import os, sys, subprocess, project_helper\nassert project_helper.answer == 42\nassert sys.stdin.read() == ''\nprint('text marker')\nsys.stdout.buffer.write(b'binary marker\\xff\\n')\nos.write(1, b'raw marker\\xfe\\n')\nsubprocess.run([sys.executable, '-I', '-B', '-c', \"import os; os.write(2, b'child marker\\\\n')\"], check=True)\n42", + ) + .await; + assert_eq!(output.status, OutcomeStatus::Ok, "{:?}", output.traceback); + assert_eq!(output.value.as_deref(), Some("42")); + let text = all_output(&output); + for marker in [ + "text marker", + "binary marker", + "raw marker", + "child marker", + "�", + ] { + assert!(text.contains(marker), "missing {marker}: {text}"); + } + assert!(!root.path().join("__pycache__").exists()); + assert_eq!(cell(&task, "6 * 7").await.value.as_deref(), Some("42")); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn flood_is_bounded_and_final_results_survive() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "flood", root.path()); + let output = cell( + &task, + "import sys\nsys.stdout.write('x' * (2 * 1024 * 1024))\n42", + ) + .await; + assert_eq!(output.status, OutcomeStatus::Ok); + assert_eq!(output.value.as_deref(), Some("42")); + assert!(output.stdout.len() + output.stderr.len() <= 48 * 1024); + assert!(output.dropped_stdout_bytes > 0); + let error = cell( + &task, + "print('y' * 100000)\nraise ValueError('still visible')", + ) + .await; + assert_eq!(error.status, OutcomeStatus::Error); + assert!(error.traceback.unwrap().contains("still visible")); + assert_eq!(cell(&task, "40 + 2").await.value.as_deref(), Some("42")); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn validation_and_reset_only_do_not_spawn_and_capacity_is_retained() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let empty = bind(&runtime, "empty", root.path()); + assert_eq!( + empty.reset("empty reset").await.unwrap().retired_generation, + None + ); + assert!(matches!( + empty + .execute("x".repeat(MAX_SOURCE_BYTES + 1), CancellationToken::new()) + .await, + Err(Error::InvalidInput(_)) + )); + assert!(runtime.snapshot().holders.is_empty()); + let mut tasks = Vec::new(); + for index in 0..MAX_WORKERS { + let task = bind(&runtime, &format!("holder-{index}"), root.path()); + assert_eq!( + cell(&task, "saved = 42\nsaved").await.value.as_deref(), + Some("42") + ); + tasks.push(task); + } + assert_eq!(runtime.snapshot().holders.len(), MAX_WORKERS); + assert!(matches!( + empty.execute("1", CancellationToken::new()).await, + Err(Error::Capacity { .. }) + )); + assert_eq!(cell(&tasks[0], "saved").await.value.as_deref(), Some("42")); + tasks[1].reset("free one slot").await.unwrap(); + assert_eq!(cell(&empty, "6 * 7").await.value.as_deref(), Some("42")); + assert_eq!(runtime.snapshot().holders.len(), MAX_WORKERS); + runtime.shutdown("test complete").await.unwrap(); + assert!(runtime.snapshot().holders.is_empty()); +} + +#[tokio::test] +async fn unfinished_cancellation_loses_state_but_completed_token_does_not() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "cancel", root.path()); + let completed_token = CancellationToken::new(); + let first = task + .execute("saved = 42", completed_token.clone()) + .await + .unwrap(); + completed_token.cancel(); + assert_eq!(cell(&task, "saved").await.value.as_deref(), Some("42")); + let stop = CancellationToken::new(); + let blocked = task.execute("while True:\n pass", stop.clone()); + assert!(matches!( + task.execute("1", CancellationToken::new()).await, + Err(Error::Busy) + )); + stop.cancel(); + let outcome = tokio::time::timeout(Duration::from_secs(10), blocked) + .await + .unwrap(); + assert!(matches!( + outcome, + Ok(Outcome { + status: OutcomeStatus::Cancelled, + .. + }) | Err(Error::Cancelled) + )); + task.reset("settle cancellation").await.unwrap(); + let fresh = cell(&task, "'saved' in globals()").await; + assert_eq!(fresh.value.as_deref(), Some("False")); + assert_ne!(fresh.generation, first.generation); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn dropped_unpolled_execution_is_supervised_and_resettable() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "dropped", root.path()); + drop(task.execute("while True:\n pass", CancellationToken::new())); + tokio::time::timeout(Duration::from_secs(10), task.reset("caller disappeared")) + .await + .unwrap() + .unwrap(); + assert_eq!(cell(&task, "42").await.value.as_deref(), Some("42")); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn retirement_fences_stale_handles_before_cleanup_is_awaited() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "retired", root.path()); + cell(&task, "saved = 42").await; + let cleanup = task.retire("task archived"); + assert!(matches!( + task.execute("saved", CancellationToken::new()).await, + Err(Error::Retired) + )); + cleanup.await.unwrap(); + let replacement = bind(&runtime, "retired", root.path()); + assert_eq!( + cell(&replacement, "'saved' in globals()") + .await + .value + .as_deref(), + Some("False") + ); + assert!(matches!( + task.execute("1", CancellationToken::new()).await, + Err(Error::Retired) + )); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn owner_revocation_cleans_up_an_idle_worker() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let lifetime = CancellationToken::new(); + let task = runtime + .bind( + "owner", + LaunchSpec { + python: packaged_python(), + cwd: root.path().canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + lifetime.clone(), + ) + .unwrap(); + cell( + &task, + "import asyncio\nbackground = asyncio.create_task(asyncio.sleep(1000))", + ) + .await; + lifetime.cancel(); + tokio::time::timeout(Duration::from_secs(10), async { + while !runtime.snapshot().holders.is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(matches!( + task.execute("1", CancellationToken::new()).await, + Err(Error::Retired) + )); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn cancellation_during_handshake_prevents_code_and_releases_capacity() { + let root = tempfile::tempdir().unwrap(); + let fake = root.path().join("delayed_ready.py"); + std::fs::write( + &fake, + r#" +import json, os, pathlib, platform, struct, sys, time +generation = int(sys.argv[-1]) +pathlib.Path('bootstrap-started').touch() +while not pathlib.Path('allow-ready').exists(): + time.sleep(0.005) +ready = json.dumps(dict(type='ready', generation=generation, protocol_version=1, + implementation='cpython', version=platform.python_version(), + executable=sys.executable, cwd=os.getcwd())).encode() +os.write(1, struct.pack('>I', len(ready)) + ready) +header = sys.stdin.buffer.read(4) +if len(header) == 4: + message = json.loads(sys.stdin.buffer.read(struct.unpack('>I', header)[0])) + if message['type'] == 'execute': + pathlib.Path('code-dispatched').touch() +"#, + ) + .unwrap(); + let mut python = packaged_python(); + python.worker = fake.canonicalize().unwrap(); + let runtime = runtime(); + let task = runtime + .bind( + "handshake", + LaunchSpec { + python, + cwd: root.path().canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + ) + .unwrap(); + let stop = CancellationToken::new(); + let pending = task.execute("raise RuntimeError('must not execute')", stop.clone()); + tokio::time::timeout(Duration::from_secs(5), async { + while !root.path().join("bootstrap-started").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + stop.cancel(); + std::fs::write(root.path().join("allow-ready"), "").unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), pending) + .await + .unwrap() + .unwrap(); + assert_eq!(outcome.status, OutcomeStatus::Cancelled); + assert!(!root.path().join("code-dispatched").exists()); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); +} diff --git a/apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs b/apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs new file mode 100644 index 000000000..9132c3ddc --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs @@ -0,0 +1,207 @@ +//! Process-tree checks use the staged CPython distribution, never PATH Python. +use std::{path::PathBuf, time::Duration}; + +use maple_code_mode::{ + Config, Error, LaunchSpec, OutcomeStatus, PackagedPython, Runtime, TaskHandle, +}; +use tokio_util::sync::CancellationToken; + +fn bind(runtime: &Runtime, root: &std::path::Path) -> TaskHandle { + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + runtime + .bind( + "process-tree", + LaunchSpec { + python: PackagedPython::from_manifest(manifest) + .expect("run `just python-prepare` first"), + cwd: root.canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + ) + .unwrap() +} + +async fn start_tree(task: &TaskHandle) -> (ProcessWitness, ProcessWitness) { + let outcome = tokio::time::timeout( + Duration::from_secs(15), + task.execute( + r#"import asyncio, os, pathlib, subprocess, sys +child_code = "import os, pathlib, sys, time; pathlib.Path(sys.argv[1]).write_text(str(os.getpid())); time.sleep(60)" +descendant = subprocess.Popen([sys.executable, '-I', '-B', '-c', child_code, str(pathlib.Path('descendant-ready').absolute())]) +while not pathlib.Path('descendant-ready').exists(): + await asyncio.sleep(0.01) +async def retained_background(): + try: + await asyncio.sleep(1000) + finally: + pathlib.Path('cooperative-finally').write_text('completed') +background = asyncio.create_task(retained_background()) +await asyncio.sleep(0) +print(os.getpid(), descendant.pid) +"#, + CancellationToken::new(), + ), + ) + .await + .expect("native tree startup exceeded deadline") + .expect("native tree startup transport failed"); + assert_eq!(outcome.status, OutcomeStatus::Ok, "{outcome:?}"); + let pids: Vec = outcome + .stdout + .split_whitespace() + .map(|pid| pid.parse().unwrap()) + .collect(); + assert_eq!(pids.len(), 2, "unexpected PID response: {outcome:?}"); + // Windows keeps handles to these exact process objects before retirement, + // so observing termination cannot be confused by later numeric PID reuse. + ( + ProcessWitness::capture(pids[0]), + ProcessWitness::capture(pids[1]), + ) +} + +fn runtime() -> Runtime { + Runtime::new(Config { + retirement_grace: Duration::from_millis(500), + ..Config::default() + }) +} + +#[tokio::test] +async fn cooperative_retirement_ends_the_ordinary_descendant() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, root.path()); + let (worker, descendant) = start_tree(&task).await; + tokio::time::timeout(Duration::from_secs(10), task.reset("tree test reset")) + .await + .unwrap() + .unwrap(); + assert!( + root.path().join("cooperative-finally").exists(), + "responsive shutdown did not execute the background cleanup handler" + ); + worker.assert_stopped(true).await; + descendant.assert_stopped(false).await; + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn blocked_loop_is_forced_down_with_its_ordinary_descendant() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, root.path()); + let (worker, descendant) = start_tree(&task).await; + let stop = CancellationToken::new(); + let execution = task.execute( + "pathlib.Path('sync-entered').write_text('entered')\nwhile True:\n pass", + stop.clone(), + ); + tokio::time::timeout(Duration::from_secs(5), async { + while !root.path().join("sync-entered").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the worker did not enter the synchronous infinite loop"); + stop.cancel(); + let outcome = tokio::time::timeout(Duration::from_secs(10), execution) + .await + .expect("cancelled execution did not settle"); + assert!( + matches!(outcome, Ok(ref value) if value.status == OutcomeStatus::Cancelled) + || matches!(outcome, Err(Error::Cancelled)), + "unexpected cancellation result: {outcome:?}" + ); + task.reset("confirm forced cleanup").await.unwrap(); + worker.assert_stopped(true).await; + descendant.assert_stopped(false).await; + assert!( + !root.path().join("cooperative-finally").exists(), + "the non-yielding loop unexpectedly ran cooperative cleanup" + ); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); +} + +struct ProcessWitness { + pid: u32, + #[cfg(windows)] + handle: windows::Win32::Foundation::HANDLE, +} + +impl ProcessWitness { + fn capture(pid: u32) -> Self { + #[cfg(windows)] + let handle = unsafe { + windows::Win32::System::Threading::OpenProcess( + windows::Win32::System::Threading::PROCESS_SYNCHRONIZE, + false, + pid, + ) + } + .expect("could not retain the running native process handle"); + Self { + pid, + #[cfg(windows)] + handle, + } + } + + async fn assert_stopped(&self, direct_worker: bool) { + tokio::time::timeout(Duration::from_secs(5), async { + while !self.stopped(direct_worker) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("native process {} is still running", self.pid)); + } + + #[cfg(unix)] + fn stopped(&self, direct_worker: bool) -> bool { + // The direct child must have been reaped. Arbitrary Unix grandchildren + // are not ours to reap: a zombie is terminated but awaits its own parent. + let result = unsafe { libc::kill(self.pid as i32, 0) }; + if result == -1 { + let error = std::io::Error::last_os_error(); + assert_eq!(error.raw_os_error(), Some(libc::ESRCH)); + return true; + } + if direct_worker { + return false; + } + let output = std::process::Command::new("/bin/ps") + .args(["-o", "stat=", "-p", &self.pid.to_string()]) + .output() + .expect("could not inspect the Unix descendant state"); + String::from_utf8_lossy(&output.stdout) + .trim() + .starts_with('Z') + } + + #[cfg(windows)] + fn stopped(&self, _direct_worker: bool) -> bool { + use windows::Win32::{ + Foundation::{WAIT_FAILED, WAIT_OBJECT_0}, + System::Threading::WaitForSingleObject, + }; + let result = unsafe { WaitForSingleObject(self.handle, 0) }; + assert_ne!(result, WAIT_FAILED, "native process wait failed"); + result == WAIT_OBJECT_0 + } +} + +#[cfg(windows)] +impl Drop for ProcessWitness { + fn drop(&mut self) { + let _ = unsafe { windows::Win32::Foundation::CloseHandle(self.handle) }; + } +} diff --git a/apps/maple-agent/crates/maple-harness/Cargo.toml b/apps/maple-agent/crates/maple-harness/Cargo.toml new file mode 100644 index 000000000..81144a09a --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "maple-harness" +description = "GPUI-free semantic action, authority, audit, and keymap core for Maple" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +[dependencies] +jsonschema = { version = "0.49.9", default-features = false } +schemars = { version = "1", features = ["derive", "uuid1"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = "2.0" +tokio-util = { workspace = true } +uuid = { version = "1.25", features = ["serde", "v4"] } diff --git a/apps/maple-agent/crates/maple-harness/src/action.rs b/apps/maple-agent/crates/maple-harness/src/action.rs new file mode 100644 index 000000000..c20e29ec4 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/action.rs @@ -0,0 +1,1551 @@ +use std::{fmt, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde_json::{Map, Value}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + audit::AuditSpec, + policy::InvocationPolicy, + semantic::{RevisionDomain, SemanticTarget}, +}; + +const MAX_ACTION_ID_LEN: usize = 128; +const MAX_REASON_CODE_LEN: usize = 64; +const MAX_CONTEXT_PATTERN_LEN: usize = 512; + +/// A stable, wire-facing action identifier. +/// +/// IDs use an intentionally conservative grammar so they survive Rust and UI +/// refactors: at least two dot-separated ASCII segments, each beginning with a +/// lowercase letter and continuing with lowercase letters, digits, or `_`. +#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct ActionId( + #[schemars( + length(min = 3, max = 128), + pattern(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") + )] + String, +); + +impl ActionId { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_action_id(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Display for ActionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for ActionId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for ActionId { + type Err = ActionIdError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl TryFrom for ActionId { + type Error = ActionIdError; + + fn try_from(value: String) -> Result { + Self::parse(value) + } +} + +impl TryFrom<&str> for ActionId { + type Error = ActionIdError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for ActionId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionIdError { + #[error("action ID is empty")] + Empty, + #[error("action ID exceeds {MAX_ACTION_ID_LEN} bytes")] + TooLong, + #[error("action ID must contain at least one namespace separator")] + MissingNamespace, + #[error("action ID contains an empty segment at position {segment}")] + EmptySegment { segment: usize }, + #[error("action ID segment {segment} must start with an ASCII lowercase letter")] + InvalidSegmentStart { segment: usize }, + #[error("action ID contains invalid character {character:?} at byte {byte_index}")] + InvalidCharacter { byte_index: usize, character: char }, +} + +fn validate_action_id(value: &str) -> Result<(), ActionIdError> { + if value.is_empty() { + return Err(ActionIdError::Empty); + } + if value.len() > MAX_ACTION_ID_LEN { + return Err(ActionIdError::TooLong); + } + if !value.contains('.') { + return Err(ActionIdError::MissingNamespace); + } + + let mut byte_offset = 0; + for (segment_index, segment) in value.split('.').enumerate() { + if segment.is_empty() { + return Err(ActionIdError::EmptySegment { + segment: segment_index, + }); + } + if !segment.as_bytes()[0].is_ascii_lowercase() { + return Err(ActionIdError::InvalidSegmentStart { + segment: segment_index, + }); + } + for (relative_index, character) in segment.char_indices() { + if !(character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_') { + return Err(ActionIdError::InvalidCharacter { + byte_index: byte_offset + relative_index, + character, + }); + } + } + byte_offset += segment.len() + 1; + } + Ok(()) +} + +/// Stable machine-readable reason code used by availability and errors. +#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct DisabledReasonCode( + #[schemars(length(min = 1, max = 64), pattern(r"^[a-z][a-z0-9_]*$"))] String, +); + +impl DisabledReasonCode { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_reason_code(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for DisabledReasonCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for DisabledReasonCode { + type Err = ReasonCodeError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for DisabledReasonCode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ReasonCodeError { + #[error("reason code is empty")] + Empty, + #[error("reason code exceeds {MAX_REASON_CODE_LEN} bytes")] + TooLong, + #[error("reason code must start with an ASCII lowercase letter")] + InvalidStart, + #[error("reason code contains invalid character {character:?} at byte {byte_index}")] + InvalidCharacter { byte_index: usize, character: char }, +} + +fn validate_reason_code(value: &str) -> Result<(), ReasonCodeError> { + if value.is_empty() { + return Err(ReasonCodeError::Empty); + } + if value.len() > MAX_REASON_CODE_LEN { + return Err(ReasonCodeError::TooLong); + } + if !value.as_bytes()[0].is_ascii_lowercase() { + return Err(ReasonCodeError::InvalidStart); + } + for (byte_index, character) in value.char_indices() { + if !(character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_') { + return Err(ReasonCodeError::InvalidCharacter { + byte_index, + character, + }); + } + } + Ok(()) +} + +macro_rules! uuid_id { + ($name:ident) => { + #[derive( + Clone, + Copy, + Debug, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + Deserialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub const fn from_uuid(value: Uuid) -> Self { + Self(value) + } + + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } + } + }; +} + +uuid_id!(InvocationId); +uuid_id!(ProgramId); +uuid_id!(ExecutionId); +// Stable identity for asynchronous work that outlives the admitting executor. +// InvocationId identifies the attempt/audit record; OperationId identifies +// the retained cancellation and terminal-completion handle. +uuid_id!(OperationId); + +/// Exact opaque identity assigned by Maple's model-run registry. +/// +/// Model run IDs are deliberately not re-minted as UUIDs at the harness +/// boundary: cancellation, tombstones, audit, and Code Mode must all refer to +/// the same host-owned value losslessly. +#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct RunId(#[schemars(length(min = 1, max = 4096))] String); + +impl RunId { + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } + + pub fn from_host(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(RunIdError::Empty); + } + if value.len() > 4096 { + return Err(RunIdError::TooLong); + } + if value.chars().any(char::is_control) { + return Err(RunIdError::ControlCharacter); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for RunId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for RunId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RunId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_host(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum RunIdError { + #[error("model run ID cannot be empty")] + Empty, + #[error("model run ID exceeds 4096 bytes")] + TooLong, + #[error("model run ID contains a control character")] + ControlCharacter, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TaskIdentity { + pub account_scope: String, + pub task_id: String, +} + +impl TaskIdentity { + pub fn new(account_scope: impl Into, task_id: impl Into) -> Self { + Self { + account_scope: account_scope.into(), + task_id: task_id.into(), + } + } +} + +#[derive(Clone, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct SemanticContextPattern(#[schemars(length(min = 1, max = 512))] String); + +impl SemanticContextPattern { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ContextPatternError::Empty); + } + if value.len() > MAX_CONTEXT_PATTERN_LEN { + return Err(ContextPatternError::TooLong); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(ContextPatternError::InvalidWhitespaceOrControl); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SemanticContextPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for SemanticContextPattern { + type Err = ContextPatternError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for SemanticContextPattern { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ContextPatternError { + #[error("semantic context pattern is empty")] + Empty, + #[error("semantic context pattern exceeds {MAX_CONTEXT_PATTERN_LEN} bytes")] + TooLong, + #[error("semantic context pattern has surrounding whitespace or control characters")] + InvalidWhitespaceOrControl, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionEffect { + Observe, + Navigate, + MutateMaple, + ExternalEffect, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Recoverability { + Ephemeral, + Reversible, + Irreversible, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShortcutProfile { + #[default] + Standard, + Vim, +} + +/// Declares how an action derives its canonical precondition domain from the +/// call's semantic target. +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PreconditionDomainSelector { + Global, + /// Use the complete semantic target as the revision domain. + Target, + /// Derive a task record domain from any task-bearing target. + Task, + Project, + /// Derive a task timeline domain from any task-bearing target. + Timeline, + TimelineItem, + /// Derive a draft domain from any task-bearing target. + Draft, + Setting, + Permission, + Question, + QueueItem, +} + +impl PreconditionDomainSelector { + fn resolve( + self, + target: Option<&SemanticTarget>, + ) -> Result { + if self == Self::Global { + return Ok(RevisionDomain::Global); + } + + let target = + target.ok_or(ActionPreconditionInvariantError::MissingTarget { selector: self })?; + let incompatible = || ActionPreconditionInvariantError::IncompatibleTarget { + selector: self, + target: Box::new(target.clone()), + }; + + match self { + Self::Global => unreachable!("global selector returned before target resolution"), + Self::Target => Ok(RevisionDomain::Target { + target: target.clone(), + }), + Self::Task => target + .task_id() + .map(|task_id| RevisionDomain::Task { + task_id: task_id.to_owned(), + }) + .ok_or_else(incompatible), + Self::Project => match target { + SemanticTarget::Project { canonical_root } => Ok(RevisionDomain::Project { + canonical_root: canonical_root.clone(), + }), + _ => Err(incompatible()), + }, + Self::Timeline => target + .task_id() + .map(|task_id| RevisionDomain::Timeline { + task_id: task_id.to_owned(), + }) + .ok_or_else(incompatible), + Self::TimelineItem => match target { + SemanticTarget::TimelineItem { task_id, item_id } + | SemanticTarget::Annotation { + task_id, item_id, .. + } => Ok(RevisionDomain::TimelineItem { + task_id: task_id.clone(), + item_id: item_id.clone(), + }), + _ => Err(incompatible()), + }, + Self::Draft => target + .task_id() + .map(|task_id| RevisionDomain::Draft { + task_id: task_id.to_owned(), + }) + .ok_or_else(incompatible), + Self::Setting => match target { + SemanticTarget::Setting { key } => Ok(RevisionDomain::Setting { key: key.clone() }), + _ => Err(incompatible()), + }, + Self::Permission => match target { + SemanticTarget::Permission { request_id } => Ok(RevisionDomain::Permission { + request_id: request_id.clone(), + }), + _ => Err(incompatible()), + }, + Self::Question => match target { + SemanticTarget::Question { + request_id, + question_id, + } => Ok(RevisionDomain::Question { + request_id: request_id.clone(), + question_id: question_id.clone(), + }), + _ => Err(incompatible()), + }, + Self::QueueItem => match target { + SemanticTarget::QueueItem { task_id, queue_id } => Ok(RevisionDomain::QueueItem { + task_id: task_id.clone(), + queue_id: queue_id.clone(), + }), + _ => Err(incompatible()), + }, + } + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefaultBinding { + pub profile: ShortcutProfile, + pub context: String, + pub sequence: String, + #[serde(default = "empty_object")] + pub arguments: Value, +} + +pub type DefaultBindings = Vec; + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionDescriptor { + pub schema_version: u16, + pub id: ActionId, + pub label: String, + pub description: String, + pub category: String, + pub argument_schema: Value, + pub result_schema: Value, + #[serde(default)] + pub contexts: Vec, + pub effect: ActionEffect, + pub invocation_policy: InvocationPolicy, + pub recoverability: Recoverability, + #[serde(default)] + pub audit: AuditSpec, + #[serde(default)] + pub default_bindings: DefaultBindings, + /// The action-declared revision domain accepted in call preconditions. + /// + /// `None` means this action does not accept a precondition. A declared + /// kind permits, but does not require, a call precondition; executors that + /// require compare-and-set semantics may additionally require its + /// presence after this structural check. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precondition_domain: Option, + /// Whether this action participates in the GPUI keymap/action catalog. + #[serde(default)] + pub bindable: bool, + /// True for a host action whose accepted response precedes process shutdown. + #[serde(default)] + pub terminal_host_action: bool, +} + +impl ActionDescriptor { + pub fn validate_arguments(&self, arguments: &Value) -> Result<(), SchemaValidationError> { + validate_instance(&self.argument_schema, arguments) + } + + pub fn validate_result(&self, result: &Value) -> Result<(), SchemaValidationError> { + validate_instance(&self.result_schema, result) + } + + /// Validates that a call uses this descriptor and cannot substitute a + /// different revision domain kind. + pub fn validate_precondition( + &self, + call: &ActionCall, + ) -> Result<(), ActionPreconditionInvariantError> { + if call.action_id != self.id { + return Err(ActionPreconditionInvariantError::ActionMismatch { + expected: self.id.clone(), + actual: call.action_id.clone(), + }); + } + match (self.precondition_domain, call.precondition.as_ref()) { + (None, None) | (Some(_), None) => Ok(()), + (None, Some(_)) => Err(ActionPreconditionInvariantError::UndeclaredPrecondition), + (Some(selector), Some(precondition)) => { + let expected = selector.resolve(call.target.as_ref())?; + if precondition.domain == expected { + Ok(()) + } else { + Err(ActionPreconditionInvariantError::DomainMismatch { + expected: Box::new(expected), + actual: Box::new(precondition.domain.clone()), + }) + } + } + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionPreconditionInvariantError { + #[error("call action {actual} does not match descriptor {expected}")] + ActionMismatch { + expected: ActionId, + actual: ActionId, + }, + #[error("action descriptor does not declare a precondition domain")] + UndeclaredPrecondition, + #[error("precondition selector {selector:?} requires a semantic target")] + MissingTarget { + selector: PreconditionDomainSelector, + }, + #[error("semantic target {target:?} cannot define precondition selector {selector:?}")] + IncompatibleTarget { + selector: PreconditionDomainSelector, + target: Box, + }, + #[error("precondition domain {actual:?} does not match canonical domain {expected:?}")] + DomainMismatch { + expected: Box, + actual: Box, + }, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SchemaValidationError { + #[error("invalid JSON schema: {0}")] + InvalidSchema(String), + #[error("value does not match schema: {0}")] + InvalidValue(String), +} + +fn validate_instance(schema: &Value, instance: &Value) -> Result<(), SchemaValidationError> { + let validator = jsonschema::validator_for(schema) + .map_err(|error| SchemaValidationError::InvalidSchema(error.to_string()))?; + if validator.is_valid(instance) { + Ok(()) + } else { + let message = validator + .iter_errors(instance) + .next() + .map(|error| error.masked().to_string()) + .unwrap_or_else(|| "unknown validation error".to_owned()); + Err(SchemaValidationError::InvalidValue(message)) + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionCall { + pub action_id: ActionId, + #[serde(default = "empty_object")] + pub arguments: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precondition: Option, +} + +impl ActionCall { + pub fn new(action_id: ActionId) -> Self { + Self { + action_id, + arguments: empty_object(), + target: None, + precondition: None, + } + } + + pub fn with_arguments(mut self, arguments: Value) -> Self { + self.arguments = arguments; + self + } + + pub fn with_target(mut self, target: SemanticTarget) -> Self { + self.target = Some(target); + self + } + + pub fn with_precondition(mut self, precondition: ActionPrecondition) -> Self { + self.precondition = Some(precondition); + self + } +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionPrecondition { + pub domain: RevisionDomain, + pub target_revision: u64, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Availability { + Available, + Disabled { + code: DisabledReasonCode, + message: String, + }, +} + +impl Availability { + pub const fn is_available(&self) -> bool { + matches!(self, Self::Available) + } + + pub fn disabled(code: DisabledReasonCode, message: impl Into) -> Self { + Self::Disabled { + code, + message: message.into(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionStatus { + Accepted, + AcceptedTerminal, + Completed, + Failed, + Cancelled, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionErrorCode { + UnknownAction, + InvalidArguments, + NotApplicable, + Unavailable, + PolicyDenied, + StaleTarget, + Cancelled, + Failed, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ActionError { + pub code: ActionErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ActionError { + /// Builds an error whose code does not require additional structured + /// metadata. Use [`Self::unavailable`] for `Unavailable` errors. + pub fn new( + code: ActionErrorCode, + message: impl Into, + ) -> Result { + let error = Self { + code, + message: message.into(), + reason_code: None, + details: None, + }; + error.validate()?; + Ok(error) + } + + pub fn unavailable(code: DisabledReasonCode, message: impl Into) -> Self { + Self { + code: ActionErrorCode::Unavailable, + message: message.into(), + reason_code: Some(code), + details: None, + } + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + pub fn validate(&self) -> Result<(), ActionErrorInvariantError> { + match (self.code, self.reason_code.as_ref()) { + (ActionErrorCode::Unavailable, None) => { + Err(ActionErrorInvariantError::UnavailableMissingReasonCode) + } + (ActionErrorCode::Unavailable, Some(_)) | (_, None) => Ok(()), + (code, Some(_)) => Err(ActionErrorInvariantError::UnexpectedReasonCode { code }), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ActionErrorWire { + code: ActionErrorCode, + message: String, + #[serde(default)] + reason_code: Option, + #[serde(default)] + details: Option, +} + +#[derive(Serialize)] +struct ActionErrorWireRef<'a> { + code: ActionErrorCode, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + reason_code: Option<&'a DisabledReasonCode>, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option<&'a Value>, +} + +impl Serialize for ActionError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + ActionErrorWireRef { + code: self.code, + message: &self.message, + reason_code: self.reason_code.as_ref(), + details: self.details.as_ref(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ActionError { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ActionErrorWire::deserialize(deserializer)?; + let error = Self { + code: wire.code, + message: wire.message, + reason_code: wire.reason_code, + details: wire.details, + }; + error.validate().map_err(de::Error::custom)?; + Ok(error) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionErrorInvariantError { + #[error("unavailable action error is missing a stable reason code")] + UnavailableMissingReasonCode, + #[error("action error code {code:?} must not include an unavailable reason code")] + UnexpectedReasonCode { code: ActionErrorCode }, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ActionResponse { + pub invocation_id: InvocationId, + pub action_id: ActionId, + pub status: ActionStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_revision: Option, +} + +impl ActionResponse { + /// Builds an asynchronous acceptance response with a fresh host operation + /// identity. The operation ID is part of the result envelope so action + /// result schemas and generated SDKs expose it without conflating it with + /// the invocation/audit identity. + pub fn accepted(invocation_id: InvocationId, action_id: ActionId, result: Value) -> Self { + Self::accepted_with_operation_id(invocation_id, action_id, OperationId::new(), result) + } + + /// Deterministic form used by a host that reserves an operation identity + /// before handing work to a backend. Existing `operation_id` input is + /// overwritten: an executor cannot select a caller-provided identity. + pub fn accepted_with_operation_id( + invocation_id: InvocationId, + action_id: ActionId, + operation_id: OperationId, + mut result: Value, + ) -> Self { + if let Some(result) = result.as_object_mut() { + result.insert( + "operation_id".to_string(), + Value::String(operation_id.to_string()), + ); + } + Self { + invocation_id, + action_id, + status: ActionStatus::Accepted, + result: Some(result), + error: None, + state_revision: None, + } + } + + /// Returns the stable operation identity for an accepted response. + /// Non-accepted responses deliberately have no operation handle. + pub fn operation_id(&self) -> Result, ActionResponseInvariantError> { + if self.status != ActionStatus::Accepted { + return Ok(None); + } + let value = self + .result + .as_ref() + .and_then(Value::as_object) + .and_then(|result| result.get("operation_id")) + .ok_or(ActionResponseInvariantError::AcceptedMissingOperationId)?; + serde_json::from_value(value.clone()) + .map(Some) + .map_err(|_| ActionResponseInvariantError::AcceptedInvalidOperationId) + } + + pub fn accepted_terminal( + invocation_id: InvocationId, + action_id: ActionId, + result: Value, + ) -> Self { + Self { + invocation_id, + action_id, + status: ActionStatus::AcceptedTerminal, + result: Some(result), + error: None, + state_revision: None, + } + } + + pub fn completed( + invocation_id: InvocationId, + action_id: ActionId, + result: Value, + state_revision: Option, + ) -> Self { + Self { + invocation_id, + action_id, + status: ActionStatus::Completed, + result: Some(result), + error: None, + state_revision, + } + } + + pub fn failed( + invocation_id: InvocationId, + action_id: ActionId, + error: ActionError, + ) -> Result { + let status = if error.code == ActionErrorCode::Cancelled { + ActionStatus::Cancelled + } else { + ActionStatus::Failed + }; + let response = Self { + invocation_id, + action_id, + status, + result: None, + error: Some(error), + state_revision: None, + }; + response.validate()?; + Ok(response) + } + + pub fn validate(&self) -> Result<(), ActionResponseInvariantError> { + if let Some(error) = &self.error { + error.validate()?; + } + + match self.status { + ActionStatus::Accepted => { + if self.error.is_some() { + return Err(ActionResponseInvariantError::SuccessHasError); + } + self.operation_id()?; + } + ActionStatus::AcceptedTerminal => { + if self.error.is_some() { + return Err(ActionResponseInvariantError::SuccessHasError); + } + } + ActionStatus::Completed => { + if self.error.is_some() { + return Err(ActionResponseInvariantError::SuccessHasError); + } + if self.result.is_none() { + return Err(ActionResponseInvariantError::CompletedMissingResult); + } + } + ActionStatus::Failed => { + if self.error.is_none() { + return Err(ActionResponseInvariantError::FailureMissingError); + } + if self.result.is_some() { + return Err(ActionResponseInvariantError::FailureHasResult); + } + if self.error.as_ref().map(|error| error.code) == Some(ActionErrorCode::Cancelled) { + return Err(ActionResponseInvariantError::FailedHasCancelledError); + } + } + ActionStatus::Cancelled => { + let Some(error) = &self.error else { + return Err(ActionResponseInvariantError::FailureMissingError); + }; + if self.result.is_some() { + return Err(ActionResponseInvariantError::FailureHasResult); + } + if error.code != ActionErrorCode::Cancelled { + return Err( + ActionResponseInvariantError::CancelledHasNonCancelledError { + actual: error.code, + }, + ); + } + } + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ActionResponseWire { + invocation_id: InvocationId, + action_id: ActionId, + status: ActionStatus, + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, + #[serde(default)] + state_revision: Option, +} + +#[derive(Serialize)] +struct ActionResponseWireRef<'a> { + invocation_id: InvocationId, + action_id: &'a ActionId, + status: ActionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option<&'a Value>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'a ActionError>, + #[serde(skip_serializing_if = "Option::is_none")] + state_revision: Option, +} + +impl Serialize for ActionResponse { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + ActionResponseWireRef { + invocation_id: self.invocation_id, + action_id: &self.action_id, + status: self.status, + result: self.result.as_ref(), + error: self.error.as_ref(), + state_revision: self.state_revision, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ActionResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ActionResponseWire::deserialize(deserializer)?; + let response = Self { + invocation_id: wire.invocation_id, + action_id: wire.action_id, + status: wire.status, + result: wire.result, + error: wire.error, + state_revision: wire.state_revision, + }; + response.validate().map_err(de::Error::custom)?; + Ok(response) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionResponseInvariantError { + #[error(transparent)] + InvalidError(#[from] ActionErrorInvariantError), + #[error("successful or accepted action response contains an error")] + SuccessHasError, + #[error("accepted action response is missing a stable operation_id result field")] + AcceptedMissingOperationId, + #[error("accepted action response contains an invalid operation_id")] + AcceptedInvalidOperationId, + #[error("completed action response is missing a result")] + CompletedMissingResult, + #[error("failed or cancelled action response is missing an error")] + FailureMissingError, + #[error("failed or cancelled action response contains a result")] + FailureHasResult, + #[error("failed action response contains a cancelled error")] + FailedHasCancelledError, + #[error("cancelled action response contains non-cancelled error {actual:?}")] + CancelledHasNonCancelledError { actual: ActionErrorCode }, +} + +fn empty_object() -> Value { + Value::Object(Map::new()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn descriptor(id: &str) -> ActionDescriptor { + ActionDescriptor { + schema_version: 1, + id: ActionId::parse(id).unwrap(), + label: "Test action".into(), + description: "Test action descriptor".into(), + category: "Tests".into(), + argument_schema: json!({"type": "object"}), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + effect: ActionEffect::MutateMaple, + invocation_policy: InvocationPolicy::ControllerCallable, + recoverability: Recoverability::Reversible, + audit: AuditSpec::default(), + default_bindings: Vec::new(), + precondition_domain: None, + bindable: false, + terminal_host_action: false, + } + } + + #[test] + fn stable_action_ids_accept_the_normative_grammar() { + for valid in [ + "app.quit", + "task.set_archived", + "composer.vim.count_digit", + "settings.set_model2", + ] { + assert_eq!(ActionId::parse(valid).unwrap().as_str(), valid); + } + } + + #[test] + fn stable_action_ids_reject_ambiguous_or_unstable_spellings() { + for invalid in [ + "", + "quit", + ".quit", + "app.", + "app..quit", + "App.quit", + "app.Quit", + "app. quit", + "app.-quit", + "app.quît", + ] { + assert!(ActionId::parse(invalid).is_err(), "accepted {invalid:?}"); + assert!( + serde_json::from_value::(json!(invalid)).is_err(), + "deserialized {invalid:?}" + ); + } + } + + #[test] + fn model_run_ids_preserve_host_owned_opaque_identity() { + let value = "run_1788062400000_17"; + let run = RunId::from_host(value).unwrap(); + assert_eq!(run.as_str(), value); + assert_eq!(serde_json::to_value(&run).unwrap(), json!(value)); + assert_eq!(serde_json::from_value::(json!(value)).unwrap(), run); + assert_eq!(RunId::from_host(" ").unwrap_err(), RunIdError::Empty); + } + + #[test] + fn argument_and_result_schemas_validate_wire_values() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.argument_schema = json!({ + "type": "object", + "required": ["task_id", "archived"], + "properties": { + "task_id": {"type": "string"}, + "archived": {"type": "boolean"} + }, + "additionalProperties": false + }); + action_descriptor.result_schema = json!({ + "type": "object", + "required": ["changed"], + "properties": {"changed": {"type": "boolean"}} + }); + action_descriptor.bindable = true; + + assert!( + action_descriptor + .validate_arguments(&json!({"task_id": "t1", "archived": true})) + .is_ok() + ); + assert!( + action_descriptor + .validate_arguments(&json!({"task_id": "t1", "archived": "yes"})) + .is_err() + ); + assert!( + action_descriptor + .validate_result(&json!({"changed": true})) + .is_ok() + ); + assert!(action_descriptor.validate_result(&json!({})).is_err()); + + let round_trip: ActionDescriptor = + serde_json::from_value(serde_json::to_value(&action_descriptor).unwrap()).unwrap(); + assert_eq!(round_trip, action_descriptor); + } + + #[test] + fn schema_validation_diagnostics_mask_offending_secret_values() { + let sentinel = "MAPLE_SECRET_SENTINEL_9f31"; + let error = validate_instance(&json!({"type": "string", "maxLength": 2}), &json!(sentinel)) + .unwrap_err(); + assert!(!error.to_string().contains(sentinel)); + } + + #[test] + fn controller_wire_types_generate_schemars_one_schemas_for_gpui_adapters() { + let action_call = schemars::schema_for!(ActionCall); + let target = schemars::schema_for!(SemanticTarget); + let precondition = schemars::schema_for!(ActionPrecondition); + + for schema in [action_call, target, precondition] { + let value = serde_json::to_value(schema).unwrap(); + assert!(value.is_object()); + assert!(value.get("$schema").is_some()); + } + } + + #[test] + fn unavailable_error_has_stable_reason_and_human_copy() { + let error = ActionError::unavailable( + DisabledReasonCode::parse("no_annotations").unwrap(), + "No annotations exist in this task", + ); + assert_eq!(error.code, ActionErrorCode::Unavailable); + assert_eq!(error.reason_code.unwrap().as_str(), "no_annotations"); + } + + #[test] + fn action_errors_enforce_unavailable_reason_code_in_both_directions() { + assert_eq!( + ActionError::new(ActionErrorCode::Unavailable, "not ready").unwrap_err(), + ActionErrorInvariantError::UnavailableMissingReasonCode + ); + assert!(ActionError::new(ActionErrorCode::Failed, "failed").is_ok()); + + assert!( + serde_json::from_value::(json!({ + "code": "unavailable", + "message": "not ready" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "code": "failed", + "message": "failed", + "reason_code": "not_ready" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "code": "unavailable", + "message": "not ready", + "reason_code": "not_ready" + })) + .is_ok() + ); + + let forged = ActionError { + code: ActionErrorCode::Failed, + message: "failed".into(), + reason_code: Some(DisabledReasonCode::parse("not_ready").unwrap()), + details: None, + }; + assert!(serde_json::to_value(forged).is_err()); + } + + #[test] + fn descriptor_rejects_caller_selected_precondition_domain_kind() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.precondition_domain = Some(PreconditionDomainSelector::Task); + + let matching = ActionCall::new(action_descriptor.id.clone()) + .with_target(SemanticTarget::Task { + task_id: "task-1".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Task { + task_id: "task-1".into(), + }, + target_revision: 7, + }); + assert!(action_descriptor.validate_precondition(&matching).is_ok()); + + let substituted = ActionCall::new(action_descriptor.id.clone()) + .with_target(SemanticTarget::Task { + task_id: "task-1".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Global, + target_revision: 7, + }); + assert_eq!( + action_descriptor + .validate_precondition(&substituted) + .unwrap_err(), + ActionPreconditionInvariantError::DomainMismatch { + expected: Box::new(RevisionDomain::Task { + task_id: "task-1".into(), + }), + actual: Box::new(RevisionDomain::Global), + } + ); + + let undeclared_call = ActionCall::new(ActionId::parse("task.open").unwrap()) + .with_target(SemanticTarget::Task { + task_id: "task-1".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Task { + task_id: "task-1".into(), + }, + target_revision: 7, + }); + let undeclared = descriptor("task.open").validate_precondition(&undeclared_call); + assert_eq!( + undeclared.unwrap_err(), + ActionPreconditionInvariantError::UndeclaredPrecondition + ); + } + + #[test] + fn descriptor_rejects_same_kind_precondition_for_a_different_identity() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.precondition_domain = Some(PreconditionDomainSelector::Task); + let call = ActionCall::new(action_descriptor.id.clone()) + .with_target(SemanticTarget::Task { + task_id: "task-a".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Task { + task_id: "task-b".into(), + }, + target_revision: 7, + }); + + assert_eq!( + action_descriptor.validate_precondition(&call).unwrap_err(), + ActionPreconditionInvariantError::DomainMismatch { + expected: Box::new(RevisionDomain::Task { + task_id: "task-a".into(), + }), + actual: Box::new(RevisionDomain::Task { + task_id: "task-b".into(), + }), + } + ); + } + + #[test] + fn descriptor_precondition_validation_is_bound_to_the_action_id() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.precondition_domain = Some(PreconditionDomainSelector::Task); + let other = ActionCall::new(ActionId::parse("task.open").unwrap()); + + assert!(matches!( + action_descriptor.validate_precondition(&other), + Err(ActionPreconditionInvariantError::ActionMismatch { .. }) + )); + } + + #[test] + fn response_invariants_distinguish_acceptance_from_completion() { + let invocation_id = InvocationId::new(); + let action_id = ActionId::parse("task.open").unwrap(); + let accepted = ActionResponse::accepted( + invocation_id, + action_id.clone(), + json!({ + "operation_id": "op-1" + }), + ); + assert_eq!(accepted.status, ActionStatus::Accepted); + assert!(accepted.validate().is_ok()); + + let completed = + ActionResponse::completed(invocation_id, action_id, json!({"task_id": "t1"}), Some(4)); + assert_eq!(completed.status, ActionStatus::Completed); + assert!(completed.validate().is_ok()); + } + + #[test] + fn response_deserialization_rejects_inconsistent_status_result_and_error() { + let invocation_id = InvocationId::new(); + let action_id = ActionId::parse("task.open").unwrap(); + let base = json!({ + "invocation_id": invocation_id, + "action_id": action_id, + }); + + let cases = [ + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "completed" + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "error": {"code": "failed", "message": "failed"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "result": {} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "result": {"operation_id": "caller-selected"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "failed" + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "failed", + "result": {}, + "error": {"code": "failed", "message": "failed"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "failed", + "error": {"code": "cancelled", "message": "cancelled"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "cancelled", + "error": {"code": "failed", "message": "failed"} + }), + ]; + + assert!(base.is_object()); + for case in cases { + assert!( + serde_json::from_value::(case.clone()).is_err(), + "accepted inconsistent response {case}" + ); + } + + assert!( + serde_json::from_value::(json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "result": {"operation_id": OperationId::new()} + })) + .is_ok() + ); + assert!( + serde_json::from_value::(json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "cancelled", + "error": {"code": "cancelled", "message": "cancelled"} + })) + .is_ok() + ); + + let forged = ActionResponse { + invocation_id, + action_id, + status: ActionStatus::Completed, + result: None, + error: None, + state_revision: None, + }; + assert!(serde_json::to_value(forged).is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/audit.rs b/apps/maple-agent/crates/maple-harness/src/audit.rs new file mode 100644 index 000000000..3af6710d8 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/audit.rs @@ -0,0 +1,869 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + ActionEffect, ActionErrorCode, ActionId, InvocationActor, InvocationId, InvocationTransport, + PolicyDecision, ProgramId, Recoverability, RunId, SemanticTarget, UiControllerAccess, +}; + +pub const DEFAULT_ACTION_AUDIT_CAPACITY: usize = 512; + +/// Descriptor-owned audit rules. Argument capture is deny-by-default and only +/// explicitly allowlisted top-level scalar fields can enter the audit ring. +#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AuditSpec { + pub safe_argument_fields: BTreeSet, + /// Marks an action whose arguments may contain authentication, credential, + /// or other secret material. Such actions must redact every argument. + pub secret_bearing: bool, +} + +impl AuditSpec { + pub fn redact_all() -> Self { + Self::default() + } + + pub fn allow_fields( + fields: impl IntoIterator>, + ) -> Result { + let spec = Self { + safe_argument_fields: fields.into_iter().map(Into::into).collect(), + secret_bearing: false, + }; + spec.validate()?; + Ok(spec) + } + + pub fn secret_bearing() -> Self { + Self { + safe_argument_fields: BTreeSet::new(), + secret_bearing: true, + } + } + + pub fn validate(&self) -> Result<(), AuditSpecError> { + if self.secret_bearing && !self.safe_argument_fields.is_empty() { + return Err(AuditSpecError::SecretBearingAllowlist); + } + for field in &self.safe_argument_fields { + if field.is_empty() || field.trim() != field { + return Err(AuditSpecError::InvalidField(field.clone())); + } + if field_is_sensitive(field) { + return Err(AuditSpecError::SensitiveField(field.clone())); + } + } + Ok(()) + } + + pub fn redact(&self, arguments: &Value) -> RedactedArguments { + let Some(object) = arguments.as_object() else { + return RedactedArguments { + fields: BTreeMap::new(), + redacted_field_count: usize::from(!arguments.is_null()), + }; + }; + + let mut fields = BTreeMap::new(); + let mut redacted_field_count = 0; + for (key, value) in object { + if !self.secret_bearing + && self.safe_argument_fields.contains(key) + && !field_is_sensitive(key) + && is_safe_scalar(value) + { + fields.insert(key.clone(), value.clone()); + } else { + redacted_field_count += 1; + } + } + RedactedArguments { + fields, + redacted_field_count, + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum AuditSpecError { + #[error("secret-bearing actions must redact every argument")] + SecretBearingAllowlist, + #[error("audit allowlist field {0:?} is empty or has surrounding whitespace")] + InvalidField(String), + #[error("audit allowlist field {0:?} appears sensitive and cannot be retained")] + SensitiveField(String), +} + +fn is_safe_scalar(value: &Value) -> bool { + matches!(value, Value::Bool(_) | Value::Number(_) | Value::String(_)) +} + +fn field_is_sensitive(field: &str) -> bool { + let normalized = field.to_ascii_lowercase(); + [ + "password", + "passwd", + "secret", + "token", + "credential", + "authorization", + "cookie", + "oauth", + "callback", + "private_key", + "api_key", + "header", + "environment", + "python_code", + "python_output", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RedactedArguments { + fields: BTreeMap, + redacted_field_count: usize, +} + +impl RedactedArguments { + pub fn fields(&self) -> &BTreeMap { + &self.fields + } + + pub fn redacted_field_count(&self) -> usize { + self.redacted_field_count + } + + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationActorSummary { + DirectUser, + Model, + UserCode, + Internal, +} + +impl From for InvocationActorSummary { + fn from(actor: InvocationActor) -> Self { + match actor { + InvocationActor::DirectUser => Self::DirectUser, + InvocationActor::Model => Self::Model, + InvocationActor::UserCode => Self::UserCode, + InvocationActor::Internal => Self::Internal, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditOutcome { + Denied, + Started, + Accepted, + AcceptedTerminal, + Completed, + Failed, + Cancelled, + CompletedAfterCancelRequest, +} + +impl AuditOutcome { + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Denied + | Self::AcceptedTerminal + | Self::Completed + | Self::Failed + | Self::Cancelled + | Self::CompletedAfterCancelRequest + ) + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ActionAuditRecord { + pub sequence: u64, + pub invocation_id: InvocationId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_run_id: Option, + pub timestamp_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cancel_requested_at_ms: Option, + pub actor: InvocationActorSummary, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub action_id: ActionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + pub arguments: RedactedArguments, + pub effect: ActionEffect, + pub recoverability: Recoverability, + pub decision: PolicyDecision, + pub outcome: AuditOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, +} + +#[derive(Clone, Debug)] +pub struct ActionAuditDraft { + pub invocation_id: InvocationId, + pub program_id: Option, + pub model_run_id: Option, + pub timestamp_ms: i64, + pub actor: InvocationActorSummary, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub action_id: ActionId, + pub target: Option, + pub arguments: RedactedArguments, + pub effect: ActionEffect, + pub recoverability: Recoverability, + pub decision: PolicyDecision, + pub outcome: AuditOutcome, + pub error_code: Option, +} + +#[derive(Clone, Debug)] +pub struct ActionAuditRing { + capacity: usize, + next_sequence: u64, + records: VecDeque, +} + +impl ActionAuditRing { + pub fn new(capacity: usize) -> Result { + if capacity == 0 { + return Err(AuditRingError::ZeroCapacity); + } + Ok(Self { + capacity, + next_sequence: 1, + records: VecDeque::with_capacity(capacity), + }) + } + + /// Retains a new audit record without ever evicting an invocation whose + /// terminal outcome has not been observed yet. + /// + /// When the ring is full, the oldest terminal record is evicted. If every + /// retained record is still pending, the caller must apply backpressure (or + /// otherwise fail the attempted invocation) rather than losing the audit + /// trail needed to finish an in-flight invocation. + pub fn record(&mut self, draft: ActionAuditDraft) -> Result { + if draft.outcome == AuditOutcome::CompletedAfterCancelRequest { + return Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + draft.invocation_id, + )); + } + if self.records.len() == self.capacity { + let oldest_terminal = self + .records + .iter() + .position(|record| record.outcome.is_terminal()) + .ok_or(AuditRingError::CapacityExceeded { + capacity: self.capacity, + })?; + self.records.remove(oldest_terminal); + } + + let record = ActionAuditRecord { + sequence: self.next_sequence, + invocation_id: draft.invocation_id, + program_id: draft.program_id, + model_run_id: draft.model_run_id, + timestamp_ms: draft.timestamp_ms, + duration_ms: None, + cancel_requested_at_ms: None, + actor: draft.actor, + transport: draft.transport, + controller_access: draft.controller_access, + policy_epoch: draft.policy_epoch, + action_id: draft.action_id, + target: draft.target, + arguments: draft.arguments, + effect: draft.effect, + recoverability: draft.recoverability, + decision: draft.decision, + outcome: draft.outcome, + error_code: draft.error_code, + }; + self.next_sequence = self.next_sequence.saturating_add(1); + self.records.push_back(record.clone()); + Ok(record) + } + + /// Records lifecycle evidence that cancellation was requested for an + /// in-flight invocation. Repeated calls are idempotent and preserve the + /// first request timestamp. + pub fn mark_cancel_requested( + &mut self, + invocation_id: InvocationId, + timestamp_ms: i64, + ) -> Result { + let record = self + .records + .iter_mut() + .rev() + .find(|record| record.invocation_id == invocation_id) + .ok_or(AuditRingError::InvocationNotRetained(invocation_id))?; + if record.outcome.is_terminal() { + return Err(AuditRingError::AlreadyTerminal(invocation_id)); + } + if timestamp_ms < record.timestamp_ms { + return Err(AuditRingError::CancelRequestBeforeStart { + invocation_id, + started_at_ms: record.timestamp_ms, + requested_at_ms: timestamp_ms, + }); + } + if record.cancel_requested_at_ms.is_none() { + record.cancel_requested_at_ms = Some(timestamp_ms); + } + Ok(record.clone()) + } + + /// Records the nonterminal boundary where an executor handed ownership to + /// a retained asynchronous operation. Replaying the same acceptance is + /// idempotent so UI/controller delivery retries cannot manufacture a + /// second lifecycle transition. + pub fn accept( + &mut self, + invocation_id: InvocationId, + ) -> Result { + let record = self + .records + .iter_mut() + .rev() + .find(|record| record.invocation_id == invocation_id) + .ok_or(AuditRingError::InvocationNotRetained(invocation_id))?; + match record.outcome { + AuditOutcome::Started => record.outcome = AuditOutcome::Accepted, + AuditOutcome::Accepted => {} + _ => return Err(AuditRingError::AlreadyTerminal(invocation_id)), + } + Ok(record.clone()) + } + + pub fn finish( + &mut self, + invocation_id: InvocationId, + duration_ms: u64, + outcome: AuditOutcome, + error_code: Option, + ) -> Result { + if !outcome.is_terminal() { + return Err(AuditRingError::NonTerminalFinish(outcome)); + } + let record = self + .records + .iter_mut() + .rev() + .find(|record| record.invocation_id == invocation_id) + .ok_or(AuditRingError::InvocationNotRetained(invocation_id))?; + if record.outcome.is_terminal() { + return Err(AuditRingError::AlreadyTerminal(invocation_id)); + } + if outcome == AuditOutcome::CompletedAfterCancelRequest { + if record.effect != ActionEffect::ExternalEffect + && record.effect != ActionEffect::MutateMaple + { + return Err(AuditRingError::InvalidCompletedAfterCancelEffect); + } + if record.recoverability != Recoverability::Irreversible { + return Err(AuditRingError::CompletedAfterCancelRequiresIrreversible); + } + if record.cancel_requested_at_ms.is_none() { + return Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + invocation_id, + )); + } + } else if outcome == AuditOutcome::Completed + && record.recoverability == Recoverability::Irreversible + && record.cancel_requested_at_ms.is_some() + { + return Err(AuditRingError::CompletedAfterCancelOutcomeRequired( + invocation_id, + )); + } + record.duration_ms = Some(duration_ms); + record.outcome = outcome; + record.error_code = error_code; + Ok(record.clone()) + } + + pub fn records(&self) -> impl DoubleEndedIterator { + self.records.iter() + } + + pub fn recent(&self, limit: usize) -> Vec { + self.records.iter().rev().take(limit).cloned().collect() + } + + pub fn len(&self) -> usize { + self.records.len() + } + + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } +} + +impl Default for ActionAuditRing { + fn default() -> Self { + Self::new(DEFAULT_ACTION_AUDIT_CAPACITY) + .expect("default action audit ring capacity is nonzero") + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum AuditRingError { + #[error("action audit ring capacity must be greater than zero")] + ZeroCapacity, + #[error("action audit ring capacity {capacity} is exhausted by nonterminal invocations")] + CapacityExceeded { capacity: usize }, + #[error("cannot finish an audit record with nonterminal outcome {0:?}")] + NonTerminalFinish(AuditOutcome), + #[error("invocation {0} is not retained in the bounded audit ring")] + InvocationNotRetained(InvocationId), + #[error("invocation {0} already has a terminal audit outcome")] + AlreadyTerminal(InvocationId), + #[error( + "cancel request for invocation {invocation_id} at {requested_at_ms} predates its audit start at {started_at_ms}" + )] + CancelRequestBeforeStart { + invocation_id: InvocationId, + started_at_ms: i64, + requested_at_ms: i64, + }, + #[error("completed_after_cancel_request is invalid for an observe/navigation action")] + InvalidCompletedAfterCancelEffect, + #[error("completed_after_cancel_request requires irreversible recoverability")] + CompletedAfterCancelRequiresIrreversible, + #[error("completed_after_cancel_request requires a recorded cancel request for invocation {0}")] + CompletedAfterCancelWithoutCancelRequest(InvocationId), + #[error( + "invocation {0} completed after cancellation and requires completed_after_cancel_request" + )] + CompletedAfterCancelOutcomeRequired(InvocationId), +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{ActionId, PolicyDenialCode}; + + fn draft( + invocation_id: InvocationId, + arguments: RedactedArguments, + outcome: AuditOutcome, + ) -> ActionAuditDraft { + ActionAuditDraft { + invocation_id, + program_id: Some(ProgramId::new()), + model_run_id: None, + timestamp_ms: 1_000, + actor: InvocationActorSummary::Model, + transport: InvocationTransport::Python, + controller_access: UiControllerAccess::FullAccess, + policy_epoch: 3, + action_id: ActionId::parse("permission.respond").unwrap(), + target: Some(SemanticTarget::Permission { + request_id: "request-1".into(), + }), + arguments, + effect: ActionEffect::MutateMaple, + recoverability: Recoverability::Reversible, + decision: PolicyDecision::Allowed, + outcome, + error_code: None, + } + } + + #[test] + fn redaction_is_descriptor_driven_and_deny_by_default() { + let spec = AuditSpec::allow_fields(["task_id", "archived"]).unwrap(); + let redacted = spec.redact(&json!({ + "task_id": "task-1", + "archived": true, + "password": "do-not-log", + "nested": {"token": "also-do-not-log"} + })); + assert_eq!(redacted.fields()["task_id"], "task-1"); + assert_eq!(redacted.fields()["archived"], true); + assert_eq!(redacted.redacted_field_count(), 2); + let encoded = serde_json::to_string(&redacted).unwrap(); + assert!(!encoded.contains("do-not-log")); + assert!(!encoded.contains("also-do-not-log")); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("token")); + } + + #[test] + fn unsafe_secret_bearing_allowlists_are_rejected() { + assert!(AuditSpec::allow_fields(["access_token"]).is_err()); + let mut spec = AuditSpec::secret_bearing(); + spec.safe_argument_fields.insert("task_id".into()); + assert_eq!(spec.validate(), Err(AuditSpecError::SecretBearingAllowlist)); + } + + #[test] + fn bounded_ring_evicts_oldest_and_keeps_monotonic_sequence() { + let mut ring = ActionAuditRing::new(2).unwrap(); + let first = ring + .record(draft( + InvocationId::new(), + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + ring.record(draft( + InvocationId::new(), + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + let third = ring + .record(draft( + InvocationId::new(), + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + assert_eq!(ring.len(), 2); + assert_eq!(first.sequence, 1); + assert_eq!(third.sequence, 3); + assert!(ring.records().all(|record| record.sequence != 1)); + } + + #[test] + fn full_ring_never_evicts_nonterminal_records() { + let first_id = InvocationId::new(); + let second_id = InvocationId::new(); + let rejected_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(2).unwrap(); + let first = ring + .record(draft( + first_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + let second = ring + .record(draft( + second_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + )) + .unwrap(); + + assert_eq!( + ring.record(draft( + rejected_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )), + Err(AuditRingError::CapacityExceeded { capacity: 2 }) + ); + assert_eq!( + ring.records() + .map(|record| (record.sequence, record.invocation_id)) + .collect::>(), + vec![(first.sequence, first_id), (second.sequence, second_id)] + ); + + let completed = ring + .finish(first_id, 17, AuditOutcome::Completed, None) + .unwrap(); + assert_eq!(completed.sequence, first.sequence); + + let replacement = ring + .record(draft( + rejected_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + assert_eq!(replacement.sequence, 3); + assert_eq!( + ring.records() + .map(|record| record.invocation_id) + .collect::>(), + vec![second_id, rejected_id] + ); + } + + #[test] + fn full_ring_evicts_oldest_terminal_even_when_pending_record_is_older() { + let pending_id = InvocationId::new(); + let first_terminal_id = InvocationId::new(); + let second_terminal_id = InvocationId::new(); + let replacement_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(3).unwrap(); + + ring.record(draft( + pending_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + ring.record(draft( + first_terminal_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + ring.record(draft( + second_terminal_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Completed, + )) + .unwrap(); + + ring.record(draft( + replacement_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + + assert_eq!( + ring.records() + .map(|record| record.invocation_id) + .collect::>(), + vec![pending_id, second_terminal_id, replacement_id] + ); + assert!( + ring.finish(pending_id, 23, AuditOutcome::Completed, None) + .is_ok() + ); + } + + #[test] + fn async_audit_is_not_completed_until_terminal_result_is_known() { + let invocation_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(8).unwrap(); + let started = ring + .record(draft( + invocation_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + assert_eq!(started.outcome, AuditOutcome::Started); + assert_eq!(started.duration_ms, None); + + let accepted = ring.accept(invocation_id).unwrap(); + assert_eq!(accepted.outcome, AuditOutcome::Accepted); + assert_eq!(ring.accept(invocation_id).unwrap(), accepted); + assert_eq!(accepted.duration_ms, None); + + let completed = ring + .finish(invocation_id, 42, AuditOutcome::Completed, None) + .unwrap(); + assert_eq!(completed.outcome, AuditOutcome::Completed); + assert_eq!(completed.duration_ms, Some(42)); + assert!(matches!( + ring.finish(invocation_id, 50, AuditOutcome::Cancelled, None), + Err(AuditRingError::AlreadyTerminal(_)) + )); + } + + #[test] + fn completed_after_cancel_requires_irreversible_recoverability_and_cancel_evidence() { + let reversible_id = InvocationId::new(); + let unmarked_irreversible_id = InvocationId::new(); + let raced_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(4).unwrap(); + + ring.record(draft( + reversible_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + )) + .unwrap(); + ring.mark_cancel_requested(reversible_id, 1_100).unwrap(); + assert_eq!( + ring.finish( + reversible_id, + 100, + AuditOutcome::CompletedAfterCancelRequest, + None, + ), + Err(AuditRingError::CompletedAfterCancelRequiresIrreversible) + ); + + let mut unmarked_irreversible = draft( + unmarked_irreversible_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + ); + unmarked_irreversible.recoverability = Recoverability::Irreversible; + ring.record(unmarked_irreversible).unwrap(); + assert_eq!( + ring.finish( + unmarked_irreversible_id, + 100, + AuditOutcome::CompletedAfterCancelRequest, + None, + ), + Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + unmarked_irreversible_id + )) + ); + + let mut raced = draft( + raced_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + ); + raced.recoverability = Recoverability::Irreversible; + ring.record(raced).unwrap(); + let marked = ring.mark_cancel_requested(raced_id, 1_125).unwrap(); + assert_eq!(marked.cancel_requested_at_ms, Some(1_125)); + let completed = ring + .finish( + raced_id, + 125, + AuditOutcome::CompletedAfterCancelRequest, + None, + ) + .unwrap(); + assert_eq!(completed.recoverability, Recoverability::Irreversible); + assert_eq!(completed.cancel_requested_at_ms, Some(1_125)); + assert_eq!(completed.outcome, AuditOutcome::CompletedAfterCancelRequest); + let encoded = serde_json::to_value(completed).unwrap(); + assert_eq!(encoded["recoverability"], "irreversible"); + assert_eq!(encoded["cancel_requested_at_ms"], 1_125); + } + + #[test] + fn irreversible_completion_after_cancel_must_use_race_outcome() { + let invocation_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(2).unwrap(); + let mut accepted = draft( + invocation_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + ); + accepted.recoverability = Recoverability::Irreversible; + ring.record(accepted).unwrap(); + ring.mark_cancel_requested(invocation_id, 1_050).unwrap(); + + assert_eq!( + ring.finish(invocation_id, 50, AuditOutcome::Completed, None), + Err(AuditRingError::CompletedAfterCancelOutcomeRequired( + invocation_id + )) + ); + assert!( + ring.finish( + invocation_id, + 50, + AuditOutcome::CompletedAfterCancelRequest, + None, + ) + .is_ok() + ); + } + + #[test] + fn cancel_request_evidence_is_ordered_idempotent_and_only_for_inflight_records() { + let invocation_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(2).unwrap(); + ring.record(draft( + invocation_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + + assert_eq!( + ring.mark_cancel_requested(invocation_id, 999), + Err(AuditRingError::CancelRequestBeforeStart { + invocation_id, + started_at_ms: 1_000, + requested_at_ms: 999, + }) + ); + ring.mark_cancel_requested(invocation_id, 1_025).unwrap(); + let repeated = ring.mark_cancel_requested(invocation_id, 1_030).unwrap(); + assert_eq!(repeated.cancel_requested_at_ms, Some(1_025)); + ring.finish(invocation_id, 25, AuditOutcome::Cancelled, None) + .unwrap(); + assert_eq!( + ring.mark_cancel_requested(invocation_id, 1_040), + Err(AuditRingError::AlreadyTerminal(invocation_id)) + ); + } + + #[test] + fn completed_after_cancel_cannot_be_inserted_without_lifecycle_evidence() { + let invalid_id = InvocationId::new(); + let valid_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(1).unwrap(); + let mut invalid = draft( + invalid_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::CompletedAfterCancelRequest, + ); + invalid.recoverability = Recoverability::Irreversible; + assert_eq!( + ring.record(invalid), + Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + invalid_id + )) + ); + assert!(ring.is_empty()); + assert_eq!( + ring.record(draft( + valid_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap() + .sequence, + 1 + ); + } + + #[test] + fn denied_decision_is_serialized_without_sensitive_arguments() { + let invocation_id = InvocationId::new(); + let mut denied = draft( + invocation_id, + AuditSpec::default().redact(&json!({"secret": "never"})), + AuditOutcome::Denied, + ); + denied.decision = PolicyDecision::Denied { + code: PolicyDenialCode::ReadOnlyMutation, + message: "Read Only cannot respond".into(), + }; + let record = ActionAuditRing::new(4).unwrap().record(denied).unwrap(); + let encoded = serde_json::to_string(&record).unwrap(); + assert!(encoded.contains("read_only_mutation")); + assert!(!encoded.contains("never")); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/catalog.rs b/apps/maple-agent/crates/maple-harness/src/catalog.rs new file mode 100644 index 000000000..48d9633d3 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/catalog.rs @@ -0,0 +1,1017 @@ +//! The initial semantic action catalog, as data. +//! +//! The prototype's `app/src/harness/catalog.rs` (about 3,300 lines) declared +//! every descriptor next to its typed GPUI adapter and executor. That file +//! cannot survive without GPUI, but the *shape* of the catalog can: stable +//! dot-separated IDs, one family per application area, explicit effect and +//! authority per action, argument schemas that name stable identifiers (task +//! IDs, canonical roots, item IDs, setting keys) and never indices or entity +//! handles, and default Standard/Vim bindings declared on the descriptor. +//! +//! This module rebuilds a representative slice of section 11.1 of the design +//! document as a validated [`ActionRegistry`]. It is deliberately smaller +//! than the original (about forty actions instead of a few hundred) but it +//! covers every family and every authority class, so tests can prove the +//! contract rules: Human Only identity operations, the `app.quit` terminal +//! contract, the `permission.respond` Full Access contract, bindable actions +//! carrying adapters, and secret-bearing audit redaction. + +use serde_json::{Value, json}; + +use crate::{ + ActionDescriptor, ActionEffect, ActionId, ActionRegistry, AuditSpec, DefaultBinding, + InvocationPolicy, PreconditionDomainSelector, Recoverability, RegistryBuilder, + RegistryValidationError, SCHEMA_VERSION, SemanticContextPattern, ShortcutProfile, +}; + +/// One row of the catalog table before it becomes a descriptor. +struct Row { + id: &'static str, + label: &'static str, + description: &'static str, + category: &'static str, + arguments: Value, + effect: ActionEffect, + policy: InvocationPolicy, + recoverability: Recoverability, + audit: AuditSpec, + precondition: Option, + contexts: &'static [&'static str], + bindings: Vec<(ShortcutProfile, &'static str, &'static str)>, + terminal: bool, +} + +const NO_ARGS: fn() -> Value = || json!({"type": "object", "additionalProperties": false}); + +fn args(properties: Value, required: &[&str]) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": properties, + "required": required, + }) +} + +fn optional_task_arg() -> Value { + args(json!({"task_id": {"type": "string", "minLength": 1}}), &[]) +} + +fn task_arg() -> Value { + args( + json!({"task_id": {"type": "string", "minLength": 1}}), + &["task_id"], + ) +} + +fn rows() -> Vec { + use ActionEffect::*; + use InvocationPolicy::*; + use Recoverability::*; + let std = ShortcutProfile::Standard; + let vim = ShortcutProfile::Vim; + vec![ + // App / screens + Row { + id: "app.quit", + label: "Quit Maple", + description: "Commit an orderly shutdown after the accepted response is delivered.", + category: "App", + arguments: NO_ARGS(), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![(std, "MapleApp", "cmd-q")], + terminal: true, + }, + Row { + id: "settings.open", + label: "Open Settings", + description: "Show the settings screen, parking the current chat.", + category: "App", + arguments: args( + json!({"section": {"type": "string", "enum": ["account", "appearance", "shortcuts", "programmability", "mcp"]}}), + &[], + ), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["section"]).unwrap(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![ + (std, "MapleApp", "cmd-,"), + (vim, "MapleApp && app_vim_mode == normal", "space s"), + ], + terminal: false, + }, + Row { + id: "settings.close", + label: "Close Settings", + description: "Return to the parked chat screen.", + category: "App", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp && screen == settings"], + bindings: vec![(std, "MapleApp && screen == settings", "escape")], + terminal: false, + }, + Row { + id: "shortcuts.open", + label: "Open Shortcut Settings", + description: "Open the shortcut profile editor.", + category: "App", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![(std, "MapleApp", "cmd-k cmd-s")], + terminal: false, + }, + Row { + id: "palette.open", + label: "Command Palette", + description: "Open the root command palette.", + category: "App", + arguments: args(json!({"query": {"type": "string", "maxLength": 256}}), &[]), + effect: Navigate, + policy: HumanOnly, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["query"]).unwrap(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![ + (std, "MapleApp", "cmd-shift-p"), + (vim, "MapleApp && app_vim_mode == normal", ":"), + ], + terminal: false, + }, + // Account / auth: Human Only + Row { + id: "account.sign_out", + label: "Sign Out", + description: "End the current Maple session on this device.", + category: "Account", + arguments: NO_ARGS(), + effect: ExternalEffect, + policy: HumanOnly, + recoverability: Irreversible, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + Row { + id: "account.delete", + label: "Delete Account", + description: "Begin the two-step account deletion flow.", + category: "Account", + arguments: NO_ARGS(), + effect: ExternalEffect, + policy: HumanOnly, + recoverability: Irreversible, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + Row { + id: "auth.submit_password", + label: "Sign In", + description: "Submit email and password on the login screen.", + category: "Account", + arguments: args( + json!({"email": {"type": "string"}, "password": {"type": "string"}}), + &["email", "password"], + ), + effect: ExternalEffect, + policy: HumanOnly, + recoverability: Irreversible, + audit: AuditSpec::secret_bearing(), + precondition: None, + contexts: &["MapleApp && screen == login"], + bindings: vec![], + terminal: false, + }, + // Tasks + Row { + id: "task.new", + label: "New Task", + description: "Create a task in the current project.", + category: "Tasks", + arguments: args(json!({"project_root": {"type": "string"}}), &[]), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["project_root"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Project), + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-n"), + (vim, "Chat && app_vim_mode == normal", "space n"), + ], + terminal: false, + }, + Row { + id: "task.open", + label: "Open Task", + description: "Make a task the active conversation.", + category: "Tasks", + arguments: task_arg(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "task.rename", + label: "Rename Task", + description: "Set a task title.", + category: "Tasks", + arguments: args( + json!({"task_id": {"type": "string"}, "title": {"type": "string", "maxLength": 200}}), + &["task_id", "title"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "task.set_archived", + label: "Archive Task", + description: "Archive or unarchive a task; a setter, not a toggle.", + category: "Tasks", + arguments: args( + json!({"task_id": {"type": "string"}, "archived": {"type": "boolean"}}), + &["task_id", "archived"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id", "archived"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "task.focus_next", + label: "Next Task", + description: "Move the sidebar selection to the next task.", + category: "Tasks", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Sidebar"], + bindings: vec![ + (std, "Sidebar", "down"), + (vim, "Sidebar && app_vim_mode == normal", "j"), + ], + terminal: false, + }, + Row { + id: "task.focus_previous", + label: "Previous Task", + description: "Move the sidebar selection to the previous task.", + category: "Tasks", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Sidebar"], + bindings: vec![ + (std, "Sidebar", "up"), + (vim, "Sidebar && app_vim_mode == normal", "k"), + ], + terminal: false, + }, + // Projects + Row { + id: "project.choose", + label: "Open Project…", + description: "Pick a project directory; with no path the executor opens the native picker.", + category: "Projects", + arguments: args(json!({"path": {"type": "string"}}), &[]), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["path"]).unwrap(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-o"), + (vim, "Chat && app_vim_mode == normal", "space p"), + ], + terminal: false, + }, + Row { + id: "project.set_trusted", + label: "Trust Project", + description: "Grant or revoke tool trust for a project root.", + category: "Projects", + arguments: args( + json!({"canonical_root": {"type": "string"}, "trusted": {"type": "boolean"}}), + &["canonical_root", "trusted"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["canonical_root", "trusted"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Project), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "project.set_collapsed", + label: "Collapse Project", + description: "Collapse or expand a sidebar project group.", + category: "Projects", + arguments: args( + json!({"canonical_root": {"type": "string"}, "collapsed": {"type": "boolean"}}), + &["canonical_root", "collapsed"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["canonical_root", "collapsed"]).unwrap(), + precondition: None, + contexts: &["Sidebar"], + bindings: vec![], + terminal: false, + }, + // Sidebar / transcript / regions + Row { + id: "sidebar.focus", + label: "Focus Sidebar", + description: "Move keyboard focus to the sidebar region.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-1"), + (vim, "Chat && app_vim_mode == normal", "g s"), + ], + terminal: false, + }, + Row { + id: "sidebar.set_collapsed", + label: "Toggle Sidebar", + description: "Show or hide the sidebar; a setter so callers state intent.", + category: "Navigation", + arguments: args(json!({"collapsed": {"type": "boolean"}}), &["collapsed"]), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["collapsed"]).unwrap(), + precondition: None, + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "transcript.focus", + label: "Focus Transcript", + description: "Move keyboard focus to the transcript region.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-2"), + (vim, "Chat && app_vim_mode == normal", "g t"), + ], + terminal: false, + }, + Row { + id: "transcript.focus_next", + label: "Next Message", + description: "Select the next transcript item.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Transcript"], + bindings: vec![ + (std, "Transcript", "down"), + (vim, "Transcript && app_vim_mode == normal", "j"), + ], + terminal: false, + }, + Row { + id: "transcript.focus_previous", + label: "Previous Message", + description: "Select the previous transcript item.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Transcript"], + bindings: vec![ + (std, "Transcript", "up"), + (vim, "Transcript && app_vim_mode == normal", "k"), + ], + terminal: false, + }, + Row { + id: "timeline.copy_item", + label: "Copy Message", + description: "Copy one timeline item by stable ID.", + category: "Transcript", + arguments: args( + json!({"task_id": {"type": "string"}, "item_id": {"type": "string"}}), + &["task_id", "item_id"], + ), + effect: Observe, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["task_id", "item_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::TimelineItem), + contexts: &["Transcript"], + bindings: vec![], + terminal: false, + }, + Row { + id: "timeline.copy_selected", + label: "Copy Selected Message", + description: "Copy the selected transcript item; a Human Only contextual alias that resolves to timeline.copy_item.", + category: "Transcript", + arguments: NO_ARGS(), + effect: Observe, + policy: HumanOnly, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Transcript"], + bindings: vec![ + (std, "Transcript", "cmd-c"), + (vim, "Transcript && app_vim_mode == normal", "y y"), + ], + terminal: false, + }, + Row { + id: "timeline.set_tool_expanded", + label: "Expand Tool Call", + description: "Expand or collapse a tool call card.", + category: "Transcript", + arguments: args( + json!({"task_id": {"type": "string"}, "item_id": {"type": "string"}, "expanded": {"type": "boolean"}}), + &["task_id", "item_id", "expanded"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["task_id", "item_id", "expanded"]).unwrap(), + precondition: Some(PreconditionDomainSelector::TimelineItem), + contexts: &["Transcript"], + bindings: vec![], + terminal: false, + }, + // Composer + Row { + id: "composer.focus", + label: "Focus Composer", + description: "Move keyboard focus to the composer.", + category: "Composer", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-3"), + (vim, "Chat && app_vim_mode == normal", "i"), + ], + terminal: false, + }, + Row { + id: "composer.set_text", + label: "Set Draft Text", + description: "Replace the draft text for a task.", + category: "Composer", + arguments: args( + json!({"task_id": {"type": "string"}, "text": {"type": "string", "maxLength": 200000}}), + &["task_id", "text"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Draft), + contexts: &["Composer"], + bindings: vec![], + terminal: false, + }, + Row { + id: "composer.send", + label: "Send", + description: "Send the current draft to the model.", + category: "Composer", + arguments: optional_task_arg(), + effect: ExternalEffect, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Draft), + contexts: &["Composer"], + bindings: vec![(std, "Composer && input_role == composer", "enter")], + terminal: false, + }, + Row { + id: "composer.attach_files", + label: "Attach Files", + description: "Attach files by path; with no paths the executor opens the native picker.", + category: "Composer", + arguments: args( + json!({"task_id": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}, "maxItems": 32}}), + &[], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Draft), + contexts: &["Composer"], + bindings: vec![(std, "Composer", "cmd-shift-a")], + terminal: false, + }, + Row { + id: "composer.set_model", + label: "Set Model", + description: "Choose the model for a task.", + category: "Composer", + arguments: args( + json!({"task_id": {"type": "string"}, "model": {"type": "string"}}), + &["task_id", "model"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id", "model"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Composer"], + bindings: vec![], + terminal: false, + }, + // Runs / queue + Row { + id: "run.stop", + label: "Stop", + description: "Stop the active model run for a task.", + category: "Runs", + arguments: optional_task_arg(), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-."), + (vim, "Chat && app_vim_mode == normal", "ctrl-c"), + ], + terminal: false, + }, + Row { + id: "queue.remove", + label: "Remove Queued Message", + description: "Remove one queued follow-up by queue ID.", + category: "Runs", + arguments: args( + json!({"task_id": {"type": "string"}, "queue_id": {"type": "string"}}), + &["task_id", "queue_id"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id", "queue_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Target), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + // Questions / permissions + Row { + id: "question.submit", + label: "Answer Question", + description: "Submit answers to a model question by request ID.", + category: "Requests", + arguments: args( + json!({"request_id": {"type": "string"}, "answers": {"type": "object"}}), + &["request_id", "answers"], + ), + effect: ExternalEffect, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["request_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Target), + contexts: &["Dialog && dialog == question"], + bindings: vec![], + terminal: false, + }, + Row { + id: "permission.respond", + label: "Respond to Permission", + description: "Allow or deny a tool permission request; an ordinary Full Access mutation by product decision.", + category: "Requests", + arguments: args( + json!({"request_id": {"type": "string"}, "decision": {"type": "string", "enum": ["allow_once", "allow_always", "deny"]}}), + &["request_id", "decision"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["request_id", "decision"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Target), + contexts: &["Dialog && dialog == permission"], + bindings: vec![], + terminal: false, + }, + // Settings + Row { + id: "settings.set_theme", + label: "Set Theme", + description: "Explicit setter for the theme preference.", + category: "Settings", + arguments: args( + json!({"theme": {"type": "string", "enum": ["system", "light", "dark"]}}), + &["theme"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["theme"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + Row { + id: "settings.set_shortcut_profile", + label: "Set Shortcut Profile", + description: "Switch between the Standard and Vim profiles.", + category: "Settings", + arguments: args( + json!({"profile": {"type": "string", "enum": ["standard", "vim"]}}), + &["profile"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["profile"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + Row { + id: "mcp.set_enabled", + label: "Enable MCP Server", + description: "Enable or disable one configured MCP server.", + category: "Settings", + arguments: args( + json!({"server_id": {"type": "string"}, "enabled": {"type": "boolean"}}), + &["server_id", "enabled"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["server_id", "enabled"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + // Utilities + Row { + id: "ui.dismiss", + label: "Dismiss", + description: "Close the topmost overlay, menu, or dialog.", + category: "Utilities", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Overlay", "Dialog"], + bindings: vec![(std, "Overlay || Dialog", "escape")], + terminal: false, + }, + Row { + id: "ui.activate_selected", + label: "Activate Selection", + description: "Activate the selected semantic object; resolves to a concrete action that is re-authorized.", + category: "Utilities", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat", "Settings"], + bindings: vec![ + (std, "Sidebar || Transcript || Settings", "enter"), + ( + vim, + "(Sidebar || Transcript || Settings) && app_vim_mode == normal", + "enter", + ), + ], + terminal: false, + }, + Row { + id: "link.open", + label: "Open Link", + description: "Open a URL in the system browser.", + category: "Utilities", + arguments: args( + json!({"url": {"type": "string", "format": "uri"}}), + &["url"], + ), + effect: ExternalEffect, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["url"]).unwrap(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + // Code Mode: authority changes are Human Only, execution is ordinary + Row { + id: "code_mode.set_enabled", + label: "Set Python Code Mode", + description: "Turn the Developer Preview Code Mode on or off.", + category: "Code Mode", + arguments: args(json!({"enabled": {"type": "boolean"}}), &["enabled"]), + effect: MutateMaple, + policy: HumanOnly, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["enabled"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + Row { + id: "code_mode.set_controller_access", + label: "Set UI Controller Access", + description: "Set the model/Python controller mode: Off, Read Only, or Full Access.", + category: "Code Mode", + arguments: args( + json!({"access": {"type": "string", "enum": ["off", "read_only", "full_access"]}}), + &["access"], + ), + effect: MutateMaple, + policy: HumanOnly, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["access"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + Row { + id: "code_mode.execute", + label: "Run Python", + description: "Execute source in the task's persistent kernel.", + category: "Code Mode", + arguments: args( + json!({"task_id": {"type": "string"}, "source": {"type": "string", "maxLength": 262144}}), + &["task_id", "source"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "code_mode.stop", + label: "Stop Python", + description: "Cancel the running execution and revoke its controller lease.", + category: "Code Mode", + arguments: task_arg(), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + ] +} + +fn descriptor(row: Row) -> ActionDescriptor { + let bindable = !row.bindings.is_empty(); + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(row.id).expect("catalog IDs follow the grammar"), + label: row.label.to_owned(), + description: row.description.to_owned(), + category: row.category.to_owned(), + argument_schema: row.arguments, + result_schema: json!({"type": "object"}), + contexts: row + .contexts + .iter() + .map(|c| SemanticContextPattern::parse(*c).expect("catalog contexts are valid")) + .collect(), + effect: row.effect, + invocation_policy: row.policy, + recoverability: row.recoverability, + audit: row.audit, + default_bindings: row + .bindings + .iter() + .map(|(profile, context, sequence)| DefaultBinding { + profile: *profile, + context: (*context).to_owned(), + sequence: (*sequence).to_owned(), + arguments: json!({}), + }) + .collect(), + precondition_domain: row.precondition, + bindable, + terminal_host_action: row.terminal, + } +} + +/// All descriptors of the initial catalog, unvalidated. +pub fn initial_descriptors() -> Vec { + rows().into_iter().map(descriptor).collect() +} + +/// The initial catalog as a validated registry. Every bindable action is +/// registered with an adapter, standing in for the one typed GPUI adapter the +/// application must provide per bindable action. +pub fn initial_registry() -> Result> { + let mut builder = RegistryBuilder::new(); + for descriptor in initial_descriptors() { + if descriptor.bindable { + builder.register_adapter(descriptor.id.clone()); + } + builder.register(descriptor); + } + builder.build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn initial_catalog_validates() { + let registry = initial_registry().unwrap_or_else(|errors| panic!("{errors:#?}")); + assert!(registry.len() >= 40); + } + + #[test] + fn every_family_in_section_11_1_is_represented() { + let registry = initial_registry().unwrap(); + let families: BTreeSet<&str> = registry + .iter() + .map(|(id, _)| id.as_str().split('.').next().unwrap()) + .collect(); + for family in [ + "app", + "settings", + "shortcuts", + "account", + "auth", + "task", + "project", + "sidebar", + "transcript", + "timeline", + "composer", + "run", + "queue", + "question", + "permission", + "mcp", + "ui", + "link", + "code_mode", + ] { + assert!(families.contains(family), "missing family {family}"); + } + } + + #[test] + fn identity_and_authority_actions_are_human_only() { + let registry = initial_registry().unwrap(); + for id in [ + "account.sign_out", + "account.delete", + "auth.submit_password", + "code_mode.set_enabled", + "code_mode.set_controller_access", + ] { + let descriptor = registry.descriptor(&ActionId::parse(id).unwrap()).unwrap(); + assert_eq!( + descriptor.invocation_policy, + InvocationPolicy::HumanOnly, + "{id}" + ); + } + let respond = registry + .descriptor(&ActionId::parse("permission.respond").unwrap()) + .unwrap(); + assert_eq!( + respond.invocation_policy, + InvocationPolicy::ControllerCallable + ); + let quit = registry + .descriptor(&ActionId::parse("app.quit").unwrap()) + .unwrap(); + assert!(quit.terminal_host_action); + } + + #[test] + fn secret_bearing_actions_redact_everything() { + let registry = initial_registry().unwrap(); + let login = registry + .descriptor(&ActionId::parse("auth.submit_password").unwrap()) + .unwrap(); + let redacted = login + .audit + .redact(&json!({"email": "a@b", "password": "hunter2"})); + assert!(redacted.is_empty()); + assert_eq!(redacted.redacted_field_count(), 2); + } + + #[test] + fn arguments_use_stable_identifiers_not_indices() { + for descriptor in initial_descriptors() { + let properties = descriptor.argument_schema["properties"] + .as_object() + .cloned() + .unwrap_or_default(); + for key in properties.keys() { + assert!( + !key.ends_with("_index") && !key.ends_with("_ix"), + "{}: {key}", + descriptor.id + ); + } + } + } + + #[test] + fn bindable_actions_have_adapters_and_vice_versa() { + let registry = initial_registry().unwrap(); + for (id, descriptor) in registry.iter() { + assert_eq!(descriptor.bindable, registry.has_adapter(id), "{id}"); + } + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/controller.rs b/apps/maple-agent/crates/maple-harness/src/controller.rs new file mode 100644 index 000000000..d0dd1ab8d --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/controller.rs @@ -0,0 +1,470 @@ +//! The controller bridge between Code Mode / model programs and the host. +//! +//! In the prototype this seam was split across `crates/maple-code-mode/src/ +//! controller.rs` (the `UiControllerTransport` trait, request disposition and +//! delivery fences) and `app/src/harness/controller.rs` (a bounded GPUI +//! request/response bridge). The Python `maple_gpui` SDK serialised an +//! [`ActionCall`]-shaped request into the worker pipe; the kernel attached +//! provenance; the UI thread claimed the request, ran it through the one +//! [`ActionHost`](crate::ActionHost), and wrote the response frame back. +//! +//! This module is a from-scratch, runtime-free restatement of that bridge. +//! It keeps the three properties that made the prototype safe: +//! +//! - **Bounded admission.** The queue has a fixed capacity and rejects, +//! never blocks, when the UI has fallen behind. +//! - **One owner per request.** A [`RequestDisposition`] fence decides, +//! exactly once, whether the transport abandoned a request (timeout, drop, +//! Stop) or the UI claimed it. A claimed request always gets its exact +//! response; an abandoned one never enters the host. +//! - **Delivery before destruction.** A [`DeliveryBarrier`] marks when the +//! response frame reached the worker. Terminal host actions (`app.quit`) +//! commit shutdown only after the barrier succeeds, so Python observes +//! `accepted_terminal` instead of a broken pipe. +//! +//! Wire requests never carry authority: the bridge stores the origin the +//! kernel minted and the host reads its *current* policy at dispatch. + +use std::{ + collections::VecDeque, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, + }, +}; + +use thiserror::Error; + +use crate::{ActionCall, ActionResponse, ActionStatus, ControllerOrigin, UiControllerAccess}; + +/// Default bound on requests waiting for the UI thread. +pub const DEFAULT_CONTROLLER_QUEUE_CAPACITY: usize = 64; + +const PENDING: u8 = 0; +const UI_CLAIMED: u8 = 1; +const ABANDONED: u8 = 2; + +/// One-shot ownership decision for a request at the transport/UI seam. +/// +/// Process-local; never serialised into the worker protocol. +#[derive(Clone, Debug, Default)] +pub struct RequestDisposition(Arc); + +impl RequestDisposition { + /// Claim UI ownership immediately before dispatch. `false` means the + /// transport already abandoned the request; skip it without entering the + /// host. + pub fn try_claim_ui(&self) -> bool { + self.0 + .compare_exchange(PENDING, UI_CLAIMED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + /// Abandon only while still queued. `false` means the UI owns it and the + /// transport must wait for the exact response. + pub fn abandon_if_pending(&self) -> bool { + self.0 + .compare_exchange(PENDING, ABANDONED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + pub fn is_ui_claimed(&self) -> bool { + self.0.load(Ordering::Acquire) == UI_CLAIMED + } + + pub fn is_abandoned(&self) -> bool { + self.0.load(Ordering::Acquire) == ABANDONED + } +} + +const DELIVERY_PENDING: u8 = 0; +const DELIVERY_SUCCEEDED: u8 = 1; +const DELIVERY_FAILED: u8 = 2; + +#[derive(Debug, Default)] +struct DeliveryState { + outcome: AtomicU8, + terminal_committed: AtomicBool, +} + +/// Completion fence for one response frame. +#[derive(Clone, Debug, Default)] +pub struct DeliveryBarrier(Arc); + +impl DeliveryBarrier { + /// The transport wrote the frame into the worker pipe. + pub fn mark_delivered(&self) -> bool { + self.0 + .outcome + .compare_exchange( + DELIVERY_PENDING, + DELIVERY_SUCCEEDED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + /// The transport could not write the frame (worker gone, pipe closed). + pub fn mark_failed(&self) -> bool { + self.0 + .outcome + .compare_exchange( + DELIVERY_PENDING, + DELIVERY_FAILED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub fn is_delivered(&self) -> bool { + self.0.outcome.load(Ordering::Acquire) == DELIVERY_SUCCEEDED + } + + pub fn is_settled(&self) -> bool { + self.0.outcome.load(Ordering::Acquire) != DELIVERY_PENDING + } + + /// Terminal host actions call this once the accepted-terminal frame is + /// delivered (or delivery definitively failed) to release shutdown. + pub fn commit_terminal(&self) -> Result<(), DeliveryError> { + if !self.is_settled() { + return Err(DeliveryError::TerminalBeforeDelivery); + } + self.0.terminal_committed.store(true, Ordering::Release); + Ok(()) + } + + pub fn is_terminal_committed(&self) -> bool { + self.0.terminal_committed.load(Ordering::Acquire) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum DeliveryError { + #[error( + "terminal shutdown may only commit after the response frame is delivered or delivery failed" + )] + TerminalBeforeDelivery, +} + +/// Monotonic per-bridge request identity (distinct from the host's +/// `InvocationId`, which is minted only when the UI claims the request). +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RequestId(u64); + +/// A request queued for the UI thread. +#[derive(Clone, Debug)] +pub struct QueuedRequest { + pub id: RequestId, + pub call: ActionCall, + pub origin: ControllerOrigin, + pub disposition: RequestDisposition, + pub delivery: DeliveryBarrier, +} + +/// A request the UI has claimed and must now run through the host. +#[derive(Debug)] +pub struct ClaimedRequest { + pub id: RequestId, + pub call: ActionCall, + pub origin: ControllerOrigin, + pub delivery: DeliveryBarrier, +} + +/// What the transport receives back for one request. +#[derive(Clone, Debug)] +pub struct Outcome { + pub id: RequestId, + pub response: ActionResponse, + pub delivery: DeliveryBarrier, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum SubmitError { + #[error("Maple UI Controller access is Off")] + ControllerOff, + #[error("controller request queue is full ({capacity})")] + QueueFull { capacity: usize }, + #[error("controller bridge is revoked; no new requests are admitted")] + Revoked, +} + +/// Bounded request/response bridge owned by the host process. +#[derive(Debug)] +pub struct ControllerBridge { + capacity: usize, + next_id: u64, + access: UiControllerAccess, + revoked: bool, + queue: VecDeque, + outcomes: Vec, +} + +impl Default for ControllerBridge { + fn default() -> Self { + Self::new(DEFAULT_CONTROLLER_QUEUE_CAPACITY) + } +} + +impl ControllerBridge { + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + next_id: 1, + access: UiControllerAccess::Off, + revoked: false, + queue: VecDeque::new(), + outcomes: Vec::new(), + } + } + + pub fn access(&self) -> UiControllerAccess { + self.access + } + + /// Mirrors a host authority change. Turning the controller Off revokes + /// every queued request (section 10.4); a later `ReadOnly`/`FullAccess` + /// re-arms the bridge for new programs. + pub fn set_access(&mut self, access: UiControllerAccess) -> usize { + self.access = access; + if access == UiControllerAccess::Off { + self.revoked = true; + self.revoke_queued() + } else { + self.revoked = false; + 0 + } + } + + /// Stop: abandon everything still queued. Claimed requests are not + /// touched; their exact response still flows back. + pub fn revoke_queued(&mut self) -> usize { + let mut revoked = 0; + for request in &self.queue { + if request.disposition.abandon_if_pending() { + revoked += 1; + } + } + self.queue.clear(); + revoked + } + + /// Transport side: enqueue a request minted by the kernel. + pub fn submit( + &mut self, + call: ActionCall, + origin: ControllerOrigin, + ) -> Result { + if self.revoked { + return Err(SubmitError::Revoked); + } + if self.access == UiControllerAccess::Off { + return Err(SubmitError::ControllerOff); + } + if self.queue.len() >= self.capacity { + return Err(SubmitError::QueueFull { + capacity: self.capacity, + }); + } + let request = QueuedRequest { + id: RequestId(self.next_id), + call, + origin, + disposition: RequestDisposition::default(), + delivery: DeliveryBarrier::default(), + }; + self.next_id += 1; + self.queue.push_back(request.clone()); + Ok(request) + } + + /// UI side: claim the next live request. Abandoned requests are dropped + /// silently; they never reach the host or the audit ring. + pub fn claim_next(&mut self) -> Option { + while let Some(request) = self.queue.pop_front() { + if request.origin.cancellation.is_cancelled() { + request.disposition.abandon_if_pending(); + continue; + } + if request.disposition.try_claim_ui() { + return Some(ClaimedRequest { + id: request.id, + call: request.call, + origin: request.origin, + delivery: request.delivery, + }); + } + } + None + } + + /// UI side: publish the host's response for a claimed request. + pub fn complete(&mut self, claimed: ClaimedRequest, response: ActionResponse) -> Outcome { + let outcome = Outcome { + id: claimed.id, + response, + delivery: claimed.delivery, + }; + self.outcomes.push(outcome.clone()); + outcome + } + + /// Transport side: drain responses ready to be framed to the worker. + pub fn take_outcomes(&mut self) -> Vec { + std::mem::take(&mut self.outcomes) + } + + pub fn queued_len(&self) -> usize { + self.queue.len() + } +} + +/// True when a response is the accepted-terminal frame that must be delivered +/// before the host may shut down. +pub fn is_terminal_frame(response: &ActionResponse) -> bool { + response.status == ActionStatus::AcceptedTerminal +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ActionBudget, ActionId, ExecutionId, InvocationActor, InvocationId, ProgramId}; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + fn origin(cancellation: CancellationToken) -> ControllerOrigin { + ControllerOrigin { + actor: InvocationActor::Model, + source_task: None, + program_id: ProgramId::new(), + program_started_unix_ms: 0, + execution_id: ExecutionId::new(), + model_run_id: None, + kernel_generation: 1, + observed_policy_epoch: 0, + cancellation, + budget: ActionBudget::new(8).unwrap(), + } + } + + fn call() -> ActionCall { + ActionCall::new(ActionId::parse("task.list").unwrap()) + } + + fn bridge(capacity: usize) -> ControllerBridge { + let mut bridge = ControllerBridge::new(capacity); + bridge.set_access(UiControllerAccess::ReadOnly); + bridge + } + + #[test] + fn off_and_full_reject_without_blocking() { + let mut bridge = ControllerBridge::new(1); + assert_eq!( + bridge + .submit(call(), origin(CancellationToken::new())) + .err(), + Some(SubmitError::ControllerOff) + ); + bridge.set_access(UiControllerAccess::FullAccess); + bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + assert_eq!( + bridge + .submit(call(), origin(CancellationToken::new())) + .err(), + Some(SubmitError::QueueFull { capacity: 1 }) + ); + } + + #[test] + fn abandoned_requests_never_reach_the_ui() { + let mut bridge = bridge(4); + let queued = bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + assert!(queued.disposition.abandon_if_pending()); + assert!(bridge.claim_next().is_none()); + assert!(!queued.disposition.try_claim_ui()); + } + + #[test] + fn claim_wins_the_race_and_gets_its_exact_response() { + let mut bridge = bridge(4); + let queued = bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + let claimed = bridge.claim_next().unwrap(); + assert!( + !queued.disposition.abandon_if_pending(), + "transport must now await the response" + ); + let response = ActionResponse::completed( + InvocationId::new(), + claimed.call.action_id.clone(), + json!({}), + Some(1), + ); + let outcome = bridge.complete(claimed, response.clone()); + assert_eq!(outcome.id, queued.id); + assert_eq!(bridge.take_outcomes().len(), 1); + assert_eq!(outcome.response, response); + } + + #[test] + fn cancelled_programs_are_skipped_at_claim_time() { + let mut bridge = bridge(4); + let token = CancellationToken::new(); + let queued = bridge.submit(call(), origin(token.clone())).unwrap(); + token.cancel(); + assert!(bridge.claim_next().is_none()); + assert!(queued.disposition.is_abandoned()); + } + + #[test] + fn turning_the_controller_off_revokes_the_queue() { + let mut bridge = bridge(4); + bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + assert_eq!(bridge.set_access(UiControllerAccess::Off), 2); + assert_eq!(bridge.queued_len(), 0); + assert_eq!( + bridge + .submit(call(), origin(CancellationToken::new())) + .err(), + Some(SubmitError::Revoked) + ); + bridge.set_access(UiControllerAccess::ReadOnly); + assert!( + bridge + .submit(call(), origin(CancellationToken::new())) + .is_ok() + ); + } + + #[test] + fn terminal_shutdown_waits_for_delivery() { + let barrier = DeliveryBarrier::default(); + assert_eq!( + barrier.commit_terminal(), + Err(DeliveryError::TerminalBeforeDelivery) + ); + assert!(barrier.mark_delivered()); + assert!(!barrier.mark_failed(), "delivery settles exactly once"); + barrier.commit_terminal().unwrap(); + assert!(barrier.is_terminal_committed()); + let terminal = ActionResponse::accepted_terminal( + InvocationId::new(), + ActionId::parse("app.quit").unwrap(), + json!({}), + ); + assert!(is_terminal_frame(&terminal)); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/discovery.rs b/apps/maple-agent/crates/maple-harness/src/discovery.rs new file mode 100644 index 000000000..0b3766873 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/discovery.rs @@ -0,0 +1,401 @@ +//! Discovery surfaces: the root command palette and which-key. +//! +//! Both existed in the prototype as GPUI overlays (`app/src/harness/ +//! palette.rs`, `which_key.rs`). Their value was never the rendering; it was +//! that they were *derived* from the same registry and resolved keymap that +//! drive dispatch, so a user could always find the action behind a button, +//! the key behind an action, and the reason an action was unavailable. This +//! module keeps those derivations as plain data structures: +//! +//! - [`PaletteIndex`] searches labels, IDs, descriptions, categories, and +//! current bindings, and ranks matches deterministically. Activation is +//! the caller's job: it must go through +//! [`ActionHost::invoke_palette`](crate::ActionHost::invoke_palette) so the +//! gesture carries `DirectUser`/`CommandPalette` provenance. +//! - [`WhichKeyTrie`] compiles effective bindings into a prefix trie so a +//! pending multi-stroke sequence can show its continuations, filtered to +//! the contexts that are currently active. + +use std::collections::BTreeMap; + +use crate::{ + ActionDescriptor, ActionId, ActionRegistry, Availability, ContextExpression, KeymapBinding, + ResolvedBinding, ResolvedBindingState, ResolvedKeymap, +}; + +/// One searchable palette entry. +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteEntry { + pub action_id: ActionId, + pub label: String, + pub description: String, + pub category: String, + /// Effective key sequences bound to this action in the active profile, + /// with the context each applies in. + pub bindings: Vec<(String, String)>, + /// Ex-style alias reachable from the Vim colon layer (`:settings`). + pub alias: Option, +} + +/// A ranked palette hit. +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteHit<'a> { + pub entry: &'a PaletteEntry, + pub score: u32, + pub availability: Availability, +} + +/// Searchable index over the registry and the active resolved keymap. +#[derive(Clone, Debug, Default)] +pub struct PaletteIndex { + entries: Vec, +} + +impl PaletteIndex { + /// Builds the index. `aliases` maps colon-layer words to action IDs + /// (`"settings" -> settings.open`). + pub fn build( + registry: &ActionRegistry, + keymap: Option<&ResolvedKeymap>, + aliases: &BTreeMap, + ) -> Self { + let mut by_action: BTreeMap<&ActionId, Vec<(String, String)>> = BTreeMap::new(); + if let Some(keymap) = keymap { + for binding in &keymap.bindings { + if binding.state != ResolvedBindingState::Effective { + continue; + } + if let Some(action_id) = binding.binding.action_id() { + by_action + .entry(action_id) + .or_default() + .push((binding.sequence.to_string(), binding.context.to_string())); + } + } + } + let alias_for: BTreeMap<&ActionId, &String> = + aliases.iter().map(|(a, id)| (id, a)).collect(); + let entries = registry + .iter() + .map(|(id, descriptor)| PaletteEntry { + action_id: id.clone(), + label: descriptor.label.clone(), + description: descriptor.description.clone(), + category: descriptor.category.clone(), + bindings: by_action.get(id).cloned().unwrap_or_default(), + alias: alias_for.get(id).map(|a| (*a).clone()), + }) + .collect(); + Self { entries } + } + + pub fn entries(&self) -> &[PaletteEntry] { + &self.entries + } + + /// Searches the index. `availability` lets the caller supply live + /// availability per action so disabled entries stay visible with their + /// reason instead of disappearing. + pub fn search<'a>( + &'a self, + query: &str, + availability: impl Fn(&ActionId) -> Availability, + ) -> Vec> { + let query = query.trim().to_ascii_lowercase(); + let mut hits: Vec> = self + .entries + .iter() + .filter_map(|entry| { + let score = score(entry, &query)?; + Some(PaletteHit { + entry, + score, + availability: availability(&entry.action_id), + }) + }) + .collect(); + // Available before disabled, then score, then stable ID order. + hits.sort_by(|a, b| { + b.availability + .is_available() + .cmp(&a.availability.is_available()) + .then(b.score.cmp(&a.score)) + .then(a.entry.action_id.cmp(&b.entry.action_id)) + }); + hits + } +} + +/// Deterministic ranking: exact alias or ID beats a label prefix, which beats +/// a word-prefix match, which beats a substring anywhere. +fn score(entry: &PaletteEntry, query: &str) -> Option { + if query.is_empty() { + return Some(1); + } + let id = entry.action_id.as_str(); + let label = entry.label.to_ascii_lowercase(); + if entry.alias.as_deref() == Some(query) || id == query { + return Some(1000); + } + if label.starts_with(query) || id.starts_with(query) { + return Some(800); + } + if label.split_whitespace().any(|w| w.starts_with(query)) + || id.split(['.', '_']).any(|w| w.starts_with(query)) + { + return Some(600); + } + if label.contains(query) || id.contains(query) { + return Some(400); + } + if entry.category.to_ascii_lowercase().contains(query) { + return Some(300); + } + if entry + .bindings + .iter() + .any(|(sequence, _)| sequence.contains(query)) + { + return Some(250); + } + if entry.description.to_ascii_lowercase().contains(query) { + return Some(200); + } + None +} + +/// A which-key continuation: the next stroke and what it leads to. +#[derive(Clone, Debug, PartialEq)] +pub struct Continuation { + pub stroke: String, + /// `Some` when this stroke completes a binding. + pub action: Option<(ActionId, String)>, + /// Number of longer bindings that still start with this stroke. + pub deeper: usize, + pub context: String, +} + +#[derive(Debug, Default)] +struct Node { + children: BTreeMap, + /// Complete bindings ending exactly here, keyed by context. + leaves: Vec<(ContextExpression, ActionId)>, +} + +/// Prefix trie over effective bindings, for pending-chord help. +#[derive(Debug, Default)] +pub struct WhichKeyTrie { + root: Node, +} + +impl WhichKeyTrie { + pub fn build(keymap: &ResolvedKeymap, registry: &ActionRegistry) -> Self { + let mut trie = Self::default(); + for binding in &keymap.bindings { + trie.insert(binding, registry); + } + trie + } + + fn insert(&mut self, binding: &ResolvedBinding, registry: &ActionRegistry) { + if binding.state != ResolvedBindingState::Effective { + return; + } + let KeymapBinding::Action { action_id, .. } = &binding.binding else { + return; + }; + if !registry.contains(action_id) { + return; + } + let mut node = &mut self.root; + for stroke in binding.sequence.strokes() { + node = node.children.entry(stroke.to_owned()).or_default(); + } + node.leaves + .push((binding.context.clone(), action_id.clone())); + } + + /// Continuations after `pending` strokes, restricted to bindings whose + /// context expression is one of `active_contexts` (already evaluated by + /// the caller against the focus/context stack). Labels come from the + /// registry so which-key shows human copy, not just IDs. + pub fn continuations( + &self, + pending: &[&str], + active_contexts: &[ContextExpression], + registry: &ActionRegistry, + ) -> Vec { + let mut node = &self.root; + for stroke in pending { + match node.children.get(*stroke) { + Some(next) => node = next, + None => return Vec::new(), + } + } + let is_active = |context: &ContextExpression| active_contexts.contains(context); + let mut out = Vec::new(); + for (stroke, child) in &node.children { + let leaf = child.leaves.iter().find(|(context, _)| is_active(context)); + let deeper = count_active_leaves(child, &is_active) - usize::from(leaf.is_some()); + if leaf.is_none() && deeper == 0 { + continue; + } + out.push(Continuation { + stroke: stroke.clone(), + action: leaf.map(|(_, id)| (id.clone(), label_for(registry, id))), + deeper, + context: leaf + .map(|(context, _)| context.to_string()) + .unwrap_or_default(), + }); + } + out + } +} + +fn count_active_leaves(node: &Node, is_active: &dyn Fn(&ContextExpression) -> bool) -> usize { + node.leaves.iter().filter(|(c, _)| is_active(c)).count() + + node + .children + .values() + .map(|child| count_active_leaves(child, is_active)) + .sum::() +} + +fn label_for(registry: &ActionRegistry, id: &ActionId) -> String { + registry + .descriptor(id) + .map(|d: &ActionDescriptor| d.label.clone()) + .unwrap_or_else(|| id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + DisabledReasonCode, KeymapDocument, ShortcutProfile, catalog::initial_registry, + resolve_keymap, + }; + + fn template(registry: &ActionRegistry, profile: ShortcutProfile) -> KeymapDocument { + // Build a template document from the catalog's default bindings. + let mut by_context: BTreeMap> = + BTreeMap::new(); + for (id, descriptor) in registry.iter() { + for binding in &descriptor.default_bindings { + if binding.profile == profile { + by_context + .entry(binding.context.clone()) + .or_default() + .insert( + binding.sequence.clone(), + serde_json::Value::String(id.to_string()), + ); + } + } + } + let entries: Vec = by_context + .into_iter() + .map( + |(context, bindings)| serde_json::json!({"context": context, "bindings": bindings}), + ) + .collect(); + KeymapDocument::parse(&serde_json::Value::Array(entries).to_string()).unwrap() + } + + fn keymap(profile: ShortcutProfile) -> (ActionRegistry, ResolvedKeymap) { + let registry = initial_registry().unwrap(); + let standard = template(®istry, ShortcutProfile::Standard); + let vim = template(®istry, ShortcutProfile::Vim); + let resolved = resolve_keymap( + profile, + &standard, + &vim, + &KeymapDocument::default(), + ®istry, + ) + .unwrap(); + (registry, resolved) + } + + #[test] + fn palette_ranks_alias_then_prefix_and_keeps_disabled_visible() { + let (registry, resolved) = keymap(ShortcutProfile::Standard); + let mut aliases = BTreeMap::new(); + aliases.insert( + "settings".to_owned(), + ActionId::parse("settings.open").unwrap(), + ); + let index = PaletteIndex::build(®istry, Some(&resolved), &aliases); + let hits = index.search("settings", |_| Availability::Available); + assert_eq!(hits[0].entry.action_id.as_str(), "settings.open"); + assert_eq!(hits[0].score, 1000); + assert!( + hits.iter() + .any(|h| h.entry.action_id.as_str() == "settings.set_theme") + ); + + let disabled = DisabledReasonCode::parse("no_active_task").unwrap(); + let hits = index.search("task", |id| { + if id.as_str() == "task.rename" { + Availability::disabled(disabled.clone(), "No task is selected") + } else { + Availability::Available + } + }); + let rename = hits + .iter() + .position(|h| h.entry.action_id.as_str() == "task.rename") + .unwrap(); + assert!(!hits[rename].availability.is_available()); + assert!(hits[..rename].iter().all(|h| h.availability.is_available())); + } + + #[test] + fn palette_shows_current_bindings_from_the_resolved_keymap() { + let (registry, resolved) = keymap(ShortcutProfile::Standard); + let index = PaletteIndex::build(®istry, Some(&resolved), &BTreeMap::new()); + let quit = index + .entries() + .iter() + .find(|e| e.action_id.as_str() == "app.quit") + .unwrap(); + assert_eq!( + quit.bindings, + vec![("cmd-q".to_owned(), "MapleApp".to_owned())] + ); + let by_key = index.search("cmd-q", |_| Availability::Available); + assert_eq!(by_key[0].entry.action_id.as_str(), "app.quit"); + } + + #[test] + fn which_key_lists_space_leader_continuations_in_vim() { + let (registry, resolved) = keymap(ShortcutProfile::Vim); + let trie = WhichKeyTrie::build(&resolved, ®istry); + let active = vec![ + ContextExpression::parse("MapleApp && app_vim_mode == normal").unwrap(), + ContextExpression::parse("Chat && app_vim_mode == normal").unwrap(), + ]; + let next = trie.continuations(&["space"], &active, ®istry); + let strokes: Vec<&str> = next.iter().map(|c| c.stroke.as_str()).collect(); + assert_eq!(strokes, vec!["n", "p", "s"]); + let settings = next.iter().find(|c| c.stroke == "s").unwrap(); + assert_eq!(settings.action.as_ref().unwrap().1, "Open Settings"); + assert_eq!(settings.deeper, 0); + // Sidebar-only bindings do not leak into a context that is not active. + let sidebar_only = trie.continuations(&[], &active, ®istry); + assert!(sidebar_only.iter().all(|c| c.stroke != "j")); + } + + #[test] + fn which_key_reports_prefixes_and_dead_ends() { + let (registry, resolved) = keymap(ShortcutProfile::Standard); + let trie = WhichKeyTrie::build(&resolved, ®istry); + let active = vec![ContextExpression::parse("MapleApp").unwrap()]; + let root = trie.continuations(&[], &active, ®istry); + let cmd_k = root.iter().find(|c| c.stroke == "cmd-k").unwrap(); + assert!(cmd_k.action.is_none()); + assert_eq!(cmd_k.deeper, 1); + assert!(trie.continuations(&["nope"], &active, ®istry).is_empty()); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/host.rs b/apps/maple-agent/crates/maple-harness/src/host.rs new file mode 100644 index 000000000..65867d7ff --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/host.rs @@ -0,0 +1,1006 @@ +//! The single action host, re-expressed without a window. +//! +//! In the original `maple-gpui` Developer Preview this lived in +//! `app/src/harness/host.rs` (about 2,400 lines) and was welded to GPUI: +//! typed GPUI actions, pointer callbacks, the command palette, application +//! Vim, and the Code Mode controller bridge all funnelled into one executor. +//! This module is a from-scratch, GPUI-free re-statement of that executor so +//! the *ideas* compile and are tested even though nothing renders: +//! +//! 1. **One path.** Every origin becomes an [`ActionCall`] plus a +//! host-minted [`TrustedInvocation`], and every call goes through +//! [`ActionHost::dispatch`]. There is no second business-logic entry point. +//! 2. **Descriptor first.** Unknown IDs, schema-invalid arguments, and +//! undeclared preconditions fail before any authority check. +//! 3. **Authority is host state.** Wire callers never carry authority. The +//! host attaches the *current* [`ProgrammabilityAuthority`] and policy +//! epoch; a lease minted under an older epoch fails closed. +//! 4. **Availability, then compare-and-set.** Executors report availability +//! against live state; call preconditions are checked against the +//! [`RevisionTracker`] so stale model programs cannot act on old state. +//! 5. **Generic actions are never authority shortcuts.** An executor may +//! answer [`ExecutorOutcome::Delegate`] with the concrete call it resolved +//! (for example `ui.activate_selected` resolving to `account.sign_out`); +//! the host re-enters the full pipeline for the concrete descriptor with a +//! derived invocation, so a Human Only target stays Human Only. +//! 6. **Audit is not optional.** Denials, starts, acceptances, and terminal +//! outcomes all land in the bounded, redacting [`ActionAuditRing`]. +//! 7. **Terminal host actions.** An action such as `app.quit` commits its +//! audit record and returns `AcceptedTerminal`, after which the host stops +//! admitting calls; orderly shutdown belongs to the embedding process. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +use crate::{ + ActionAuditDraft, ActionAuditRecord, ActionAuditRing, ActionBudget, ActionCall, + ActionDescriptor, ActionError, ActionErrorCode, ActionId, ActionRegistry, ActionResponse, + ActionStatus, AuditOutcome, Availability, DEFAULT_ACTION_AUDIT_CAPACITY, DirectUserIngress, + DirectUserProvenance, ExecutionId, InvocationActor, InvocationId, InvocationTransport, + PolicyDecision, ProgramId, ProgrammabilityAuthority, PythonCodeMode, RevisionChange, + RevisionDomain, RevisionTracker, RunId, StaleRevision, TaskIdentity, TrustedInvocation, + TrustedInvocationError, UiControllerAccess, +}; + +/// Default per-invocation admission budget for a direct-user gesture: one +/// gesture, one action, plus room for a single generic-to-concrete delegation. +pub const DIRECT_USER_ACTION_BUDGET: u32 = 2; + +/// Wall-clock source so tests can pin timestamps and durations. +pub trait HostClock: Send { + fn now_ms(&self) -> i64; +} + +/// System clock used by real hosts. +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemClock; + +impl HostClock for SystemClock { + fn now_ms(&self) -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} + +/// Host-owned mutable state that executors may read and bump. +/// +/// Executors receive `&mut HostState` rather than the whole host so they can +/// never re-enter dispatch or touch the audit ring directly. +#[derive(Debug)] +pub struct HostState { + revisions: RevisionTracker, + authority: ProgrammabilityAuthority, + policy_epoch: u64, + shutdown_committed: bool, + /// Free-form application state for executors. The real host owned typed + /// GPUI models here; a JSON object is enough to preserve the contract. + pub model: Value, +} + +impl Default for HostState { + fn default() -> Self { + Self { + revisions: RevisionTracker::new(), + authority: ProgrammabilityAuthority::OFF, + policy_epoch: 0, + shutdown_committed: false, + model: json!({}), + } + } +} + +impl HostState { + pub fn revisions(&self) -> &RevisionTracker { + &self.revisions + } + + pub fn authority(&self) -> ProgrammabilityAuthority { + self.authority + } + + pub fn policy_epoch(&self) -> u64 { + self.policy_epoch + } + + pub fn is_shutdown_committed(&self) -> bool { + self.shutdown_committed + } + + /// Records a state change in one revision domain. Returns the new global + /// and domain revisions so the executor can report `state_revision`. + pub fn bump(&mut self, domain: RevisionDomain) -> RevisionChange { + self.revisions.bump(domain) + } +} + +/// What a policy change revoked. Section 10.4 of the design: policy is +/// revisioned, and every downgrade cancels the work it no longer permits. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PolicyRevocation { + pub new_policy_epoch: u64, + /// Python Code Mode turned off: kernels stop, executions cancel. + pub stop_kernels: bool, + /// Controller lost Full Access: pending mutation leases are void. + pub revoke_mutation_leases: bool, + /// Controller turned off: all SDK requests and event waits are void. + pub revoke_all_controller_requests: bool, +} + +/// Result of one executor run. +#[derive(Debug)] +pub enum ExecutorOutcome { + /// Synchronous completion. `bumped` is the revision change the executor + /// applied, if any, and is echoed back as `state_revision`. + Completed { + result: Value, + bumped: Option, + }, + /// The executor handed the work to a retained asynchronous operation. + /// The host records `Accepted`; the embedding process later calls + /// [`ActionHost::finish_accepted`]. + Accepted { + result: Value, + }, + /// A generic action resolved to a concrete one. The host re-dispatches + /// the concrete call under a derived invocation (final-action + /// reauthorization). + Delegate(ActionCall), + Failed(ActionError), +} + +/// The application-owned implementation of one semantic action. +pub trait Executor: Send { + /// Live availability for this call. Called on every dispatch after policy. + fn availability(&self, call: &ActionCall, state: &HostState) -> Availability { + let _ = (call, state); + Availability::Available + } + + fn execute( + &mut self, + call: &ActionCall, + invocation: &TrustedInvocation, + state: &mut HostState, + ) -> ExecutorOutcome; +} + +/// Provenance attached by the Code Mode kernel to a controller request. +/// +/// This mirrors the shape the Python SDK bridge supplied; none of these +/// fields grant authority. Authority is read from the host at dispatch time. +#[derive(Clone, Debug)] +pub struct ControllerOrigin { + pub actor: InvocationActor, + pub source_task: Option, + pub program_id: ProgramId, + pub program_started_unix_ms: u64, + pub execution_id: ExecutionId, + pub model_run_id: Option, + pub kernel_generation: u64, + /// Epoch the kernel observed when the program was admitted. If the host + /// epoch moved on, the call fails closed with `PolicyDenied`. + pub observed_policy_epoch: u64, + pub cancellation: CancellationToken, + pub budget: ActionBudget, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum HostBuildError { + #[error("executor registered for unknown action {0}")] + ExecutorForUnknownAction(ActionId), + #[error("action {0} has no executor")] + MissingExecutor(ActionId), + #[error("duplicate executor for action {0}")] + DuplicateExecutor(ActionId), +} + +/// Builder that enforces "every descriptor has exactly one executor". +pub struct ActionHostBuilder { + registry: ActionRegistry, + executors: BTreeMap>, + errors: Vec, + audit_capacity: usize, + clock: Box, +} + +impl ActionHostBuilder { + pub fn new(registry: ActionRegistry) -> Self { + Self { + registry, + executors: BTreeMap::new(), + errors: Vec::new(), + audit_capacity: DEFAULT_ACTION_AUDIT_CAPACITY, + clock: Box::new(SystemClock), + } + } + + pub fn executor(mut self, action_id: ActionId, executor: impl Executor + 'static) -> Self { + if !self.registry.contains(&action_id) { + self.errors + .push(HostBuildError::ExecutorForUnknownAction(action_id)); + return self; + } + if self.executors.contains_key(&action_id) { + self.errors + .push(HostBuildError::DuplicateExecutor(action_id)); + return self; + } + self.executors.insert(action_id, Box::new(executor)); + self + } + + pub fn audit_capacity(mut self, capacity: usize) -> Self { + self.audit_capacity = capacity; + self + } + + pub fn clock(mut self, clock: impl HostClock + 'static) -> Self { + self.clock = Box::new(clock); + self + } + + pub fn build(mut self) -> Result> { + for (id, _) in self.registry.iter() { + if !self.executors.contains_key(id) { + self.errors + .push(HostBuildError::MissingExecutor(id.clone())); + } + } + if !self.errors.is_empty() { + return Err(self.errors); + } + Ok(ActionHost { + registry: self.registry, + executors: self.executors, + ingress: DirectUserIngress::new_window(), + audit: ActionAuditRing::new(self.audit_capacity.max(1)).expect("capacity is non-zero"), + state: HostState::default(), + clock: self.clock, + }) + } +} + +/// The one action executor for an application instance. +pub struct ActionHost { + registry: ActionRegistry, + executors: BTreeMap>, + ingress: DirectUserIngress, + audit: ActionAuditRing, + state: HostState, + clock: Box, +} + +impl ActionHost { + pub fn registry(&self) -> &ActionRegistry { + &self.registry + } + + pub fn state(&self) -> &HostState { + &self.state + } + + pub fn state_mut(&mut self) -> &mut HostState { + &mut self.state + } + + pub fn audit(&self) -> &ActionAuditRing { + &self.audit + } + + /// The window-scoped direct-user ingress. Only physical input handlers + /// may hold this; it is how pointer/keyboard/palette gestures prove they + /// are human. + pub fn direct_user_ingress(&self) -> &DirectUserIngress { + &self.ingress + } + + /// Changes session authority. Every change bumps the policy epoch so any + /// invocation admitted earlier fails closed on its next action. + pub fn set_authority(&mut self, authority: ProgrammabilityAuthority) -> PolicyRevocation { + let previous = self.state.authority; + let next = authority.with_code_mode(authority.code_mode); + self.state.authority = next; + self.state.policy_epoch = self.state.policy_epoch.saturating_add(1); + PolicyRevocation { + new_policy_epoch: self.state.policy_epoch, + stop_kernels: previous.code_mode == PythonCodeMode::DeveloperPreview + && next.code_mode != PythonCodeMode::DeveloperPreview, + revoke_mutation_leases: previous.controller == UiControllerAccess::FullAccess + && next.controller != UiControllerAccess::FullAccess, + revoke_all_controller_requests: previous.controller != UiControllerAccess::Off + && next.controller == UiControllerAccess::Off, + } + } + + /// Mints a direct-user invocation from opaque physical provenance. This is + /// the *only* way to obtain `InvocationActor::DirectUser`. + pub fn direct_user_invocation( + &self, + provenance: DirectUserProvenance, + source_task: Option, + ) -> Result { + self.ingress.trusted_invocation( + InvocationId::new(), + provenance, + source_task, + self.state.authority.controller, + self.state.policy_epoch, + CancellationToken::new(), + ActionBudget::new(DIRECT_USER_ACTION_BUDGET).expect("non-zero budget"), + ) + } + + /// Mints a controller (model / user code) invocation. The controller + /// access recorded on the lease is the host's *current* value, never one + /// supplied by the caller; the caller's observed epoch is preserved so a + /// stale program fails closed at admission. + pub fn controller_invocation( + &self, + origin: &ControllerOrigin, + ) -> Result { + let invocation = TrustedInvocation::new( + InvocationId::new(), + origin.source_task.clone(), + Some(origin.program_id), + origin.model_run_id.clone(), + Some(origin.execution_id), + Some(origin.kernel_generation), + origin.actor, + InvocationTransport::Python, + self.state.authority.controller, + origin.observed_policy_epoch, + origin.cancellation.clone(), + origin.budget.clone(), + )?; + Ok(invocation.with_program_started_unix_ms(origin.program_started_unix_ms)) + } + + /// Convenience: dispatch a pointer gesture (button, menu item). + pub fn invoke_pointer(&mut self, call: ActionCall) -> ActionResponse { + let provenance = self.ingress.pointer(); + match self.direct_user_invocation(provenance, None) { + Ok(invocation) => self.dispatch(call, invocation), + Err(error) => self.wiring_failure(call, error), + } + } + + /// Convenience: dispatch a command-palette activation. + pub fn invoke_palette(&mut self, call: ActionCall) -> ActionResponse { + let provenance = self.ingress.command_palette(); + match self.direct_user_invocation(provenance, None) { + Ok(invocation) => self.dispatch(call, invocation), + Err(error) => self.wiring_failure(call, error), + } + } + + /// Convenience: dispatch a Code Mode / model controller request. + pub fn invoke_controller( + &mut self, + call: ActionCall, + origin: &ControllerOrigin, + ) -> ActionResponse { + match self.controller_invocation(origin) { + Ok(invocation) => self.dispatch(call, invocation), + Err(error) => self.wiring_failure(call, error), + } + } + + fn wiring_failure(&self, call: ActionCall, error: TrustedInvocationError) -> ActionResponse { + ActionResponse::failed( + InvocationId::new(), + call.action_id, + ActionError::new(ActionErrorCode::PolicyDenied, error.to_string()) + .expect("policy_denied needs no reason code"), + ) + .expect("failed response is well-formed") + } + + /// The one execution path. + pub fn dispatch(&mut self, call: ActionCall, invocation: TrustedInvocation) -> ActionResponse { + let invocation_id = invocation.invocation_id(); + let action_id = call.action_id.clone(); + + if self.state.shutdown_committed { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::Failed, + "host shutdown committed; no new actions admitted", + ); + } + + // 1. Descriptor and structural validation come before authority. + let Some(descriptor) = self.registry.descriptor(&action_id).cloned() else { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::UnknownAction, + "unknown action", + ); + }; + if let Err(error) = descriptor.validate_arguments(&call.arguments) { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::InvalidArguments, + error.to_string(), + ); + } + if let Err(error) = descriptor.validate_precondition(&call) { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::InvalidArguments, + error.to_string(), + ); + } + + // 2. Lease freshness and budget. A stale epoch or cancelled program + // never reaches an executor. + if let Err(error) = invocation.admit_call(self.state.policy_epoch) { + let code = if invocation.cancellation().is_cancelled() { + ActionErrorCode::Cancelled + } else { + ActionErrorCode::PolicyDenied + }; + return self.fail(invocation_id, action_id, code, error.to_string()); + } + + // 3. Authority matrix (actor x transport x controller mode x effect). + let decision = invocation.authorize(&descriptor); + let started_ms = self.clock.now_ms(); + if let PolicyDecision::Denied { message, .. } = &decision { + self.record_denied( + &descriptor, + &call, + &invocation, + decision.clone(), + started_ms, + ); + return self.fail( + invocation_id, + action_id, + ActionErrorCode::PolicyDenied, + message.clone(), + ); + } + + // 4. Live availability, then compare-and-set precondition. + let executor = self + .executors + .get(&action_id) + .expect("builder guarantees an executor per descriptor"); + if let Availability::Disabled { code, message } = executor.availability(&call, &self.state) + { + let error = ActionError::unavailable(code, message); + return ActionResponse::failed(invocation_id, action_id, error) + .expect("unavailable response is well-formed"); + } + if let Some(precondition) = &call.precondition + && let Err(StaleRevision { + domain, + expected, + actual, + }) = self + .state + .revisions + .check(&precondition.domain, precondition.target_revision) + { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::StaleTarget, + format!( + "{} changed from revision {expected} to {actual}", + domain_label(&domain) + ), + ); + } + + // 5. Audit "started", then run the executor. + if let Err(error) = self.audit.record(ActionAuditDraft { + invocation_id, + program_id: invocation.program_id(), + model_run_id: invocation.model_run_id(), + timestamp_ms: started_ms, + actor: invocation.actor().into(), + transport: invocation.transport(), + controller_access: invocation.controller_access(), + policy_epoch: invocation.policy_epoch(), + action_id: action_id.clone(), + target: call.target.clone(), + arguments: descriptor.audit.redact(&call.arguments), + effect: descriptor.effect, + recoverability: descriptor.recoverability, + decision, + outcome: AuditOutcome::Started, + error_code: None, + }) { + // Audit backpressure: the ring is full of in-flight records. Fail + // the call rather than lose the trail. + return self.fail( + invocation_id, + action_id, + ActionErrorCode::Failed, + error.to_string(), + ); + } + + let executor = self + .executors + .get_mut(&action_id) + .expect("builder guarantees an executor per descriptor"); + let outcome = executor.execute(&call, &invocation, &mut self.state); + let duration_ms = (self.clock.now_ms() - started_ms).max(0) as u64; + + match outcome { + ExecutorOutcome::Completed { result, bumped } => { + if descriptor.terminal_host_action { + self.state.shutdown_committed = true; + let _ = self.audit.finish( + invocation_id, + duration_ms, + AuditOutcome::AcceptedTerminal, + None, + ); + return ActionResponse::accepted_terminal(invocation_id, action_id, result); + } + let outcome = if descriptor.recoverability == crate::Recoverability::Irreversible + && self.cancel_was_requested(invocation_id) + { + AuditOutcome::CompletedAfterCancelRequest + } else { + AuditOutcome::Completed + }; + let _ = self.audit.finish(invocation_id, duration_ms, outcome, None); + ActionResponse::completed( + invocation_id, + action_id, + result, + Some( + bumped.map_or(self.state.revisions.state_revision(), |c| c.state_revision), + ), + ) + } + ExecutorOutcome::Accepted { result } => { + let _ = self.audit.accept(invocation_id); + ActionResponse::accepted(invocation_id, action_id, result) + } + ExecutorOutcome::Delegate(concrete) => { + // Final-action reauthorization: close the generic record and + // run the concrete call through the whole pipeline again. + let _ = + self.audit + .finish(invocation_id, duration_ms, AuditOutcome::Completed, None); + let derived = invocation.derived(InvocationId::new()); + let mut response = self.dispatch(concrete, derived); + // Report under the caller's invocation so the SDK correlates + // the answer with the request it made. + response.invocation_id = invocation_id; + response + } + ExecutorOutcome::Failed(error) => { + let outcome = if error.code == ActionErrorCode::Cancelled { + AuditOutcome::Cancelled + } else { + AuditOutcome::Failed + }; + let _ = self + .audit + .finish(invocation_id, duration_ms, outcome, Some(error.code)); + ActionResponse::failed(invocation_id, action_id, error) + .expect("executor errors carry the required metadata") + } + } + } + + /// Completes an operation the executor previously returned as `Accepted`. + pub fn finish_accepted( + &mut self, + invocation_id: InvocationId, + outcome: AuditOutcome, + error_code: Option, + ) -> Option { + let started = self + .audit + .records() + .rev() + .find(|record| record.invocation_id == invocation_id) + .map(|record| record.timestamp_ms)?; + let duration_ms = (self.clock.now_ms() - started).max(0) as u64; + self.audit + .finish(invocation_id, duration_ms, outcome, error_code) + .ok() + } + + /// Records a user or policy cancel request against an in-flight invocation. + pub fn request_cancel(&mut self, invocation_id: InvocationId) -> Option { + let now = self.clock.now_ms(); + self.audit.mark_cancel_requested(invocation_id, now).ok() + } + + fn cancel_was_requested(&self, invocation_id: InvocationId) -> bool { + self.audit + .records() + .rev() + .find(|record| record.invocation_id == invocation_id) + .is_some_and(|record| record.cancel_requested_at_ms.is_some()) + } + + fn record_denied( + &mut self, + descriptor: &ActionDescriptor, + call: &ActionCall, + invocation: &TrustedInvocation, + decision: PolicyDecision, + timestamp_ms: i64, + ) { + let _ = self.audit.record(ActionAuditDraft { + invocation_id: invocation.invocation_id(), + program_id: invocation.program_id(), + model_run_id: invocation.model_run_id(), + timestamp_ms, + actor: invocation.actor().into(), + transport: invocation.transport(), + controller_access: invocation.controller_access(), + policy_epoch: invocation.policy_epoch(), + action_id: descriptor.id.clone(), + target: call.target.clone(), + arguments: descriptor.audit.redact(&call.arguments), + effect: descriptor.effect, + recoverability: descriptor.recoverability, + decision, + outcome: AuditOutcome::Denied, + error_code: Some(ActionErrorCode::PolicyDenied), + }); + } + + fn fail( + &self, + invocation_id: InvocationId, + action_id: ActionId, + code: ActionErrorCode, + message: impl Into, + ) -> ActionResponse { + let error = ActionError::new(code, message) + .expect("non-unavailable error codes need no reason code"); + ActionResponse::failed(invocation_id, action_id, error) + .expect("failed response is well-formed") + } +} + +fn domain_label(domain: &RevisionDomain) -> String { + serde_json::to_value(domain) + .ok() + .and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_owned)) + .unwrap_or_else(|| "revision domain".to_owned()) +} + +impl ActionResponse { + /// True when the host admitted the call and the executor ran to a + /// terminal or accepted state. + pub fn is_success(&self) -> bool { + matches!( + self.status, + ActionStatus::Accepted | ActionStatus::AcceptedTerminal | ActionStatus::Completed + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ActionEffect, AuditSpec, InvocationPolicy, Recoverability, RegistryBuilder, SCHEMA_VERSION, + }; + + struct FixedClock; + impl HostClock for FixedClock { + fn now_ms(&self) -> i64 { + 1_700_000_000_000 + } + } + + fn descriptor(id: &str, effect: ActionEffect, policy: InvocationPolicy) -> ActionDescriptor { + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(id).unwrap(), + label: id.to_owned(), + description: format!("test action {id}"), + category: "test".into(), + argument_schema: json!({"type": "object", "additionalProperties": false}), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + effect, + invocation_policy: policy, + recoverability: Recoverability::Reversible, + audit: AuditSpec::redact_all(), + default_bindings: Vec::new(), + precondition_domain: Some(crate::PreconditionDomainSelector::Global), + bindable: false, + terminal_host_action: false, + } + } + + /// Bumps the global revision and records that it ran. + struct Counter; + impl Executor for Counter { + fn execute( + &mut self, + _: &ActionCall, + _: &TrustedInvocation, + state: &mut HostState, + ) -> ExecutorOutcome { + let bumped = state.bump(RevisionDomain::Global); + let runs = state.model["runs"].as_u64().unwrap_or(0) + 1; + state.model["runs"] = json!(runs); + ExecutorOutcome::Completed { + result: json!({"runs": runs}), + bumped: Some(bumped), + } + } + } + + /// `ui.activate_selected`: resolves to whatever is selected. + struct ActivateSelected; + impl Executor for ActivateSelected { + fn execute( + &mut self, + _: &ActionCall, + _: &TrustedInvocation, + state: &mut HostState, + ) -> ExecutorOutcome { + let selected = state.model["selected"] + .as_str() + .unwrap_or("chat.toggle_sidebar"); + ExecutorOutcome::Delegate(ActionCall::new(ActionId::parse(selected).unwrap())) + } + } + + struct Quit; + impl Executor for Quit { + fn execute( + &mut self, + _: &ActionCall, + _: &TrustedInvocation, + _: &mut HostState, + ) -> ExecutorOutcome { + ExecutorOutcome::Completed { + result: json!({}), + bumped: None, + } + } + } + + fn host() -> ActionHost { + let mut registry = RegistryBuilder::new(); + registry.register(descriptor( + "chat.toggle_sidebar", + ActionEffect::MutateMaple, + InvocationPolicy::ControllerCallable, + )); + registry.register(descriptor( + "task.list", + ActionEffect::Observe, + InvocationPolicy::ControllerCallable, + )); + registry.register(descriptor( + "account.sign_out", + ActionEffect::ExternalEffect, + InvocationPolicy::HumanOnly, + )); + registry.register(descriptor( + "ui.activate_selected", + ActionEffect::Navigate, + InvocationPolicy::ControllerCallable, + )); + let mut quit = descriptor( + "app.quit", + ActionEffect::MutateMaple, + InvocationPolicy::ControllerCallable, + ); + quit.terminal_host_action = true; + registry.register(quit); + let registry = registry.build().unwrap(); + ActionHostBuilder::new(registry) + .executor(ActionId::parse("chat.toggle_sidebar").unwrap(), Counter) + .executor(ActionId::parse("task.list").unwrap(), Counter) + .executor(ActionId::parse("account.sign_out").unwrap(), Counter) + .executor( + ActionId::parse("ui.activate_selected").unwrap(), + ActivateSelected, + ) + .executor(ActionId::parse("app.quit").unwrap(), Quit) + .clock(FixedClock) + .build() + .unwrap() + } + + fn call(id: &str) -> ActionCall { + ActionCall::new(ActionId::parse(id).unwrap()) + } + + fn model_origin(host: &ActionHost) -> ControllerOrigin { + ControllerOrigin { + actor: InvocationActor::Model, + source_task: None, + program_id: ProgramId::new(), + program_started_unix_ms: 1, + execution_id: ExecutionId::new(), + model_run_id: None, + kernel_generation: 1, + observed_policy_epoch: host.state().policy_epoch(), + cancellation: CancellationToken::new(), + budget: ActionBudget::new(16).unwrap(), + } + } + + fn full_access() -> ProgrammabilityAuthority { + ProgrammabilityAuthority::new( + PythonCodeMode::DeveloperPreview, + UiControllerAccess::FullAccess, + ) + .unwrap() + } + + #[test] + fn builder_requires_one_executor_per_descriptor() { + let mut registry = RegistryBuilder::new(); + registry.register(descriptor( + "chat.toggle_sidebar", + ActionEffect::MutateMaple, + InvocationPolicy::ControllerCallable, + )); + let errors = ActionHostBuilder::new(registry.build().unwrap()) + .build() + .err() + .unwrap(); + assert_eq!( + errors, + vec![HostBuildError::MissingExecutor( + ActionId::parse("chat.toggle_sidebar").unwrap() + )] + ); + } + + #[test] + fn direct_user_bypasses_controller_mode_but_not_availability() { + let mut host = host(); + assert_eq!(host.state().authority(), ProgrammabilityAuthority::OFF); + let response = host.invoke_pointer(call("chat.toggle_sidebar")); + assert_eq!(response.status, ActionStatus::Completed); + assert_eq!(response.state_revision, Some(1)); + assert_eq!(host.audit().len(), 1); + assert_eq!(host.audit().recent(1)[0].outcome, AuditOutcome::Completed); + } + + #[test] + fn controller_off_denies_model_and_audits_the_denial() { + let mut host = host(); + let origin = model_origin(&host); + let response = host.invoke_controller(call("task.list"), &origin); + assert_eq!(response.status, ActionStatus::Failed); + assert_eq!(response.error.unwrap().code, ActionErrorCode::PolicyDenied); + assert_eq!(host.audit().recent(1)[0].outcome, AuditOutcome::Denied); + } + + #[test] + fn read_only_allows_observe_and_denies_mutation() { + let mut host = host(); + host.set_authority( + ProgrammabilityAuthority::new( + PythonCodeMode::DeveloperPreview, + UiControllerAccess::ReadOnly, + ) + .unwrap(), + ); + let origin = model_origin(&host); + assert!( + host.invoke_controller(call("task.list"), &origin) + .is_success() + ); + let denied = host.invoke_controller(call("chat.toggle_sidebar"), &origin); + assert_eq!(denied.error.unwrap().code, ActionErrorCode::PolicyDenied); + } + + #[test] + fn human_only_stays_human_only_through_generic_delegation() { + let mut host = host(); + host.set_authority(full_access()); + host.state_mut().model["selected"] = json!("account.sign_out"); + let origin = model_origin(&host); + let response = host.invoke_controller(call("ui.activate_selected"), &origin); + assert_eq!( + response.error.as_ref().unwrap().code, + ActionErrorCode::PolicyDenied + ); + assert_eq!(response.action_id.as_str(), "account.sign_out"); + // The same gesture from a human succeeds. + let human = host.invoke_pointer(call("ui.activate_selected")); + assert_eq!(human.status, ActionStatus::Completed); + } + + #[test] + fn policy_change_bumps_epoch_and_stale_leases_fail_closed() { + let mut host = host(); + host.set_authority(full_access()); + let origin = model_origin(&host); + assert!( + host.invoke_controller(call("chat.toggle_sidebar"), &origin) + .is_success() + ); + let revocation = host.set_authority(ProgrammabilityAuthority::OFF); + assert!( + revocation.stop_kernels + && revocation.revoke_all_controller_requests + && revocation.revoke_mutation_leases + ); + let stale = host.invoke_controller(call("task.list"), &origin); + assert_eq!(stale.error.unwrap().code, ActionErrorCode::PolicyDenied); + } + + #[test] + fn stale_precondition_is_rejected_before_execution() { + let mut host = host(); + host.invoke_pointer(call("chat.toggle_sidebar")); + let stale = call("chat.toggle_sidebar").with_precondition(crate::ActionPrecondition { + domain: RevisionDomain::Global, + target_revision: 0, + }); + let response = host.invoke_pointer(stale); + assert_eq!(response.error.unwrap().code, ActionErrorCode::StaleTarget); + let fresh = call("chat.toggle_sidebar").with_precondition(crate::ActionPrecondition { + domain: RevisionDomain::Global, + target_revision: 1, + }); + assert!(host.invoke_pointer(fresh).is_success()); + } + + #[test] + fn unknown_and_invalid_calls_fail_before_authority() { + let mut host = host(); + let unknown = host.invoke_pointer(call("nope.missing")); + assert_eq!(unknown.error.unwrap().code, ActionErrorCode::UnknownAction); + let invalid = host.invoke_pointer(call("task.list").with_arguments(json!({"extra": 1}))); + assert_eq!( + invalid.error.unwrap().code, + ActionErrorCode::InvalidArguments + ); + assert!( + host.audit().is_empty(), + "structural failures are not audited actions" + ); + } + + #[test] + fn terminal_host_action_stops_admission() { + let mut host = host(); + let response = host.invoke_pointer(call("app.quit")); + assert_eq!(response.status, ActionStatus::AcceptedTerminal); + assert!(host.state().is_shutdown_committed()); + let after = host.invoke_pointer(call("task.list")); + assert_eq!(after.status, ActionStatus::Failed); + } + + #[test] + fn direct_user_budget_bounds_a_single_gesture() { + let host = host(); + let invocation = host + .direct_user_invocation(host.direct_user_ingress().pointer(), None) + .unwrap(); + assert_eq!( + invocation.action_budget().limit(), + DIRECT_USER_ACTION_BUDGET + ); + let foreign = DirectUserIngress::new_window(); + assert_eq!( + host.direct_user_invocation(foreign.pointer(), None).err(), + Some(TrustedInvocationError::WrongWindowProvenance) + ); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/keymap.rs b/apps/maple-agent/crates/maple-harness/src/keymap.rs new file mode 100644 index 000000000..c6bd11260 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/keymap.rs @@ -0,0 +1,1042 @@ +use std::{ + collections::{BTreeMap, HashMap}, + fmt, + str::FromStr, +}; + +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, de, + de::{MapAccess, Visitor}, +}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ActionId, ActionRegistry, SchemaValidationError, ShortcutProfile}; + +const MAX_KEY_SEQUENCE_BYTES: usize = 128; +const MAX_KEY_STROKES: usize = 8; +const MAX_CONTEXT_BYTES: usize = 512; + +/// A syntactically validated space-separated GPUI keystroke sequence. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct KeySequence(String); + +impl KeySequence { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_key_sequence(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn strokes(&self) -> impl Iterator { + self.0.split(' ') + } + + pub fn stroke_count(&self) -> usize { + self.strokes().count() + } + + pub fn is_prefix_of(&self, other: &Self) -> bool { + let this = self.strokes().collect::>(); + let other = other.strokes().collect::>(); + this.len() < other.len() && other.starts_with(&this) + } +} + +impl fmt::Display for KeySequence { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for KeySequence { + type Err = KeySequenceError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for KeySequence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum KeySequenceError { + #[error("key sequence is empty")] + Empty, + #[error("key sequence exceeds {MAX_KEY_SEQUENCE_BYTES} bytes")] + TooLong, + #[error("key sequence must use single ASCII spaces with no surrounding whitespace")] + NonCanonicalWhitespace, + #[error("key sequence exceeds the {MAX_KEY_STROKES}-stroke limit")] + TooManyStrokes, + #[error("keystroke {stroke:?} contains a control or non-ASCII character")] + InvalidStroke { stroke: String }, +} + +fn validate_key_sequence(value: &str) -> Result<(), KeySequenceError> { + if value.is_empty() { + return Err(KeySequenceError::Empty); + } + if value.len() > MAX_KEY_SEQUENCE_BYTES { + return Err(KeySequenceError::TooLong); + } + let strokes = value.split_whitespace().collect::>(); + if strokes.join(" ") != value { + return Err(KeySequenceError::NonCanonicalWhitespace); + } + if strokes.len() > MAX_KEY_STROKES { + return Err(KeySequenceError::TooManyStrokes); + } + for stroke in strokes { + if stroke.is_empty() + || !stroke.is_ascii() + || stroke.chars().any(|character| character.is_ascii_control()) + { + return Err(KeySequenceError::InvalidStroke { + stroke: stroke.to_owned(), + }); + } + } + Ok(()) +} + +/// Validated GPUI-compatible boolean context expression. This core validator +/// checks the portable lexical/balance contract; the app still passes it to +/// GPUI's parser before atomically installing a complete map. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ContextExpression(String); + +impl ContextExpression { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_context_expression(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn specificity(&self) -> usize { + self.0.split("&&").count() + } +} + +impl fmt::Display for ContextExpression { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ContextExpression { + type Err = ContextExpressionError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for ContextExpression { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ContextExpressionError { + #[error("key context expression is empty")] + Empty, + #[error("key context expression exceeds {MAX_CONTEXT_BYTES} bytes")] + TooLong, + #[error("key context expression has surrounding whitespace or control characters")] + NonCanonicalWhitespace, + #[error( + "key context expression contains unsupported character {character:?} at byte {byte_index}" + )] + InvalidCharacter { byte_index: usize, character: char }, + #[error("key context expression has unbalanced parentheses")] + UnbalancedParentheses, + #[error("key context expression contains an incomplete boolean/equality operator")] + IncompleteOperator, +} + +fn validate_context_expression(value: &str) -> Result<(), ContextExpressionError> { + if value.is_empty() { + return Err(ContextExpressionError::Empty); + } + if value.len() > MAX_CONTEXT_BYTES { + return Err(ContextExpressionError::TooLong); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(ContextExpressionError::NonCanonicalWhitespace); + } + let mut depth = 0usize; + for (byte_index, character) in value.char_indices() { + if !(character.is_ascii_alphanumeric() + || matches!( + character, + '_' | '.' + | '-' + | ' ' + | '(' + | ')' + | '&' + | '|' + | '!' + | '=' + | '>' + | '<' + | '~' + | '"' + | '?' + )) + { + return Err(ContextExpressionError::InvalidCharacter { + byte_index, + character, + }); + } + match character { + '(' => depth += 1, + ')' => { + depth = depth + .checked_sub(1) + .ok_or(ContextExpressionError::UnbalancedParentheses)?; + } + _ => {} + } + } + if depth != 0 { + return Err(ContextExpressionError::UnbalancedParentheses); + } + + validate_context_operators(value.as_bytes())?; + Ok(()) +} + +fn validate_context_operators(bytes: &[u8]) -> Result<(), ContextExpressionError> { + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'&' => { + if bytes.get(index + 1) != Some(&b'&') || bytes.get(index + 2) == Some(&b'&') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + b'|' => { + if bytes.get(index + 1) != Some(&b'|') || bytes.get(index + 2) == Some(&b'|') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + b'=' => { + if bytes.get(index + 1) != Some(&b'=') || bytes.get(index + 2) == Some(&b'=') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + b'!' if bytes.get(index + 1) == Some(&b'=') => { + if bytes.get(index + 2) == Some(&b'=') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + _ => index += 1, + } + } + Ok(()) +} + +/// Zed-style binding value: action ID, `[action_id, arguments]`, or `null`. +#[derive(Clone, Debug, PartialEq)] +pub enum KeymapBinding { + Disabled, + Action { + action_id: ActionId, + arguments: Value, + }, +} + +impl KeymapBinding { + pub fn action(action_id: ActionId) -> Self { + Self::Action { + action_id, + arguments: Value::Object(Default::default()), + } + } + + pub fn parameterized(action_id: ActionId, arguments: Value) -> Self { + Self::Action { + action_id, + arguments, + } + } + + pub fn action_id(&self) -> Option<&ActionId> { + match self { + Self::Disabled => None, + Self::Action { action_id, .. } => Some(action_id), + } + } + + pub fn arguments(&self) -> Option<&Value> { + match self { + Self::Disabled => None, + Self::Action { arguments, .. } => Some(arguments), + } + } +} + +impl Serialize for KeymapBinding { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Disabled => serializer.serialize_none(), + Self::Action { + action_id, + arguments, + } if arguments + .as_object() + .is_some_and(|object| object.is_empty()) => + { + action_id.serialize(serializer) + } + Self::Action { + action_id, + arguments, + } => (action_id, arguments).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for KeymapBinding { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(Self::Disabled); + }; + match value { + Value::String(action_id) => Ok(Self::action( + ActionId::parse(action_id).map_err(de::Error::custom)?, + )), + Value::Array(values) if values.len() == 2 => { + let mut values = values.into_iter(); + let action_id = values + .next() + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| de::Error::custom("binding action ID must be a string"))?; + let arguments = values.next().expect("array length was checked"); + if !arguments.is_object() { + return Err(de::Error::custom( + "parameterized binding arguments must be an object", + )); + } + Ok(Self::parameterized( + ActionId::parse(action_id).map_err(de::Error::custom)?, + arguments, + )) + } + _ => Err(de::Error::custom( + "binding must be an action ID, [action ID, argument object], or null", + )), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeymapEntry { + pub context: ContextExpression, + #[serde(deserialize_with = "deserialize_bindings")] + pub bindings: BTreeMap, +} + +fn deserialize_bindings<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct BindingsVisitor; + + impl<'de> Visitor<'de> for BindingsVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a keymap bindings object with unique key sequences") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut bindings = BTreeMap::new(); + while let Some((sequence, binding)) = map.next_entry::()? { + if bindings.insert(sequence.clone(), binding).is_some() { + return Err(de::Error::custom(format_args!( + "duplicate binding sequence {:?}", + sequence.as_str() + ))); + } + } + Ok(bindings) + } + } + + deserializer.deserialize_map(BindingsVisitor) +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct KeymapDocument(pub Vec); + +impl KeymapDocument { + pub fn parse(input: &str) -> Result { + serde_json::from_str(input).map_err(KeymapParseError::Json) + } + + pub fn validate(&self, registry: &ActionRegistry) -> Result<(), Vec> { + let mut errors = Vec::new(); + for (entry_index, entry) in self.0.iter().enumerate() { + for (sequence, binding) in &entry.bindings { + let KeymapBinding::Action { + action_id, + arguments, + } = binding + else { + continue; + }; + let Some(descriptor) = registry.descriptor(action_id) else { + errors.push(KeymapValidationError::UnknownAction { + entry_index, + sequence: sequence.clone(), + action_id: action_id.clone(), + }); + continue; + }; + if !descriptor.bindable { + errors.push(KeymapValidationError::ActionNotBindable { + entry_index, + sequence: sequence.clone(), + action_id: action_id.clone(), + }); + } + if let Err(source) = descriptor.validate_arguments(arguments) { + errors.push(KeymapValidationError::InvalidArguments { + entry_index, + sequence: sequence.clone(), + action_id: action_id.clone(), + source, + }); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +#[derive(Debug, Error)] +pub enum KeymapParseError { + #[error("invalid keymap JSON: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Clone, Debug, Error, PartialEq)] +pub enum KeymapValidationError { + #[error("entry {entry_index} sequence {sequence} references unknown action {action_id}")] + UnknownAction { + entry_index: usize, + sequence: KeySequence, + action_id: ActionId, + }, + #[error("entry {entry_index} sequence {sequence} references non-bindable action {action_id}")] + ActionNotBindable { + entry_index: usize, + sequence: KeySequence, + action_id: ActionId, + }, + #[error( + "entry {entry_index} sequence {sequence} has invalid arguments for {action_id}: {source}" + )] + InvalidArguments { + entry_index: usize, + sequence: KeySequence, + action_id: ActionId, + source: SchemaValidationError, + }, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum BindingSource { + Template(ShortcutProfile), + User, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ResolvedBindingState { + Effective, + Shadowed, + Disabled, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedBinding { + pub context: ContextExpression, + pub sequence: KeySequence, + pub binding: KeymapBinding, + pub source: BindingSource, + pub source_entry: usize, + pub state: ResolvedBindingState, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum KeymapConflictKind { + Exact, + Prefix, + Shadowed, + Possible, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeymapConflict { + pub kind: KeymapConflictKind, + pub left: usize, + pub right: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedKeymap { + pub profile: ShortcutProfile, + pub bindings: Vec, + pub conflicts: Vec, +} + +/// Resolves exactly one selected first-party template, then user overrides. +pub fn resolve_keymap( + profile: ShortcutProfile, + standard: &KeymapDocument, + vim: &KeymapDocument, + user: &KeymapDocument, + registry: &ActionRegistry, +) -> Result> { + let template = match profile { + ShortcutProfile::Standard => standard, + ShortcutProfile::Vim => vim, + }; + template.validate(registry)?; + user.validate(registry)?; + + let mut bindings = Vec::new(); + flatten_bindings(template, BindingSource::Template(profile), &mut bindings); + flatten_bindings(user, BindingSource::User, &mut bindings); + + let mut latest_by_exact_key: HashMap<(ContextExpression, KeySequence), usize> = HashMap::new(); + let mut conflicts = Vec::new(); + for index in 0..bindings.len() { + let exact_key = ( + bindings[index].context.clone(), + bindings[index].sequence.clone(), + ); + if let Some(previous) = latest_by_exact_key.insert(exact_key, index) { + bindings[previous].state = ResolvedBindingState::Shadowed; + conflicts.push(KeymapConflict { + kind: KeymapConflictKind::Shadowed, + left: previous, + right: index, + }); + } + if matches!(bindings[index].binding, KeymapBinding::Disabled) { + bindings[index].state = ResolvedBindingState::Disabled; + } + } + + for left in 0..bindings.len() { + if bindings[left].state != ResolvedBindingState::Effective { + continue; + } + for right in (left + 1)..bindings.len() { + if bindings[right].state != ResolvedBindingState::Effective { + continue; + } + let same_sequence = bindings[left].sequence == bindings[right].sequence; + let prefix = bindings[left] + .sequence + .is_prefix_of(&bindings[right].sequence) + || bindings[right] + .sequence + .is_prefix_of(&bindings[left].sequence); + if !same_sequence && !prefix { + continue; + } + let overlap = context_overlap(&bindings[left].context, &bindings[right].context); + match overlap { + ContextOverlap::Disjoint => {} + ContextOverlap::Exact => conflicts.push(KeymapConflict { + kind: if same_sequence { + KeymapConflictKind::Exact + } else { + KeymapConflictKind::Prefix + }, + left, + right, + }), + ContextOverlap::Possible => conflicts.push(KeymapConflict { + kind: KeymapConflictKind::Possible, + left, + right, + }), + } + } + } + + Ok(ResolvedKeymap { + profile, + bindings, + conflicts, + }) +} + +fn flatten_bindings( + document: &KeymapDocument, + source: BindingSource, + output: &mut Vec, +) { + for (entry_index, entry) in document.0.iter().enumerate() { + for (sequence, binding) in &entry.bindings { + output.push(ResolvedBinding { + context: entry.context.clone(), + sequence: sequence.clone(), + binding: binding.clone(), + source, + source_entry: entry_index, + state: ResolvedBindingState::Effective, + }); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ContextOverlap { + Exact, + Disjoint, + Possible, +} + +fn context_overlap(left: &ContextExpression, right: &ContextExpression) -> ContextOverlap { + if left == right { + return ContextOverlap::Exact; + } + let Some(left_equalities) = equality_constraints(left.as_str()) else { + return ContextOverlap::Possible; + }; + let Some(right_equalities) = equality_constraints(right.as_str()) else { + return ContextOverlap::Possible; + }; + for (key, left_value) in &left_equalities { + if let Some(right_value) = right_equalities.get(key) + && left_value != right_value + { + return ContextOverlap::Disjoint; + } + } + ContextOverlap::Possible +} + +fn equality_constraints(context: &str) -> Option> { + // Only prove disjointness for a flat conjunction. OR, negation, + // grouping, and descendant expressions require a real predicate solver; + // treating a textual equality inside one of them as unconditional would + // suppress a genuine possible conflict. + if context.contains("||") || context.contains('!') || context.contains(['(', ')', '>']) { + return None; + } + + let mut equalities = HashMap::new(); + for term in context.split("&&") { + let mut parts = term.split("=="); + let key = parts.next()?; + let Some(value) = parts.next() else { + continue; + }; + if parts.next().is_some() { + return None; + } + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return None; + } + match equalities.entry(key.to_owned()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(value.to_owned()); + } + std::collections::hash_map::Entry::Occupied(entry) if entry.get() == value => {} + std::collections::hash_map::Entry::Occupied(_) => return None, + } + } + Some(equalities) +} + +/// Keeps an active last-known-good map when a hand-edited reload is invalid. +#[derive(Clone, Debug)] +pub struct LastKnownGoodKeymap { + active: ResolvedKeymap, + last_error: Option>, +} + +impl LastKnownGoodKeymap { + pub fn new(active: ResolvedKeymap) -> Self { + Self { + active, + last_error: None, + } + } + + pub fn active(&self) -> &ResolvedKeymap { + &self.active + } + + pub fn last_error(&self) -> Option<&[KeymapValidationError]> { + self.last_error.as_deref() + } + + pub fn apply(&mut self, candidate: Result>) -> bool { + match candidate { + Ok(candidate) => { + self.active = candidate; + self.last_error = None; + true + } + Err(errors) => { + self.last_error = Some(errors); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{ + ActionDescriptor, ActionEffect, AuditSpec, InvocationPolicy, Recoverability, + RegistryBuilder, SCHEMA_VERSION, + }; + + fn descriptor(id: &str) -> ActionDescriptor { + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(id).unwrap(), + label: id.into(), + description: format!("Description for {id}"), + category: "Test".into(), + argument_schema: json!({ + "type": "object", + "properties": {"steer": {"type": "boolean"}}, + "additionalProperties": false + }), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + precondition_domain: None, + effect: ActionEffect::Navigate, + invocation_policy: InvocationPolicy::ControllerCallable, + recoverability: Recoverability::Ephemeral, + audit: AuditSpec::default(), + default_bindings: Vec::new(), + bindable: true, + terminal_host_action: false, + } + } + + fn registry() -> ActionRegistry { + let mut builder = RegistryBuilder::new(); + for id in ["task.new", "composer.send", "transcript.focus_next"] { + let action_id = ActionId::parse(id).unwrap(); + builder.register(descriptor(id)); + builder.register_adapter(action_id); + } + builder.build().unwrap() + } + + #[test] + fn zed_style_wire_format_round_trips_string_parameterized_and_null() { + let document = KeymapDocument::parse( + r#"[ + { + "context": "MapleApp && profile == vim", + "bindings": { + "j": "transcript.focus_next", + "cmd-k": null, + "ctrl-enter": ["composer.send", {"steer": true}] + } + } + ]"#, + ) + .unwrap(); + assert_eq!(document.0.len(), 1); + assert!(matches!( + document.0[0].bindings[&KeySequence::parse("cmd-k").unwrap()], + KeymapBinding::Disabled + )); + assert!(document.validate(®istry()).is_ok()); + let encoded = serde_json::to_value(&document).unwrap(); + assert!(encoded[0]["bindings"]["cmd-k"].is_null()); + assert_eq!(encoded[0]["bindings"]["j"], "transcript.focus_next"); + } + + #[test] + fn malformed_context_operator_runs_are_rejected() { + for context in [ + "MapleApp &&& profile == vim", + "MapleApp ||| profile == vim", + "MapleApp & profile == vim", + "MapleApp | profile == vim", + "profile = vim", + "profile === vim", + "profile !== vim", + ] { + assert_eq!( + ContextExpression::parse(context).unwrap_err(), + ContextExpressionError::IncompleteOperator, + "context {context:?} should be rejected" + ); + } + + for context in [ + "MapleApp && profile == vim", + "MapleApp || profile != vim", + "!MapleApp", + "Pane > Editor", + "vim_operator == >", + ] { + assert!( + ContextExpression::parse(context).is_ok(), + "context {context:?} should pass portable lexical validation" + ); + } + } + + #[test] + fn duplicate_binding_keys_are_rejected_during_json_parse() { + let error = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"j":"task.new","j":"composer.send"}}]"#, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("duplicate binding sequence \"j\"") + ); + } + + #[test] + fn unknown_fields_ids_and_arguments_are_rejected() { + assert!( + KeymapDocument::parse(r#"[{"context":"MapleApp","unexpected":true,"bindings":{}}]"#) + .is_err() + ); + let unknown = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"x":"unknown.action"}}]"#) + .unwrap(); + assert!(matches!( + unknown.validate(®istry()).unwrap_err()[0], + KeymapValidationError::UnknownAction { .. } + )); + let invalid_arguments = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"x":["composer.send",{"steer":"yes"}]}}]"#, + ) + .unwrap(); + assert!(matches!( + invalid_arguments.validate(®istry()).unwrap_err()[0], + KeymapValidationError::InvalidArguments { .. } + )); + } + + #[test] + fn selected_templates_replace_instead_of_stack() { + let standard = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"cmd-n":"task.new"}}]"#) + .unwrap(); + let vim = KeymapDocument::parse( + r#"[{"context":"MapleApp && profile == vim","bindings":{"space s n":"task.new"}}]"#, + ) + .unwrap(); + let user = KeymapDocument::default(); + let standard_resolved = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &vim, + &user, + ®istry(), + ) + .unwrap(); + assert!( + standard_resolved + .bindings + .iter() + .any(|binding| binding.sequence.as_str() == "cmd-n") + ); + assert!( + standard_resolved + .bindings + .iter() + .all(|binding| binding.sequence.as_str() != "space s n") + ); + let vim_resolved = + resolve_keymap(ShortcutProfile::Vim, &standard, &vim, &user, ®istry()).unwrap(); + assert!( + vim_resolved + .bindings + .iter() + .all(|binding| binding.sequence.as_str() != "cmd-n") + ); + } + + #[test] + fn user_override_and_null_shadow_template_at_equal_context() { + let standard = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"cmd-n":"task.new","cmd-k":"task.new"}}]"#, + ) + .unwrap(); + let user = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"cmd-n":"composer.send","cmd-k":null}}]"#, + ) + .unwrap(); + let resolved = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &KeymapDocument::default(), + &user, + ®istry(), + ) + .unwrap(); + assert_eq!( + resolved + .bindings + .iter() + .filter(|binding| binding.state == ResolvedBindingState::Shadowed) + .count(), + 2 + ); + assert!(resolved.bindings.iter().any(|binding| { + binding.sequence.as_str() == "cmd-k" && binding.state == ResolvedBindingState::Disabled + })); + } + + #[test] + fn conflict_detection_classifies_prefix_and_possible_overlap() { + let template = KeymapDocument::parse( + r#"[ + {"context":"MapleApp && profile == vim","bindings":{"g":"task.new","g g":"task.new"}}, + {"context":"MapleApp && region == transcript","bindings":{"g":"transcript.focus_next"}} + ]"#, + ) + .unwrap(); + let resolved = resolve_keymap( + ShortcutProfile::Vim, + &KeymapDocument::default(), + &template, + &KeymapDocument::default(), + ®istry(), + ) + .unwrap(); + assert!( + resolved + .conflicts + .iter() + .any(|conflict| conflict.kind == KeymapConflictKind::Prefix) + ); + assert!( + resolved + .conflicts + .iter() + .any(|conflict| conflict.kind == KeymapConflictKind::Possible) + ); + } + + #[test] + fn overlap_proof_is_conservative_for_complex_predicates() { + let standard = ContextExpression::parse("profile == standard").unwrap(); + + assert_eq!( + context_overlap( + &ContextExpression::parse("profile == vim").unwrap(), + &standard, + ), + ContextOverlap::Disjoint + ); + + for context in [ + "profile == vim || region == transcript", + "!(profile == vim)", + "(profile == vim)", + "Pane > profile == vim", + ] { + assert_eq!( + context_overlap(&ContextExpression::parse(context).unwrap(), &standard), + ContextOverlap::Possible, + "complex context {context:?} must not produce a false disjointness proof" + ); + } + } + + #[test] + fn invalid_reload_preserves_last_known_good() { + let standard = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"cmd-n":"task.new"}}]"#) + .unwrap(); + let valid = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &KeymapDocument::default(), + &KeymapDocument::default(), + ®istry(), + ) + .unwrap(); + let mut active = LastKnownGoodKeymap::new(valid); + let old_bindings = active.active().bindings.clone(); + let invalid = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"x":"missing.action"}}]"#) + .unwrap(); + let candidate = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &KeymapDocument::default(), + &invalid, + ®istry(), + ); + assert!(!active.apply(candidate)); + assert_eq!(active.active().bindings, old_bindings); + assert!(active.last_error().is_some()); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/lib.rs b/apps/maple-agent/crates/maple-harness/src/lib.rs new file mode 100644 index 000000000..f1bbf3036 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/lib.rs @@ -0,0 +1,32 @@ +//! GPUI-free core types and validation for Maple's programmable harness. +//! +//! This crate deliberately contains no action executors and no UI objects. The +//! application owns those capabilities; this crate defines the wire contract, +//! policy, registry validation, semantic projection primitives, and bounded +//! audit/event stores shared by every transport. + +#![forbid(unsafe_code)] + +pub mod action; +pub mod audit; +pub mod catalog; +pub mod controller; +pub mod discovery; +pub mod host; +pub mod keymap; +pub mod policy; +pub mod registry; +pub mod semantic; + +pub use action::*; +pub use audit::*; +pub use controller::*; +pub use discovery::*; +pub use host::*; +pub use keymap::*; +pub use policy::*; +pub use registry::*; +pub use semantic::*; + +/// Initial wire schema version for Developer Preview contracts. +pub const SCHEMA_VERSION: u16 = 1; diff --git a/apps/maple-agent/crates/maple-harness/src/policy.rs b/apps/maple-agent/crates/maple-harness/src/policy.rs new file mode 100644 index 000000000..2c351b23d --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/policy.rs @@ -0,0 +1,994 @@ +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +use crate::{ + ActionDescriptor, ActionEffect, ExecutionId, InvocationId, ProgramId, RunId, TaskIdentity, +}; + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationPolicy { + ControllerCallable, + HumanOnly, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationActor { + DirectUser, + Model, + UserCode, + Internal, +} + +impl InvocationActor { + pub const ALL: [Self; 4] = [ + Self::DirectUser, + Self::Model, + Self::UserCode, + Self::Internal, + ]; +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationTransport { + Pointer, + Keybinding, + CommandPalette, + Python, + Macro, + GeneratedUi, + Internal, +} + +impl InvocationTransport { + pub const ALL: [Self; 7] = [ + Self::Pointer, + Self::Keybinding, + Self::CommandPalette, + Self::Python, + Self::Macro, + Self::GeneratedUi, + Self::Internal, + ]; +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiControllerAccess { + #[default] + Off, + ReadOnly, + FullAccess, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PythonCodeMode { + #[default] + Off, + DeveloperPreview, +} + +/// Normalized, session-only runtime authority. +#[derive(Clone, Copy, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProgrammabilityAuthority { + pub code_mode: PythonCodeMode, + pub controller: UiControllerAccess, +} + +impl<'de> Deserialize<'de> for ProgrammabilityAuthority { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct WireAuthority { + code_mode: PythonCodeMode, + controller: UiControllerAccess, + } + + let wire = WireAuthority::deserialize(deserializer)?; + Self::new(wire.code_mode, wire.controller).map_err(serde::de::Error::custom) + } +} + +impl ProgrammabilityAuthority { + pub const OFF: Self = Self { + code_mode: PythonCodeMode::Off, + controller: UiControllerAccess::Off, + }; + + pub fn new( + code_mode: PythonCodeMode, + controller: UiControllerAccess, + ) -> Result { + let authority = Self { + code_mode, + controller, + }; + authority.validate()?; + Ok(authority) + } + + pub fn validate(self) -> Result<(), AuthorityTransitionError> { + if self.code_mode == PythonCodeMode::Off && self.controller != UiControllerAccess::Off { + return Err(AuthorityTransitionError::ControllerRequiresCodeMode); + } + Ok(()) + } + + /// Changes Code Mode and atomically revokes controller access when turning it off. + pub fn with_code_mode(mut self, code_mode: PythonCodeMode) -> Self { + self.code_mode = code_mode; + if code_mode == PythonCodeMode::Off { + self.controller = UiControllerAccess::Off; + } + self + } + + pub fn with_controller( + mut self, + controller: UiControllerAccess, + ) -> Result { + if self.code_mode == PythonCodeMode::Off && controller != UiControllerAccess::Off { + return Err(AuthorityTransitionError::ControllerRequiresCodeMode); + } + self.controller = controller; + Ok(self) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum AuthorityTransitionError { + #[error("Read Only or Full Access controller authority requires Developer Preview Code Mode")] + ControllerRequiresCodeMode, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyDenialCode { + HumanOnly, + ControllerOff, + ReadOnlyMutation, + InvalidDirectUserOrigin, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum PolicyDecision { + Allowed, + Denied { + code: PolicyDenialCode, + message: String, + }, +} + +impl PolicyDecision { + pub const fn is_allowed(&self) -> bool { + matches!(self, Self::Allowed) + } + + pub fn denial_code(&self) -> Option { + match self { + Self::Allowed => None, + Self::Denied { code, .. } => Some(*code), + } + } +} + +/// Applies the complete actor/transport/controller matrix. +/// +/// Only the three physical/direct-user tuples bypass controller authority. +/// `Internal` has no implicit privilege; it is evaluated exactly like any +/// other non-direct origin. +pub fn authorize( + invocation_policy: InvocationPolicy, + effect: ActionEffect, + actor: InvocationActor, + transport: InvocationTransport, + controller_access: UiControllerAccess, +) -> PolicyDecision { + let is_direct_user_origin = is_allowed_direct_user_origin(actor, transport); + + if actor == InvocationActor::DirectUser && !is_direct_user_origin { + return PolicyDecision::Denied { + code: PolicyDenialCode::InvalidDirectUserOrigin, + message: "Direct-user authority requires a trusted pointer, physical keybinding, or command-palette activation".into(), + }; + } + + if invocation_policy == InvocationPolicy::HumanOnly { + return if is_direct_user_origin { + PolicyDecision::Allowed + } else { + PolicyDecision::Denied { + code: PolicyDenialCode::HumanOnly, + message: "This action requires direct human activation".into(), + } + }; + } + + if is_direct_user_origin { + return PolicyDecision::Allowed; + } + + match controller_access { + UiControllerAccess::Off => PolicyDecision::Denied { + code: PolicyDenialCode::ControllerOff, + message: "Maple UI Controller access is Off".into(), + }, + UiControllerAccess::ReadOnly => match effect { + ActionEffect::Observe | ActionEffect::Navigate => PolicyDecision::Allowed, + ActionEffect::MutateMaple | ActionEffect::ExternalEffect => PolicyDecision::Denied { + code: PolicyDenialCode::ReadOnlyMutation, + message: + "Read Only controller access cannot mutate Maple or cause external effects" + .into(), + }, + }, + UiControllerAccess::FullAccess => PolicyDecision::Allowed, + } +} + +pub fn is_allowed_direct_user_origin( + actor: InvocationActor, + transport: InvocationTransport, +) -> bool { + actor == InvocationActor::DirectUser + && matches!( + transport, + InvocationTransport::Pointer + | InvocationTransport::Keybinding + | InvocationTransport::CommandPalette + ) +} + +/// A shared, bounded admission counter for one program and all derived calls. +#[derive(Clone, Debug)] +pub struct ActionBudget { + inner: Arc, +} + +#[derive(Debug)] +struct ActionBudgetInner { + limit: u32, + consumed: AtomicU32, +} + +impl ActionBudget { + pub fn new(limit: u32) -> Result { + if limit == 0 { + return Err(ActionBudgetError::ZeroLimit); + } + Ok(Self { + inner: Arc::new(ActionBudgetInner { + limit, + consumed: AtomicU32::new(0), + }), + }) + } + + pub fn limit(&self) -> u32 { + self.inner.limit + } + + pub fn consumed(&self) -> u32 { + self.inner.consumed.load(Ordering::Acquire) + } + + pub fn remaining(&self) -> u32 { + self.limit().saturating_sub(self.consumed()) + } + + pub fn try_consume(&self) -> Result { + let mut consumed = self.inner.consumed.load(Ordering::Acquire); + loop { + if consumed >= self.inner.limit { + return Err(ActionBudgetError::Exhausted { + limit: self.inner.limit, + }); + } + match self.inner.consumed.compare_exchange_weak( + consumed, + consumed + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Ok(consumed + 1), + Err(current) => consumed = current, + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum ActionBudgetError { + #[error("action budget must be greater than zero")] + ZeroLimit, + #[error("program action budget of {limit} calls is exhausted")] + Exhausted { limit: u32 }, +} + +/// Window-scoped issuer held by the trusted UI host. Tokens minted by this +/// value are opaque, non-serializable, and move-only. +#[derive(Clone, Debug)] +pub struct DirectUserIngress { + issuer_id: DirectUserIssuerId, +} + +/// Opaque identity for the one direct-user issuer registered with an action +/// host. This value is deliberately non-serializable: it is host wiring, not a +/// capability a controller request may supply. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct DirectUserIssuerId(uuid::Uuid); + +impl DirectUserIngress { + pub fn new_window() -> Self { + Self { + issuer_id: DirectUserIssuerId(uuid::Uuid::new_v4()), + } + } + + /// Returns the opaque identity that the owning UI host must register with + /// its action dispatcher. Possessing an independently-created issuer with a + /// different identity never grants direct-user authority. + pub fn issuer_id(&self) -> DirectUserIssuerId { + self.issuer_id + } + + /// Mint only while handling the corresponding physical pointer callback. + pub fn pointer(&self) -> DirectUserProvenance { + DirectUserProvenance { + issuer_id: self.issuer_id, + transport: InvocationTransport::Pointer, + } + } + + /// Mint only for an explicit command-palette activation initiated by a + /// direct-user pointer/key event. + pub fn command_palette(&self) -> DirectUserProvenance { + DirectUserProvenance { + issuer_id: self.issuer_id, + transport: InvocationTransport::CommandPalette, + } + } + + /// Starts provenance for a physical multi-stroke key sequence. The token + /// remains valid across GPUI's shorter-prefix timeout only while all three + /// host generations remain unchanged. + pub fn begin_key_sequence( + &self, + focus_generation: u64, + context_generation: u64, + keymap_generation: u64, + ) -> PendingKeyProvenance { + PendingKeyProvenance { + issuer_id: self.issuer_id, + focus_generation, + context_generation, + keymap_generation, + active: true, + } + } + + /// Constructs a DirectUser invocation and rejects provenance minted for a + /// different window-scoped ingress. Keep this ingress object private to + /// the UI host; do not expose it to model, Python, macro, generated UI, or + /// generic internal dispatch code. + #[allow(clippy::too_many_arguments)] + pub fn trusted_invocation( + &self, + invocation_id: InvocationId, + provenance: DirectUserProvenance, + source_task: Option, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, + ) -> Result { + if provenance.issuer_id != self.issuer_id { + return Err(TrustedInvocationError::WrongWindowProvenance); + } + Ok(TrustedInvocation::new_direct_user( + invocation_id, + provenance, + source_task, + controller_access, + policy_epoch, + cancellation, + action_budget, + )) + } +} + +impl Default for DirectUserIngress { + fn default() -> Self { + Self::new_window() + } +} + +#[derive(Debug)] +pub struct DirectUserProvenance { + issuer_id: DirectUserIssuerId, + transport: InvocationTransport, +} + +/// Physical-key provenance retained while GPUI resolves a pending sequence. +/// It is intentionally not cloneable or serializable. +#[derive(Debug)] +pub struct PendingKeyProvenance { + issuer_id: DirectUserIssuerId, + focus_generation: u64, + context_generation: u64, + keymap_generation: u64, + active: bool, +} + +impl PendingKeyProvenance { + pub fn invalidate(&mut self) { + self.active = false; + } + + pub fn is_active(&self) -> bool { + self.active + } + + /// Consumes a successfully resolved physical keybinding. Focus/context + /// changes, profile reloads, recorder takeover, cancellation, or an + /// unresolved timeout must invalidate or fail this token. + pub fn consume_resolved( + self, + focus_generation: u64, + context_generation: u64, + keymap_generation: u64, + ) -> Result { + if !self.active { + return Err(PendingKeyProvenanceError::Invalidated); + } + if self.focus_generation != focus_generation { + return Err(PendingKeyProvenanceError::FocusChanged); + } + if self.context_generation != context_generation { + return Err(PendingKeyProvenanceError::ContextChanged); + } + if self.keymap_generation != keymap_generation { + return Err(PendingKeyProvenanceError::KeymapChanged); + } + Ok(DirectUserProvenance { + issuer_id: self.issuer_id, + transport: InvocationTransport::Keybinding, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum PendingKeyProvenanceError { + #[error("pending key provenance was invalidated")] + Invalidated, + #[error("focus changed during pending key resolution")] + FocusChanged, + #[error("key context changed during pending key resolution")] + ContextChanged, + #[error("keymap generation changed during pending key resolution")] + KeymapChanged, +} + +/// Host-assigned execution provenance. +/// +/// This type deliberately implements neither [`Serialize`] nor [`Deserialize`]. +/// Controller wire requests contain only `ActionCall`; a trusted host creates +/// this value after attaching the current policy, identity, budget, and +/// cancellation state. +/// +/// ```compile_fail +/// use maple_harness::TrustedInvocation; +/// let _: TrustedInvocation = serde_json::from_str("{}").unwrap(); +/// ``` +#[derive(Clone, Debug)] +pub struct TrustedInvocation { + invocation_id: InvocationId, + source_task: Option, + program_id: Option, + program_started_unix_ms: Option, + model_run_id: Option, + execution_id: Option, + kernel_generation: Option, + actor: InvocationActor, + transport: InvocationTransport, + direct_user_issuer_id: Option, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, +} + +impl TrustedInvocation { + #[allow(clippy::too_many_arguments)] + pub fn new( + invocation_id: InvocationId, + source_task: Option, + program_id: Option, + model_run_id: Option, + execution_id: Option, + kernel_generation: Option, + actor: InvocationActor, + transport: InvocationTransport, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, + ) -> Result { + if actor == InvocationActor::DirectUser { + return Err(TrustedInvocationError::DirectUserRequiresProvenance); + } + Ok(Self { + invocation_id, + source_task, + program_id, + program_started_unix_ms: None, + model_run_id, + execution_id, + kernel_generation, + actor, + transport, + direct_user_issuer_id: None, + controller_access, + policy_epoch, + cancellation, + action_budget, + }) + } + + #[allow(clippy::too_many_arguments)] + fn new_direct_user( + invocation_id: InvocationId, + provenance: DirectUserProvenance, + source_task: Option, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, + ) -> Self { + Self { + invocation_id, + source_task, + program_id: None, + program_started_unix_ms: None, + model_run_id: None, + execution_id: None, + kernel_generation: None, + actor: InvocationActor::DirectUser, + transport: provenance.transport, + direct_user_issuer_id: Some(provenance.issuer_id), + controller_access, + policy_epoch, + cancellation, + action_budget, + } + } + + pub fn invocation_id(&self) -> InvocationId { + self.invocation_id + } + + pub fn source_task(&self) -> Option<&TaskIdentity> { + self.source_task.as_ref() + } + + pub fn program_id(&self) -> Option { + self.program_id + } + + /// Attach the immutable root-program start minted by the Code Mode + /// kernel. This consumes the invocation so callers cannot mutate an + /// already admitted provenance value in place. + pub fn with_program_started_unix_ms(mut self, started_unix_ms: u64) -> Self { + self.program_started_unix_ms = Some(started_unix_ms); + self + } + + pub fn program_started_unix_ms(&self) -> Option { + self.program_started_unix_ms + } + + pub fn model_run_id(&self) -> Option { + self.model_run_id.clone() + } + + pub fn execution_id(&self) -> Option { + self.execution_id + } + + pub fn kernel_generation(&self) -> Option { + self.kernel_generation + } + + pub fn actor(&self) -> InvocationActor { + self.actor + } + + pub fn transport(&self) -> InvocationTransport { + self.transport + } + + /// Returns the issuer identity retained from opaque physical-input + /// provenance. Non-direct invocations never have an issuer identity. + pub fn direct_user_issuer_id(&self) -> Option { + self.direct_user_issuer_id + } + + pub fn controller_access(&self) -> UiControllerAccess { + self.controller_access + } + + pub fn policy_epoch(&self) -> u64 { + self.policy_epoch + } + + pub fn cancellation(&self) -> &CancellationToken { + &self.cancellation + } + + pub fn action_budget(&self) -> &ActionBudget { + &self.action_budget + } + + /// Evaluates this host-attached provenance against an action descriptor. + /// Wire callers cannot choose or replace any of the authority fields used + /// by this check. + pub fn authorize(&self, descriptor: &ActionDescriptor) -> PolicyDecision { + authorize( + descriptor.invocation_policy, + descriptor.effect, + self.actor, + self.transport, + self.controller_access, + ) + } + + /// Creates a follow-up invocation while preserving all authority and lease + /// state. Only the per-action invocation ID changes. + pub fn derived(&self, invocation_id: InvocationId) -> Self { + let mut derived = self.clone(); + derived.invocation_id = invocation_id; + derived + } + + /// Checks cancellation and policy lease freshness without consuming budget. + pub fn check_active(&self, current_policy_epoch: u64) -> Result<(), InvocationGuardError> { + if self.cancellation.is_cancelled() { + return Err(InvocationGuardError::Cancelled); + } + if self.policy_epoch != current_policy_epoch { + return Err(InvocationGuardError::PolicyEpochChanged { + expected: self.policy_epoch, + current: current_policy_epoch, + }); + } + Ok(()) + } + + /// Final admission check for one action in a program or chain. + pub fn admit_call(&self, current_policy_epoch: u64) -> Result { + self.check_active(current_policy_epoch)?; + self.action_budget + .try_consume() + .map_err(InvocationGuardError::Budget) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum TrustedInvocationError { + #[error("DirectUser invocations require opaque physical-input provenance")] + DirectUserRequiresProvenance, + #[error("direct-user provenance was minted for a different window")] + WrongWindowProvenance, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum InvocationGuardError { + #[error("program was cancelled")] + Cancelled, + #[error("controller policy epoch changed from {expected} to {current}")] + PolicyEpochChanged { expected: u64, current: u64 }, + #[error(transparent)] + Budget(#[from] ActionBudgetError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_only_and_full_access_follow_the_effect_matrix() { + for actor in [InvocationActor::Model, InvocationActor::UserCode] { + for transport in [ + InvocationTransport::Python, + InvocationTransport::GeneratedUi, + ] { + for effect in [ActionEffect::Observe, ActionEffect::Navigate] { + assert!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::ReadOnly, + ) + .is_allowed() + ); + } + for effect in [ActionEffect::MutateMaple, ActionEffect::ExternalEffect] { + assert_eq!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::ReadOnly, + ) + .denial_code(), + Some(PolicyDenialCode::ReadOnlyMutation) + ); + assert!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::FullAccess, + ) + .is_allowed() + ); + } + } + } + } + + #[test] + fn off_denies_every_controller_effect() { + for actor in [ + InvocationActor::Model, + InvocationActor::UserCode, + InvocationActor::Internal, + ] { + for transport in InvocationTransport::ALL { + for effect in [ + ActionEffect::Observe, + ActionEffect::Navigate, + ActionEffect::MutateMaple, + ActionEffect::ExternalEffect, + ] { + assert_eq!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::Off, + ) + .denial_code(), + Some(PolicyDenialCode::ControllerOff), + "unexpected result for {actor:?}/{transport:?}/{effect:?}" + ); + } + } + } + } + + #[test] + fn human_only_matrix_is_exhaustive() { + for actor in InvocationActor::ALL { + for transport in InvocationTransport::ALL { + let decision = authorize( + InvocationPolicy::HumanOnly, + ActionEffect::Observe, + actor, + transport, + UiControllerAccess::FullAccess, + ); + assert_eq!( + decision.is_allowed(), + is_allowed_direct_user_origin(actor, transport), + "unexpected Human Only result for {actor:?}/{transport:?}" + ); + } + } + } + + #[test] + fn internal_follow_up_inherits_provenance_policy_and_shared_budget() { + let cancellation = CancellationToken::new(); + let invocation = TrustedInvocation::new( + InvocationId::new(), + Some(TaskIdentity::new("account", "task")), + Some(ProgramId::new()), + Some(RunId::new()), + Some(ExecutionId::new()), + Some(3), + InvocationActor::Model, + InvocationTransport::Python, + UiControllerAccess::ReadOnly, + 7, + cancellation, + ActionBudget::new(2).unwrap(), + ) + .unwrap() + .with_program_started_unix_ms(1_000); + let follow_up = invocation.derived(InvocationId::new()); + + assert_eq!(follow_up.actor(), InvocationActor::Model); + assert_eq!(follow_up.transport(), InvocationTransport::Python); + assert_eq!(follow_up.direct_user_issuer_id(), None); + assert_eq!(follow_up.controller_access(), UiControllerAccess::ReadOnly); + assert_eq!(follow_up.policy_epoch(), 7); + assert_eq!(follow_up.program_id(), invocation.program_id()); + assert_eq!(follow_up.program_started_unix_ms(), Some(1_000)); + assert_eq!(follow_up.admit_call(7).unwrap(), 1); + assert_eq!(invocation.admit_call(7).unwrap(), 2); + assert!(matches!( + follow_up.admit_call(7), + Err(InvocationGuardError::Budget(ActionBudgetError::Exhausted { + limit: 2 + })) + )); + } + + #[test] + fn cancellation_and_policy_revocation_prevent_subsequent_calls() { + let cancellation = CancellationToken::new(); + let invocation = TrustedInvocation::new( + InvocationId::new(), + None, + Some(ProgramId::new()), + None, + Some(ExecutionId::new()), + None, + InvocationActor::UserCode, + InvocationTransport::Python, + UiControllerAccess::FullAccess, + 4, + cancellation.clone(), + ActionBudget::new(5).unwrap(), + ) + .unwrap(); + + assert!(invocation.admit_call(5).is_err()); + assert_eq!(invocation.action_budget().consumed(), 0); + assert!(invocation.admit_call(4).is_ok()); + cancellation.cancel(); + assert_eq!( + invocation.admit_call(4), + Err(InvocationGuardError::Cancelled) + ); + assert_eq!(invocation.action_budget().consumed(), 1); + } + + #[test] + fn direct_user_requires_move_only_physical_input_provenance() { + assert!(matches!( + TrustedInvocation::new( + InvocationId::new(), + None, + None, + None, + None, + None, + InvocationActor::DirectUser, + InvocationTransport::Pointer, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ), + Err(TrustedInvocationError::DirectUserRequiresProvenance) + )); + + let ingress = DirectUserIngress::new_window(); + let invocation = ingress + .trusted_invocation( + InvocationId::new(), + ingress.pointer(), + None, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ) + .unwrap(); + assert_eq!(invocation.actor(), InvocationActor::DirectUser); + assert_eq!(invocation.transport(), InvocationTransport::Pointer); + assert_eq!( + invocation.direct_user_issuer_id(), + Some(ingress.issuer_id()) + ); + } + + #[test] + fn pending_key_provenance_survives_resolved_timeout_but_not_host_state_changes() { + let ingress = DirectUserIngress::new_window(); + let resolved = ingress + .begin_key_sequence(10, 20, 30) + .consume_resolved(10, 20, 30) + .unwrap(); + let invocation = ingress + .trusted_invocation( + InvocationId::new(), + resolved, + None, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ) + .unwrap(); + assert_eq!(invocation.transport(), InvocationTransport::Keybinding); + + assert!(matches!( + ingress + .begin_key_sequence(10, 20, 30) + .consume_resolved(11, 20, 30), + Err(PendingKeyProvenanceError::FocusChanged) + )); + let mut cancelled = ingress.begin_key_sequence(10, 20, 30); + cancelled.invalidate(); + assert!(matches!( + cancelled.consume_resolved(10, 20, 30), + Err(PendingKeyProvenanceError::Invalidated) + )); + } + + #[test] + fn direct_user_provenance_is_window_scoped() { + let first = DirectUserIngress::new_window(); + let second = DirectUserIngress::new_window(); + assert_ne!(first.issuer_id(), second.issuer_id()); + assert!(matches!( + second.trusted_invocation( + InvocationId::new(), + first.pointer(), + None, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ), + Err(TrustedInvocationError::WrongWindowProvenance) + )); + } + + #[test] + fn authority_tuple_is_normalized_and_fail_closed() { + assert!( + ProgrammabilityAuthority::new(PythonCodeMode::Off, UiControllerAccess::ReadOnly) + .is_err() + ); + let full = ProgrammabilityAuthority::new( + PythonCodeMode::DeveloperPreview, + UiControllerAccess::FullAccess, + ) + .unwrap(); + assert_eq!( + full.with_code_mode(PythonCodeMode::Off), + ProgrammabilityAuthority::OFF + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "code_mode": "off", + "controller": "full_access" + })) + .is_err() + ); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/registry.rs b/apps/maple-agent/crates/maple-harness/src/registry.rs new file mode 100644 index 000000000..69205ed42 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/registry.rs @@ -0,0 +1,478 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value; +use thiserror::Error; + +use crate::{ + ActionDescriptor, ActionEffect, ActionId, AuditSpecError, ContextExpression, + ContextExpressionError, InvocationPolicy, KeySequence, KeySequenceError, SCHEMA_VERSION, +}; + +#[derive(Clone, Debug, Default)] +pub struct RegistryBuilder { + descriptors: Vec, + adapters: Vec, +} + +impl RegistryBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, descriptor: ActionDescriptor) -> &mut Self { + self.descriptors.push(descriptor); + self + } + + /// Records that the app provides the one typed GPUI adapter corresponding + /// to a bindable semantic action. The core stores only the stable ID and + /// remains GPUI-free. + pub fn register_adapter(&mut self, action_id: ActionId) -> &mut Self { + self.adapters.push(action_id); + self + } + + pub fn build(self) -> Result> { + let mut errors = Vec::new(); + let mut descriptors = BTreeMap::new(); + for descriptor in self.descriptors { + if descriptors.contains_key(&descriptor.id) { + errors.push(RegistryValidationError::DuplicateActionId( + descriptor.id.clone(), + )); + continue; + } + validate_descriptor(&descriptor, &mut errors); + descriptors.insert(descriptor.id.clone(), descriptor); + } + + let mut adapters = BTreeSet::new(); + for action_id in self.adapters { + if !adapters.insert(action_id.clone()) { + errors.push(RegistryValidationError::DuplicateAdapter(action_id)); + } + } + for adapter in &adapters { + match descriptors.get(adapter) { + None => errors.push(RegistryValidationError::AdapterForUnknownAction( + adapter.clone(), + )), + Some(descriptor) if !descriptor.bindable => errors.push( + RegistryValidationError::AdapterForNonBindableAction(adapter.clone()), + ), + Some(_) => {} + } + } + for descriptor in descriptors.values() { + if descriptor.bindable && !adapters.contains(&descriptor.id) { + errors.push(RegistryValidationError::MissingAdapter( + descriptor.id.clone(), + )); + } + } + + if errors.is_empty() { + Ok(ActionRegistry { + descriptors, + adapters, + }) + } else { + Err(errors) + } + } +} + +fn validate_descriptor(descriptor: &ActionDescriptor, errors: &mut Vec) { + if descriptor.schema_version != SCHEMA_VERSION { + errors.push(RegistryValidationError::UnsupportedSchemaVersion { + action_id: descriptor.id.clone(), + schema_version: descriptor.schema_version, + supported: SCHEMA_VERSION, + }); + } + validate_required_copy(descriptor, errors); + validate_schema( + &descriptor.id, + SchemaKind::Arguments, + &descriptor.argument_schema, + errors, + ); + validate_schema( + &descriptor.id, + SchemaKind::Result, + &descriptor.result_schema, + errors, + ); + if let Err(source) = descriptor.audit.validate() { + errors.push(RegistryValidationError::UnsafeAuditSpec { + action_id: descriptor.id.clone(), + source, + }); + } + if !descriptor.bindable && !descriptor.default_bindings.is_empty() { + errors.push(RegistryValidationError::DefaultBindingOnNonBindableAction( + descriptor.id.clone(), + )); + } + if requires_human_only(&descriptor.id) + && descriptor.invocation_policy != InvocationPolicy::HumanOnly + { + errors.push(RegistryValidationError::AuthorityActionNotHumanOnly( + descriptor.id.clone(), + )); + } + if descriptor.id.as_str() == "permission.respond" + && (descriptor.invocation_policy != InvocationPolicy::ControllerCallable + || descriptor.effect != ActionEffect::MutateMaple) + { + errors.push(RegistryValidationError::InvalidPermissionRespondContract( + descriptor.id.clone(), + )); + } + if descriptor.id.as_str() == "app.quit" + && (!descriptor.terminal_host_action + || descriptor.invocation_policy != InvocationPolicy::ControllerCallable) + { + errors.push(RegistryValidationError::InvalidAppQuitContract( + descriptor.id.clone(), + )); + } + for (binding_index, binding) in descriptor.default_bindings.iter().enumerate() { + if let Err(source) = ContextExpression::parse(binding.context.clone()) { + errors.push(RegistryValidationError::InvalidDefaultContext { + action_id: descriptor.id.clone(), + binding_index, + source, + }); + } + if let Err(source) = KeySequence::parse(binding.sequence.clone()) { + errors.push(RegistryValidationError::InvalidDefaultSequence { + action_id: descriptor.id.clone(), + binding_index, + source, + }); + } + if let Err(source) = descriptor.validate_arguments(&binding.arguments) { + errors.push(RegistryValidationError::InvalidDefaultArguments { + action_id: descriptor.id.clone(), + binding_index, + message: source.to_string(), + }); + } + } +} + +fn validate_required_copy( + descriptor: &ActionDescriptor, + errors: &mut Vec, +) { + for (field, value) in [ + (DescriptorCopyField::Label, descriptor.label.as_str()), + ( + DescriptorCopyField::Description, + descriptor.description.as_str(), + ), + (DescriptorCopyField::Category, descriptor.category.as_str()), + ] { + if value.trim().is_empty() || value.trim() != value { + errors.push(RegistryValidationError::MissingOrInvalidCopy { + action_id: descriptor.id.clone(), + field, + }); + } + } +} + +fn validate_schema( + action_id: &ActionId, + kind: SchemaKind, + schema: &Value, + errors: &mut Vec, +) { + if !schema.is_object() { + errors.push(RegistryValidationError::MissingSchema { + action_id: action_id.clone(), + kind, + }); + return; + } + if let Err(error) = jsonschema::validator_for(schema) { + errors.push(RegistryValidationError::InvalidSchema { + action_id: action_id.clone(), + kind, + message: error.to_string(), + }); + } +} + +fn requires_human_only(action_id: &ActionId) -> bool { + let id = action_id.as_str(); + id.starts_with("auth.") + || matches!( + id, + "account.sign_out" + | "account.delete" + | "code_mode.set_enabled" + | "code_mode.set_controller_access" + ) + || (id.starts_with("account.") + && [ + "delete", + "email", + "password", + "recovery", + "mfa", + "credential", + "token", + "revoke", + ] + .iter() + .any(|sensitive| id.contains(sensitive))) +} + +#[derive(Clone, Debug)] +pub struct ActionRegistry { + descriptors: BTreeMap, + adapters: BTreeSet, +} + +impl ActionRegistry { + pub fn descriptor(&self, action_id: &ActionId) -> Option<&ActionDescriptor> { + self.descriptors.get(action_id) + } + + pub fn contains(&self, action_id: &ActionId) -> bool { + self.descriptors.contains_key(action_id) + } + + pub fn has_adapter(&self, action_id: &ActionId) -> bool { + self.adapters.contains(action_id) + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.descriptors.iter() + } + + pub fn len(&self) -> usize { + self.descriptors.len() + } + + pub fn is_empty(&self) -> bool { + self.descriptors.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SchemaKind { + Arguments, + Result, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DescriptorCopyField { + Label, + Description, + Category, +} + +#[derive(Clone, Debug, Error, PartialEq)] +pub enum RegistryValidationError { + #[error("duplicate stable action ID {0}")] + DuplicateActionId(ActionId), + #[error( + "action {action_id} uses schema version {schema_version}, but this host supports {supported}" + )] + UnsupportedSchemaVersion { + action_id: ActionId, + schema_version: u16, + supported: u16, + }, + #[error("action {action_id} has missing or invalid {field:?}")] + MissingOrInvalidCopy { + action_id: ActionId, + field: DescriptorCopyField, + }, + #[error("action {action_id} is missing an object-shaped {kind:?} schema")] + MissingSchema { + action_id: ActionId, + kind: SchemaKind, + }, + #[error("action {action_id} has invalid {kind:?} schema: {message}")] + InvalidSchema { + action_id: ActionId, + kind: SchemaKind, + message: String, + }, + #[error("action {action_id} has an unsafe audit specification: {source}")] + UnsafeAuditSpec { + action_id: ActionId, + source: AuditSpecError, + }, + #[error("authority-changing action {0} must be Human Only")] + AuthorityActionNotHumanOnly(ActionId), + #[error("{0} must be a controller-callable Mutate Maple action")] + InvalidPermissionRespondContract(ActionId), + #[error("{0} must be controller-callable and marked as a terminal host action")] + InvalidAppQuitContract(ActionId), + #[error("action {action_id} default binding {binding_index} has invalid context: {source}")] + InvalidDefaultContext { + action_id: ActionId, + binding_index: usize, + source: ContextExpressionError, + }, + #[error("action {action_id} default binding {binding_index} has invalid sequence: {source}")] + InvalidDefaultSequence { + action_id: ActionId, + binding_index: usize, + source: KeySequenceError, + }, + #[error("action {action_id} default binding {binding_index} has invalid arguments: {message}")] + InvalidDefaultArguments { + action_id: ActionId, + binding_index: usize, + message: String, + }, + #[error("bindable action {0} has no registered typed GPUI adapter")] + MissingAdapter(ActionId), + #[error("non-bindable action {0} cannot declare default key bindings")] + DefaultBindingOnNonBindableAction(ActionId), + #[error("duplicate typed GPUI adapter registration for {0}")] + DuplicateAdapter(ActionId), + #[error("typed GPUI adapter references unknown action {0}")] + AdapterForUnknownAction(ActionId), + #[error("typed GPUI adapter references non-bindable action {0}")] + AdapterForNonBindableAction(ActionId), +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{ + ActionEffect, AuditSpec, DefaultBinding, InvocationPolicy, Recoverability, ShortcutProfile, + }; + + fn descriptor(id: &str) -> ActionDescriptor { + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(id).unwrap(), + label: "Test action".into(), + description: "A complete test action descriptor".into(), + category: "Tests".into(), + argument_schema: json!({"type": "object", "additionalProperties": false}), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + effect: ActionEffect::Observe, + invocation_policy: InvocationPolicy::ControllerCallable, + recoverability: Recoverability::Ephemeral, + audit: AuditSpec::default(), + default_bindings: Vec::new(), + precondition_domain: None, + bindable: true, + terminal_host_action: false, + } + } + + #[test] + fn registry_rejects_duplicate_descriptors() { + let descriptor = descriptor("task.open"); + let mut builder = RegistryBuilder::new(); + builder.register(descriptor.clone()).register(descriptor); + builder.register_adapter(ActionId::parse("task.open").unwrap()); + assert!( + builder + .build() + .unwrap_err() + .iter() + .any(|error| matches!(error, RegistryValidationError::DuplicateActionId(_))) + ); + } + + #[test] + fn registry_requires_documentation_schemas_and_typed_adapter() { + let mut invalid = descriptor("task.open"); + invalid.label = " ".into(); + invalid.result_schema = Value::Null; + let mut builder = RegistryBuilder::new(); + builder.register(invalid); + let errors = builder.build().unwrap_err(); + assert!( + errors + .iter() + .any(|error| matches!(error, RegistryValidationError::MissingOrInvalidCopy { .. })) + ); + assert!(errors.iter().any(|error| matches!( + error, + RegistryValidationError::MissingSchema { + kind: SchemaKind::Result, + .. + } + ))); + assert!( + errors + .iter() + .any(|error| matches!(error, RegistryValidationError::MissingAdapter(_))) + ); + } + + #[test] + fn registry_rejects_unparsable_or_schema_invalid_defaults() { + let mut invalid = descriptor("composer.send"); + invalid.argument_schema = json!({ + "type": "object", + "properties": {"steer": {"type": "boolean"}}, + "additionalProperties": false + }); + invalid.default_bindings = vec![DefaultBinding { + profile: ShortcutProfile::Standard, + context: "MapleApp && (".into(), + sequence: "ctrl-enter ".into(), + arguments: json!({"steer": "yes"}), + }]; + let mut builder = RegistryBuilder::new(); + builder.register(invalid); + builder.register_adapter(ActionId::parse("composer.send").unwrap()); + let errors = builder.build().unwrap_err(); + assert!( + errors.iter().any(|error| matches!( + error, + RegistryValidationError::InvalidDefaultContext { .. } + )) + ); + assert!(errors.iter().any(|error| matches!( + error, + RegistryValidationError::InvalidDefaultSequence { .. } + ))); + assert!(errors.iter().any(|error| matches!( + error, + RegistryValidationError::InvalidDefaultArguments { .. } + ))); + } + + #[test] + fn authority_changing_actions_must_be_human_only() { + let mut authority = descriptor("code_mode.set_controller_access"); + authority.invocation_policy = InvocationPolicy::ControllerCallable; + let mut builder = RegistryBuilder::new(); + builder.register(authority); + builder.register_adapter(ActionId::parse("code_mode.set_controller_access").unwrap()); + assert!(builder.build().unwrap_err().iter().any(|error| matches!( + error, + RegistryValidationError::AuthorityActionNotHumanOnly(_) + ))); + } + + #[test] + fn valid_registry_preserves_descriptor_and_adapter_identity() { + let action_id = ActionId::parse("permission.respond").unwrap(); + let mut action = descriptor(action_id.as_str()); + action.effect = ActionEffect::MutateMaple; + let mut builder = RegistryBuilder::new(); + builder.register(action).register_adapter(action_id.clone()); + let registry = builder.build().unwrap(); + assert!(registry.contains(&action_id)); + assert!(registry.has_adapter(&action_id)); + assert_eq!(registry.descriptor(&action_id).unwrap().id, action_id); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/semantic.rs b/apps/maple-agent/crates/maple-harness/src/semantic.rs new file mode 100644 index 000000000..caac309f9 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/semantic.rs @@ -0,0 +1,1578 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + fmt, + ops::Range, + str::FromStr, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, de}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + ActionError, ActionId, ActionStatus, Availability, InvocationId, OperationId, + ProgrammabilityAuthority, UiControllerAccess, +}; + +pub const DEFAULT_SEMANTIC_EVENT_CAPACITY: usize = 2_048; +pub const DEFAULT_SEMANTIC_NODE_PAGE_LIMIT: usize = 200; +pub const DEFAULT_SNAPSHOT_TEXT_LIMIT_BYTES: usize = 128 * 1024; +pub const MAX_EVENT_WAIT_TIMEOUT_MS: u64 = 60_000; + +macro_rules! semantic_name { + ($name:ident, $label:literal) => { + #[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] + #[serde(transparent)] + #[schemars(transparent)] + pub struct $name( + #[schemars(length(min = 1, max = 64), pattern(r"^[a-z][a-z0-9_.]*$"))] String, + ); + + impl $name { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_semantic_name(&value, $label)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = SemanticNameError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } + } + + impl TryFrom<&str> for $name { + type Error = SemanticNameError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } + } + }; +} + +semantic_name!(ScreenId, "screen ID"); +semantic_name!(RegionId, "region ID"); +semantic_name!(SemanticKind, "semantic kind"); + +impl ScreenId { + pub fn login() -> Self { + Self::parse("login").expect("built-in screen ID is valid") + } + + pub fn chat() -> Self { + Self::parse("chat").expect("built-in screen ID is valid") + } + + pub fn settings() -> Self { + Self::parse("settings").expect("built-in screen ID is valid") + } + + pub fn code_mode() -> Self { + Self::parse("code_mode").expect("built-in screen ID is valid") + } +} + +impl RegionId { + pub fn app() -> Self { + Self::parse("app").expect("built-in region ID is valid") + } + + pub fn sidebar() -> Self { + Self::parse("sidebar").expect("built-in region ID is valid") + } + + pub fn transcript() -> Self { + Self::parse("transcript").expect("built-in region ID is valid") + } + + pub fn composer() -> Self { + Self::parse("composer").expect("built-in region ID is valid") + } + + pub fn settings() -> Self { + Self::parse("settings").expect("built-in region ID is valid") + } + + pub fn overlay() -> Self { + Self::parse("overlay").expect("built-in region ID is valid") + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SemanticNameError { + #[error("{kind} is empty")] + Empty { kind: &'static str }, + #[error("{kind} exceeds 64 bytes")] + TooLong { kind: &'static str }, + #[error("{kind} must start with an ASCII lowercase letter")] + InvalidStart { kind: &'static str }, + #[error("{kind} contains invalid character {character:?} at byte {byte_index}")] + InvalidCharacter { + kind: &'static str, + byte_index: usize, + character: char, + }, +} + +fn validate_semantic_name(value: &str, kind: &'static str) -> Result<(), SemanticNameError> { + if value.is_empty() { + return Err(SemanticNameError::Empty { kind }); + } + if value.len() > 64 { + return Err(SemanticNameError::TooLong { kind }); + } + if !value.as_bytes()[0].is_ascii_lowercase() { + return Err(SemanticNameError::InvalidStart { kind }); + } + for (byte_index, character) in value.char_indices() { + if !(character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + || character == '.') + { + return Err(SemanticNameError::InvalidCharacter { + kind, + byte_index, + character, + }); + } + } + Ok(()) +} + +/// A stable target. It intentionally contains no render indices, GPUI entity +/// IDs, focus handles, or other ephemeral UI identities. +#[derive( + Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SemanticTarget { + App, + Screen { + screen: ScreenId, + }, + Region { + region: RegionId, + }, + Project { + canonical_root: String, + }, + Task { + task_id: String, + }, + TimelineItem { + task_id: String, + item_id: String, + }, + Annotation { + task_id: String, + item_id: String, + annotation_id: String, + }, + Permission { + request_id: String, + }, + Question { + request_id: String, + question_id: String, + }, + QueueItem { + task_id: String, + queue_id: String, + }, + DraftAttachment { + draft_id: u64, + }, + Setting { + key: String, + }, + MenuItem { + menu_id: String, + item_id: String, + }, +} + +impl SemanticTarget { + pub fn task_id(&self) -> Option<&str> { + match self { + Self::Task { task_id } + | Self::TimelineItem { task_id, .. } + | Self::Annotation { task_id, .. } + | Self::QueueItem { task_id, .. } => Some(task_id), + _ => None, + } + } + + /// Human-readable display path for logs/UI only. Executors must continue to + /// use the structural enum rather than parsing this string. + pub fn display_path(&self) -> String { + match self { + Self::App => "app".into(), + Self::Screen { screen } => format!("screen:{screen}"), + Self::Region { region } => format!("region:{region}"), + Self::Project { canonical_root } => format!("project:{canonical_root}"), + Self::Task { task_id } => format!("task:{task_id}"), + Self::TimelineItem { task_id, item_id } => { + format!("task:{task_id}/timeline:{item_id}") + } + Self::Annotation { + task_id, + item_id, + annotation_id, + } => format!("task:{task_id}/timeline:{item_id}/annotation:{annotation_id}"), + Self::Permission { request_id } => format!("permission:{request_id}"), + Self::Question { + request_id, + question_id, + } => format!("request:{request_id}/question:{question_id}"), + Self::QueueItem { task_id, queue_id } => { + format!("task:{task_id}/queue:{queue_id}") + } + Self::DraftAttachment { draft_id } => format!("draft_attachment:{draft_id}"), + Self::Setting { key } => format!("setting:{key}"), + Self::MenuItem { menu_id, item_id } => format!("menu:{menu_id}/item:{item_id}"), + } + } +} + +#[derive( + Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RevisionDomain { + Global, + Task { + task_id: String, + }, + Project { + canonical_root: String, + }, + Timeline { + task_id: String, + }, + TimelineItem { + task_id: String, + item_id: String, + }, + Draft { + task_id: String, + }, + Setting { + key: String, + }, + Permission { + request_id: String, + }, + Question { + request_id: String, + question_id: String, + }, + QueueItem { + task_id: String, + queue_id: String, + }, + Target { + target: SemanticTarget, + }, +} + +impl RevisionDomain { + /// Collapses the structural aliases that can otherwise give one semantic + /// object two independent revision counters. + pub fn canonicalized(&self) -> Self { + match self { + Self::Target { target } => match target { + SemanticTarget::App => Self::Global, + SemanticTarget::Project { canonical_root } => Self::Project { + canonical_root: canonical_root.clone(), + }, + SemanticTarget::Task { task_id } => Self::Task { + task_id: task_id.clone(), + }, + SemanticTarget::TimelineItem { task_id, item_id } => Self::TimelineItem { + task_id: task_id.clone(), + item_id: item_id.clone(), + }, + SemanticTarget::Permission { request_id } => Self::Permission { + request_id: request_id.clone(), + }, + SemanticTarget::Question { + request_id, + question_id, + } => Self::Question { + request_id: request_id.clone(), + question_id: question_id.clone(), + }, + SemanticTarget::QueueItem { task_id, queue_id } => Self::QueueItem { + task_id: task_id.clone(), + queue_id: queue_id.clone(), + }, + SemanticTarget::Setting { key } => Self::Setting { key: key.clone() }, + _ => self.clone(), + }, + _ => self.clone(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RevisionChange { + pub state_revision: u64, + pub target_revision: u64, +} + +/// Tracks the coherent global state revision separately from action-specific +/// target revisions. +#[derive(Clone, Debug, Default)] +pub struct RevisionTracker { + state_revision: u64, + domains: BTreeMap, +} + +impl RevisionTracker { + pub fn new() -> Self { + Self::default() + } + + pub fn state_revision(&self) -> u64 { + self.state_revision + } + + pub fn revision(&self, domain: &RevisionDomain) -> u64 { + let domain = domain.canonicalized(); + if domain == RevisionDomain::Global { + self.state_revision + } else { + self.domains.get(&domain).copied().unwrap_or(0) + } + } + + pub fn bump(&mut self, domain: RevisionDomain) -> RevisionChange { + let domain = domain.canonicalized(); + self.state_revision = self.state_revision.saturating_add(1); + let target_revision = if domain == RevisionDomain::Global { + self.state_revision + } else { + let revision = self.domains.entry(domain).or_default(); + *revision = revision.saturating_add(1); + *revision + }; + RevisionChange { + state_revision: self.state_revision, + target_revision, + } + } + + pub fn check(&self, domain: &RevisionDomain, expected: u64) -> Result<(), StaleRevision> { + let domain = domain.canonicalized(); + let actual = self.revision(&domain); + if actual == expected { + Ok(()) + } else { + Err(StaleRevision { + domain, + expected, + actual, + }) + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("revision for {domain:?} changed from {expected} to {actual}")] +pub struct StaleRevision { + pub domain: RevisionDomain, + pub expected: u64, + pub actual: u64, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InsertionSummary { + pub target: SemanticTarget, + pub byte_offset: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_byte_range: Option>, + pub draft_revision: u64, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ViewportSummary { + pub region: RegionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_visible: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_visible: Option, + pub visible_count: usize, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionAvailabilitySummary { + pub action_id: ActionId, + pub availability: Availability, +} + +#[derive(Clone, Debug, Default, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SemanticNodeState { + pub selected: bool, + pub expanded: bool, + pub disabled: bool, + pub busy: bool, + /// Small, already-redacted semantic attributes. Never place arbitrary + /// application state or credentials here. + pub attributes: BTreeMap, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticNode { + pub target: SemanticTarget, + pub kind: SemanticKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub state: SemanticNodeState, + #[serde(default)] + pub available_actions: Vec, + #[serde(default)] + pub children: Vec, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq)] +pub struct SemanticSnapshot { + pub schema_version: u16, + pub state_revision: u64, + pub event_cursor: u64, + pub screen: ScreenId, + pub active_region: RegionId, + pub selection: Option, + pub insertion: Option, + pub viewport: ViewportSummary, + pub stream_follow: bool, + pub roots: Vec, +} + +impl SemanticSnapshot { + /// Rejects secret-shaped attribute keys before a snapshot crosses the + /// controller boundary. Projection labels still remain the host's + /// responsibility and should contain only visible product text. + pub fn validate_redaction(&self) -> Result<(), SnapshotRedactionError> { + for root in &self.roots { + validate_node_attributes(root)?; + } + Ok(()) + } +} + +impl Serialize for SemanticSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::Error as _; + + self.validate_redaction().map_err(S::Error::custom)?; + #[derive(Serialize)] + #[serde(deny_unknown_fields)] + struct Wire<'a> { + schema_version: u16, + state_revision: u64, + event_cursor: u64, + screen: &'a ScreenId, + active_region: &'a RegionId, + #[serde(skip_serializing_if = "Option::is_none")] + selection: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + insertion: &'a Option, + viewport: &'a ViewportSummary, + stream_follow: bool, + roots: &'a [SemanticNode], + } + + Wire { + schema_version: self.schema_version, + state_revision: self.state_revision, + event_cursor: self.event_cursor, + screen: &self.screen, + active_region: &self.active_region, + selection: &self.selection, + insertion: &self.insertion, + viewport: &self.viewport, + stream_follow: self.stream_follow, + roots: &self.roots, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SemanticSnapshot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + schema_version: u16, + state_revision: u64, + event_cursor: u64, + screen: ScreenId, + active_region: RegionId, + #[serde(default)] + selection: Option, + #[serde(default)] + insertion: Option, + viewport: ViewportSummary, + stream_follow: bool, + #[serde(default)] + roots: Vec, + } + + let wire = Wire::deserialize(deserializer)?; + let snapshot = Self { + schema_version: wire.schema_version, + state_revision: wire.state_revision, + event_cursor: wire.event_cursor, + screen: wire.screen, + active_region: wire.active_region, + selection: wire.selection, + insertion: wire.insertion, + viewport: wire.viewport, + stream_follow: wire.stream_follow, + roots: wire.roots, + }; + snapshot.validate_redaction().map_err(de::Error::custom)?; + Ok(snapshot) + } +} + +fn validate_node_attributes(node: &SemanticNode) -> Result<(), SnapshotRedactionError> { + for (key, value) in &node.state.attributes { + validate_attribute_value(key, value)?; + } + for child in &node.children { + validate_node_attributes(child)?; + } + Ok(()) +} + +fn validate_attribute_value(key: &str, value: &Value) -> Result<(), SnapshotRedactionError> { + if semantic_field_is_sensitive(key) { + return Err(SnapshotRedactionError::SensitiveAttribute(key.to_owned())); + } + match value { + Value::Object(object) => { + for (nested_key, nested_value) in object { + validate_attribute_value(nested_key, nested_value)?; + } + } + Value::Array(values) => { + for value in values { + validate_attribute_value(key, value)?; + } + } + _ => {} + } + Ok(()) +} + +fn semantic_field_is_sensitive(field: &str) -> bool { + let normalized = field.to_ascii_lowercase(); + [ + "password", + "passwd", + "secret", + "token", + "credential", + "authorization", + "cookie", + "oauth", + "callback", + "private_key", + "api_key", + "header", + "environment", + "permission_payload", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SnapshotRedactionError { + #[error("semantic snapshot attribute {0:?} appears sensitive")] + SensitiveAttribute(String), +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticSelection { + pub region: RegionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor: Option, + pub explicit: bool, + pub observed_revision: u64, +} + +impl SemanticSelection { + pub fn new(region: RegionId, target: Option, observed_revision: u64) -> Self { + Self { + region, + target, + anchor: None, + explicit: false, + observed_revision, + } + } + + pub fn explicit(region: RegionId, target: SemanticTarget, observed_revision: u64) -> Self { + Self { + region, + target: Some(target), + anchor: None, + explicit: true, + observed_revision, + } + } + + pub fn select(&mut self, target: SemanticTarget, observed_revision: u64, explicit: bool) { + self.target = Some(target); + self.explicit = explicit; + self.observed_revision = observed_revision; + } + + pub fn clear(&mut self, observed_revision: u64) { + self.target = None; + self.anchor = None; + self.explicit = false; + self.observed_revision = observed_revision; + } +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegionReturnReason { + Overlay, + Dialog, + EnterComposer, + RegionNavigation, + Other, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegionReturnFrame { + pub token: RegionReturnToken, + pub screen: ScreenId, + pub region: RegionId, + pub target: Option, + pub reason: RegionReturnReason, + pub relevant_revision: u64, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RegionReturnToken { + stack_id: uuid::Uuid, + sequence: u64, +} + +impl RegionReturnToken { + pub fn get(self) -> u64 { + self.sequence + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RegionReturnPush { + pub token: RegionReturnToken, + pub truncated_oldest: bool, +} + +#[derive(Debug)] +pub struct RegionReturnStack { + capacity: usize, + stack_id: uuid::Uuid, + next_token: u64, + frames: VecDeque, +} + +impl RegionReturnStack { + pub fn new(capacity: usize) -> Result { + if capacity == 0 { + return Err(RegionReturnStackError::ZeroCapacity); + } + Ok(Self { + capacity, + stack_id: uuid::Uuid::new_v4(), + next_token: 1, + frames: VecDeque::with_capacity(capacity), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn push( + &mut self, + screen: ScreenId, + region: RegionId, + target: Option, + reason: RegionReturnReason, + relevant_revision: u64, + ) -> RegionReturnPush { + let token = RegionReturnToken { + stack_id: self.stack_id, + sequence: self.next_token, + }; + self.next_token = self.next_token.saturating_add(1); + let truncated_oldest = self.frames.len() == self.capacity; + if truncated_oldest { + self.frames.pop_front(); + } + self.frames.push_back(RegionReturnFrame { + token, + screen, + region, + target, + reason, + relevant_revision, + }); + RegionReturnPush { + token, + truncated_oldest, + } + } + + /// Pops only the exact top frame. It never searches through and removes an + /// unrelated lower overlay/composer return point. + pub fn pop_exact( + &mut self, + token: RegionReturnToken, + ) -> Result { + let top = self.frames.back().ok_or(RegionReturnStackError::Empty)?; + if top.token != token { + return Err(RegionReturnStackError::TokenMismatch { + expected_top: top.token, + requested: token, + }); + } + self.frames.pop_back().ok_or(RegionReturnStackError::Empty) + } + + pub fn top(&self) -> Option<&RegionReturnFrame> { + self.frames.back() + } + + pub fn len(&self) -> usize { + self.frames.len() + } + + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum RegionReturnStackError { + #[error("region return stack capacity must be greater than zero")] + ZeroCapacity, + #[error("region return stack is empty")] + Empty, + #[error("region return token {requested:?} does not match top frame {expected_top:?}")] + TokenMismatch { + expected_top: RegionReturnToken, + requested: RegionReturnToken, + }, +} + +/// Eligibility flags used to derive a navigable projection from a raw backing +/// collection. This keeps hidden/internal/zero-presence rows out of semantic +/// navigation while allowing off-screen but revealable objects. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SemanticPresence { + pub internal_only: bool, + pub filtered_out: bool, + pub revealable: bool, + pub nonzero_presence: bool, +} + +impl SemanticPresence { + pub const fn navigable(self) -> bool { + !self.internal_only && !self.filtered_out && self.revealable && self.nonzero_presence + } +} + +pub fn navigable_targets<'a>( + candidates: impl IntoIterator, +) -> Vec { + candidates + .into_iter() + .filter(|(_, presence)| presence.navigable()) + .map(|(target, _)| target.clone()) + .collect() +} + +/// Reconciles a removed selection through stable IDs. It prefers the next item +/// at the removed item's former position, then the previous surviving item. +pub fn reconcile_removed_selection( + previous_order: &[SemanticTarget], + current_order: &[SemanticTarget], + selected: &SemanticTarget, +) -> Option { + if current_order.contains(selected) { + return Some(selected.clone()); + } + let old_index = previous_order + .iter() + .position(|target| target == selected)?; + + for distance in 1..=previous_order.len() { + if let Some(candidate) = previous_order.get(old_index + distance) + && current_order.contains(candidate) + { + return Some(candidate.clone()); + } + if let Some(candidate_index) = old_index.checked_sub(distance) + && let Some(candidate) = previous_order.get(candidate_index) + && current_order.contains(candidate) + { + return Some(candidate.clone()); + } + } + None +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SemanticEventKind { + ScreenChanged, + RegionChanged, + SemanticSelectionChanged, + TargetUpdated, + TargetRemoved, + TaskOpened, + ActionStarted, + ActionCompleted, + ControllerPolicyChanged, + CodeModeStateChanged, +} + +/// Terminal subset accepted by an `action_completed` event. `Accepted` is +/// deliberately excluded because asynchronous acceptance is not completion. +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalActionStatus { + AcceptedTerminal, + Completed, + Failed, + Cancelled, +} + +impl TryFrom for TerminalActionStatus { + type Error = NonTerminalActionStatus; + + fn try_from(status: ActionStatus) -> Result { + match status { + ActionStatus::AcceptedTerminal => Ok(Self::AcceptedTerminal), + ActionStatus::Completed => Ok(Self::Completed), + ActionStatus::Failed => Ok(Self::Failed), + ActionStatus::Cancelled => Ok(Self::Cancelled), + ActionStatus::Accepted => Err(NonTerminalActionStatus), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +#[error("accepted is not a terminal action status")] +pub struct NonTerminalActionStatus; + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SemanticEventPayload { + ScreenChanged { + screen: ScreenId, + }, + RegionChanged { + region: RegionId, + }, + SemanticSelectionChanged { + selection: SemanticSelection, + }, + TargetUpdated { + target: SemanticTarget, + target_revision: u64, + }, + TargetRemoved { + target: SemanticTarget, + }, + TaskOpened { + task_id: String, + }, + ActionStarted { + invocation_id: InvocationId, + action_id: ActionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, + ActionCompleted { + invocation_id: InvocationId, + action_id: ActionId, + status: TerminalActionStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + operation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + ControllerPolicyChanged { + controller_access: UiControllerAccess, + policy_epoch: u64, + }, + CodeModeStateChanged { + authority: ProgrammabilityAuthority, + }, +} + +impl SemanticEventPayload { + pub const fn kind(&self) -> SemanticEventKind { + match self { + Self::ScreenChanged { .. } => SemanticEventKind::ScreenChanged, + Self::RegionChanged { .. } => SemanticEventKind::RegionChanged, + Self::SemanticSelectionChanged { .. } => SemanticEventKind::SemanticSelectionChanged, + Self::TargetUpdated { .. } => SemanticEventKind::TargetUpdated, + Self::TargetRemoved { .. } => SemanticEventKind::TargetRemoved, + Self::TaskOpened { .. } => SemanticEventKind::TaskOpened, + Self::ActionStarted { .. } => SemanticEventKind::ActionStarted, + Self::ActionCompleted { .. } => SemanticEventKind::ActionCompleted, + Self::ControllerPolicyChanged { .. } => SemanticEventKind::ControllerPolicyChanged, + Self::CodeModeStateChanged { .. } => SemanticEventKind::CodeModeStateChanged, + } + } + + pub fn target(&self) -> Option<&SemanticTarget> { + match self { + Self::SemanticSelectionChanged { selection } => selection.target.as_ref(), + Self::TargetUpdated { target, .. } | Self::TargetRemoved { target } => Some(target), + Self::ActionStarted { target, .. } => target.as_ref(), + _ => None, + } + } + + pub fn task_id(&self) -> Option<&str> { + match self { + Self::TaskOpened { task_id } => Some(task_id), + _ => self.target().and_then(SemanticTarget::task_id), + } + } + + pub fn action_id(&self) -> Option<&ActionId> { + match self { + Self::ActionStarted { action_id, .. } | Self::ActionCompleted { action_id, .. } => { + Some(action_id) + } + _ => None, + } + } + + pub fn invocation_id(&self) -> Option { + match self { + Self::ActionStarted { invocation_id, .. } + | Self::ActionCompleted { invocation_id, .. } => Some(*invocation_id), + _ => None, + } + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticEvent { + pub schema_version: u16, + pub cursor: u64, + pub state_revision: u64, + pub payload: SemanticEventPayload, +} + +#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SemanticEventPredicate { + pub kind: Option, + pub target: Option, + pub task_id: Option, + pub action_id: Option, + pub invocation_id: Option, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EventWaitRequest { + pub predicate: SemanticEventPredicate, + pub after_cursor: u64, + pub timeout_ms: u64, +} + +impl EventWaitRequest { + pub fn new( + predicate: SemanticEventPredicate, + after_cursor: u64, + timeout_ms: u64, + ) -> Result { + if timeout_ms == 0 || timeout_ms > MAX_EVENT_WAIT_TIMEOUT_MS { + return Err(EventWaitRequestError::InvalidTimeout { + maximum_ms: MAX_EVENT_WAIT_TIMEOUT_MS, + requested_ms: timeout_ms, + }); + } + Ok(Self { + predicate, + after_cursor, + timeout_ms, + }) + } +} + +impl<'de> Deserialize<'de> for EventWaitRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + predicate: SemanticEventPredicate, + after_cursor: u64, + timeout_ms: u64, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.predicate, wire.after_cursor, wire.timeout_ms).map_err(de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum EventWaitRequestError { + #[error( + "events.wait timeout {requested_ms} ms must be between 1 and {maximum_ms} ms inclusive" + )] + InvalidTimeout { maximum_ms: u64, requested_ms: u64 }, +} + +impl SemanticEventPredicate { + pub fn matches(&self, event: &SemanticEvent) -> bool { + self.kind.is_none_or(|kind| event.payload.kind() == kind) + && self + .target + .as_ref() + .is_none_or(|target| event.payload.target() == Some(target)) + && self + .task_id + .as_deref() + .is_none_or(|task_id| event.payload.task_id() == Some(task_id)) + && self + .action_id + .as_ref() + .is_none_or(|action_id| event.payload.action_id() == Some(action_id)) + && self + .invocation_id + .is_none_or(|invocation_id| event.payload.invocation_id() == Some(invocation_id)) + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticEventPage { + pub events: Vec, + /// Last cursor inspected, including nonmatching events. + pub scanned_through: u64, + pub has_more: bool, +} + +#[derive(Clone, Debug)] +pub struct SemanticEventRing { + capacity: usize, + next_cursor: u64, + events: VecDeque, +} + +impl SemanticEventRing { + pub fn new(capacity: usize) -> Result { + if capacity == 0 { + return Err(SemanticEventRingError::ZeroCapacity); + } + Ok(Self { + capacity, + next_cursor: 1, + events: VecDeque::with_capacity(capacity), + }) + } + + pub fn push(&mut self, state_revision: u64, payload: SemanticEventPayload) -> SemanticEvent { + let event = SemanticEvent { + schema_version: crate::SCHEMA_VERSION, + cursor: self.next_cursor, + state_revision, + payload, + }; + self.next_cursor = self.next_cursor.saturating_add(1); + if self.events.len() == self.capacity { + self.events.pop_front(); + } + self.events.push_back(event.clone()); + event + } + + pub fn latest_cursor(&self) -> u64 { + self.events.back().map_or(0, |event| event.cursor) + } + + pub fn oldest_cursor(&self) -> Option { + self.events.front().map(|event| event.cursor) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub fn events_after( + &self, + after: u64, + predicate: &SemanticEventPredicate, + limit: usize, + ) -> Result { + if limit == 0 { + return Err(SemanticEventRingError::ZeroLimit); + } + let latest = self.latest_cursor(); + if after > latest { + return Err(SemanticEventRingError::FutureCursor { + latest, + requested_after: after, + }); + } + if let Some(oldest) = self.oldest_cursor() + && after.saturating_add(1) < oldest + { + return Err(SemanticEventRingError::ResyncRequired { + oldest_available: oldest, + requested_after: after, + }); + } + + let mut events = Vec::new(); + let mut scanned_through = after; + let mut has_more = false; + for event in self.events.iter().filter(|event| event.cursor > after) { + if events.len() == limit { + has_more = true; + break; + } + scanned_through = event.cursor; + if predicate.matches(event) { + events.push(event.clone()); + } + } + if !has_more { + scanned_through = latest.max(scanned_through); + } + Ok(SemanticEventPage { + events, + scanned_through, + has_more, + }) + } +} + +impl Default for SemanticEventRing { + fn default() -> Self { + Self::new(DEFAULT_SEMANTIC_EVENT_CAPACITY) + .expect("default semantic event ring capacity is nonzero") + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum SemanticEventRingError { + #[error("semantic event ring capacity must be greater than zero")] + ZeroCapacity, + #[error("semantic event page limit must be greater than zero")] + ZeroLimit, + #[error( + "event cursor {requested_after} is older than retained history; oldest available is {oldest_available}" + )] + ResyncRequired { + oldest_available: u64, + requested_after: u64, + }, + #[error("event cursor {requested_after} is ahead of latest cursor {latest}")] + FutureCursor { latest: u64, requested_after: u64 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn task(id: &str) -> SemanticTarget { + SemanticTarget::Task { task_id: id.into() } + } + + #[test] + fn semantic_targets_are_structural_hashable_wire_values() { + let target = SemanticTarget::Annotation { + task_id: "task-1".into(), + item_id: "item-2".into(), + annotation_id: "annotation-3".into(), + }; + let value = serde_json::to_value(&target).unwrap(); + assert_eq!(value["kind"], "annotation"); + assert_eq!(value["task_id"], "task-1"); + assert_eq!( + serde_json::from_value::(value).unwrap(), + target + ); + + let mut targets = std::collections::HashSet::new(); + targets.insert(target.clone()); + assert!(targets.contains(&target)); + } + + #[test] + fn relevant_revision_changes_stale_only_the_relevant_precondition() { + let task_domain = RevisionDomain::Task { + task_id: "task-1".into(), + }; + let streaming_domain = RevisionDomain::TimelineItem { + task_id: "task-2".into(), + item_id: "assistant".into(), + }; + let mut revisions = RevisionTracker::new(); + let first = revisions.bump(task_domain.clone()); + assert_eq!(first.target_revision, 1); + + revisions.bump(streaming_domain); + assert!(revisions.check(&task_domain, 1).is_ok()); + + revisions.bump(task_domain.clone()); + assert_eq!(revisions.check(&task_domain, 1).unwrap_err().actual, 2); + } + + #[test] + fn revision_domains_canonicalize_structural_target_aliases() { + let task_domain = RevisionDomain::Task { + task_id: "task-1".into(), + }; + let target_alias = RevisionDomain::Target { + target: SemanticTarget::Task { + task_id: "task-1".into(), + }, + }; + let mut revisions = RevisionTracker::new(); + revisions.bump(task_domain); + assert_eq!(revisions.revision(&target_alias), 1); + assert!(revisions.check(&target_alias, 0).is_err()); + } + + #[test] + fn navigation_projection_excludes_hidden_internal_and_zero_presence_rows() { + let visible = task("visible"); + let hidden = task("hidden"); + let internal = task("internal"); + let zero_height = task("zero"); + let projected = navigable_targets([ + ( + &visible, + SemanticPresence { + internal_only: false, + filtered_out: false, + revealable: true, + nonzero_presence: true, + }, + ), + ( + &hidden, + SemanticPresence { + internal_only: false, + filtered_out: true, + revealable: true, + nonzero_presence: true, + }, + ), + ( + &internal, + SemanticPresence { + internal_only: true, + filtered_out: false, + revealable: true, + nonzero_presence: true, + }, + ), + ( + &zero_height, + SemanticPresence { + internal_only: false, + filtered_out: false, + revealable: true, + nonzero_presence: false, + }, + ), + ]); + assert_eq!(projected, vec![visible]); + } + + #[test] + fn selection_identity_survives_insertion_and_has_deterministic_removal_neighbor() { + let a = task("a"); + let b = task("b"); + let c = task("c"); + let inserted = task("inserted"); + assert_eq!( + reconcile_removed_selection( + &[a.clone(), b.clone(), c.clone()], + &[inserted, a.clone(), b.clone(), c.clone()], + &b, + ), + Some(b.clone()) + ); + assert_eq!( + reconcile_removed_selection( + &[a.clone(), b.clone(), c.clone()], + &[a.clone(), c.clone()], + &b, + ), + Some(c) + ); + assert_eq!( + reconcile_removed_selection(std::slice::from_ref(&a), &[], &a), + None + ); + } + + #[test] + fn bounded_event_ring_is_sequence_safe_and_requires_resync_after_overflow() { + let mut ring = SemanticEventRing::new(2).unwrap(); + ring.push( + 1, + SemanticEventPayload::TaskOpened { + task_id: "one".into(), + }, + ); + let cursor = ring.latest_cursor(); + ring.push( + 2, + SemanticEventPayload::TaskOpened { + task_id: "two".into(), + }, + ); + ring.push( + 3, + SemanticEventPayload::TaskOpened { + task_id: "three".into(), + }, + ); + + assert!(matches!( + ring.events_after(0, &SemanticEventPredicate::default(), 10), + Err(SemanticEventRingError::ResyncRequired { .. }) + )); + let page = ring + .events_after( + cursor, + &SemanticEventPredicate { + kind: Some(SemanticEventKind::TaskOpened), + task_id: Some("three".into()), + ..SemanticEventPredicate::default() + }, + 10, + ) + .unwrap(); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].cursor, 3); + assert_eq!(page.scanned_through, 3); + } + + #[test] + fn event_cursor_from_describe_closes_query_then_wait_race() { + let mut ring = SemanticEventRing::new(8).unwrap(); + let snapshot_cursor = ring.latest_cursor(); + ring.push( + 1, + SemanticEventPayload::TaskOpened { + task_id: "task-1".into(), + }, + ); + let page = ring + .events_after(snapshot_cursor, &SemanticEventPredicate::default(), 8) + .unwrap(); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].payload.task_id(), Some("task-1")); + } + + #[test] + fn region_return_stack_is_bounded_and_lifo_for_surviving_frames() { + let mut stack = RegionReturnStack::new(2).unwrap(); + let mut pushes = Vec::new(); + for region in ["sidebar", "transcript", "settings"] { + pushes.push(stack.push( + ScreenId::chat(), + RegionId::parse(region).unwrap(), + None, + RegionReturnReason::RegionNavigation, + 1, + )); + } + assert_eq!(stack.len(), 2); + assert!(pushes[2].truncated_oldest); + assert!(matches!( + stack.pop_exact(pushes[1].token), + Err(RegionReturnStackError::TokenMismatch { .. }) + )); + assert_eq!( + stack.pop_exact(pushes[2].token).unwrap().region.as_str(), + "settings" + ); + assert_eq!( + stack.pop_exact(pushes[1].token).unwrap().region.as_str(), + "transcript" + ); + } + + #[test] + fn region_return_tokens_cannot_cross_stacks() { + let mut first = RegionReturnStack::new(2).unwrap(); + let mut second = RegionReturnStack::new(2).unwrap(); + let first_token = first + .push( + ScreenId::chat(), + RegionId::sidebar(), + None, + RegionReturnReason::Overlay, + 1, + ) + .token; + second.push( + ScreenId::chat(), + RegionId::sidebar(), + None, + RegionReturnReason::Overlay, + 1, + ); + assert!(matches!( + second.pop_exact(first_token), + Err(RegionReturnStackError::TokenMismatch { .. }) + )); + } + + #[test] + fn action_completed_and_wait_request_are_validated_on_wire() { + assert!(TerminalActionStatus::try_from(ActionStatus::Accepted).is_err()); + assert!( + serde_json::from_value::(serde_json::json!("accepted")).is_err() + ); + let operation_id = OperationId::new(); + let completion = SemanticEvent { + schema_version: crate::SCHEMA_VERSION, + cursor: 8, + state_revision: 13, + payload: SemanticEventPayload::ActionCompleted { + invocation_id: InvocationId::new(), + action_id: ActionId::parse("settings.set_theme").unwrap(), + status: TerminalActionStatus::Completed, + operation_id: Some(operation_id), + result: Some(serde_json::json!({ + "local_applied": true, + "durable": true + })), + error: None, + }, + }; + let wire = serde_json::to_value(&completion).unwrap(); + assert_eq!(wire["payload"]["kind"], "action_completed"); + assert_eq!(wire["payload"]["operation_id"], operation_id.to_string()); + assert_eq!(wire["payload"]["result"]["durable"], true); + assert!(wire["payload"].get("error").is_none()); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + completion + ); + assert!(EventWaitRequest::new(SemanticEventPredicate::default(), 0, 60_000).is_ok()); + assert!(EventWaitRequest::new(SemanticEventPredicate::default(), 0, 60_001).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "predicate": {}, + "after_cursor": 0, + "timeout_ms": 0 + })) + .is_err() + ); + } + + #[test] + fn snapshot_wire_boundary_rejects_secret_shaped_attributes() { + let snapshot = SemanticSnapshot { + schema_version: crate::SCHEMA_VERSION, + state_revision: 1, + event_cursor: 0, + screen: ScreenId::chat(), + active_region: RegionId::transcript(), + selection: None, + insertion: None, + viewport: ViewportSummary { + region: RegionId::transcript(), + first_visible: None, + last_visible: None, + visible_count: 1, + }, + stream_follow: true, + roots: vec![SemanticNode { + target: SemanticTarget::App, + kind: SemanticKind::parse("application").unwrap(), + label: None, + state: SemanticNodeState { + attributes: BTreeMap::from([( + "oauth_token".into(), + Value::String("sentinel-secret".into()), + )]), + ..SemanticNodeState::default() + }, + available_actions: Vec::new(), + children: Vec::new(), + }], + }; + let error = serde_json::to_string(&snapshot).unwrap_err().to_string(); + assert!(!error.contains("sentinel-secret")); + assert!(error.contains("oauth_token")); + } +} diff --git a/apps/maple-agent/docs/programmable-harness-distillation.md b/apps/maple-agent/docs/programmable-harness-distillation.md new file mode 100644 index 000000000..d323cf93a --- /dev/null +++ b/apps/maple-agent/docs/programmable-harness-distillation.md @@ -0,0 +1,354 @@ +# Programmable harness: distillation of the retired prototype + +> Written 2026-09-09 from a read of benthecarman/maple-gpui PR #2 +> (`programmable-harness`, head 653b959) before that repository was deleted. +> It reconstructs the desktop-side modules that were **not** ported into this +> monorepo (`app/src/harness/*`, `app/src/desktop/controller_runtime.rs`, +> `app/src/backend/code_mode.rs`, the Python `maple_gpui` SDK) so that a +> production design can cite the concepts without the code. Companion files: +> `programmable-harness.md` (the original normative design) and +> `programmable-harness-preview-pr.md` (the original PR description). What +> *is* ported lives in `crates/maple-harness/`; see its module docs for which +> of the tiers below each file re-expresses. + +## 0. Crate topology and the one-sentence thesis + +``` +crates/maple-harness GPUI-free wire nouns: ActionId/Descriptor/Call/Response, + policy, audit ring, semantic targets/events, registry. +crates/maple-code-mode GPUI-free Python worker service: kernels, processes, + protocol framing, authority, controller transport trait. +app/src/harness/* Desktop ingress: catalog, ActionHost, controller bridge, + keymap compiler, palette, which-key, provenance, vim. +app/src/desktop/* The GPUI root that implements the host traits. +app/src/backend/code_mode.rs App-side ownership of the worker service. +``` + +**Thesis:** every state change in the app is a *descriptor-validated semantic +action* that passes through exactly one `ActionHost::invoke_observed` call, +carrying an unforgeable `TrustedInvocation` that names its actor, transport, +controller-access lease and policy epoch. Everything else (keymap, palette, +which-key, Python SDK) is a *projection or transport*, never an executor. + +## 1. Core nouns + +```rust +pub enum InvocationPolicy { ControllerCallable, HumanOnly } +pub enum InvocationActor { DirectUser, Model, UserCode, Internal } +pub enum InvocationTransport { Pointer, Keybinding, CommandPalette, Python, Macro, GeneratedUi, Internal } +pub enum UiControllerAccess { Off, ReadOnly, FullAccess } // default Off +pub enum PythonCodeMode { Off, DeveloperPreview } // default Off +pub enum ActionEffect { Observe, Navigate, MutateMaple, ExternalEffect } +pub enum Recoverability { Ephemeral, Reversible, Irreversible } +pub enum ActionStatus { Accepted, AcceptedTerminal, Completed, Failed, Cancelled } +pub enum ActionErrorCode { UnknownAction, InvalidArguments, NotApplicable, Unavailable, + PolicyDenied, StaleTarget, Cancelled, Failed } +``` + +The authorization function is a pure five-argument decision: + +```rust +pub fn authorize(policy, effect, actor, transport, controller_access) -> PolicyDecision { + let direct = actor == DirectUser + && matches!(transport, Pointer | Keybinding | CommandPalette); + if actor == DirectUser && !direct { Denied(InvalidDirectUserOrigin) } + if policy == HumanOnly { return if direct { Allowed } else { Denied(HumanOnly) } } + if direct { return Allowed } + match controller_access { + Off => Denied(ControllerOff), + ReadOnly => match effect { Observe|Navigate => Allowed, + MutateMaple|ExternalEffect => Denied(ReadOnlyMutation) }, + FullAccess => Allowed, + } +} +``` + +`TrustedInvocation` bundles: invocation id, optional `TaskIdentity`, +`ProgramId`, `RunId`, `ExecutionId`, kernel generation, actor, transport, +`controller_access`, `policy_epoch`, `CancellationToken`, `ActionBudget`, and +(for DirectUser) an opaque window-issued provenance token. `check_active` +rejects cancelled or stale-epoch leases; `admit_call` additionally consumes +budget. **One SDK request got `ActionBudget::new(1)`**: a Python call could +invoke at most one semantic action. + +Audit ring: capacity 512, lifecycle `record → accept → mark_cancel_requested → +finish`. `CompletedAfterCancelRequest` exists for irreversible actions that +finish after a cancel request: cancellation is a request, never fabricated +completion. + +## 2. `harness/host.rs`: the single action host + +```rust +pub(crate) trait ActionExecutor { + fn executor_identity(&self) -> &'static str; // test proof of a single impl + fn availability(&self, &ActionDescriptor, &ActionCall) -> Availability; + fn execute(&mut self, &ActionDescriptor, &ActionCall, &TrustedInvocation, + &mut ActionExecutionLease) -> ActionResponse; +} +pub(crate) struct ActionHost { + registry, audit, authority: ProgrammabilityAuthority, policy_epoch: u64, + direct_user_issuer_id: Option, + admission: ActionAdmissionState, // Active | Suspended | Quitting + operations: HashMap, +} +``` + +Bounds: `MAX_INFLIGHT_ACTIONS = 256` (under the 512 audit ring so terminal +history is always recordable), `RESERVED_LIFECYCLE_OPERATION_SLOTS = 16` +(`run.stop`, `code_mode.stop/reset/restart/…`, `account.sign_out` keep the +full 256 while ordinary actions get 240: a saturated program budget can never +block the human's ability to stop it), `MAX_RECENT_AUDIT_SNAPSHOT = 64`. + +Move-only completion capability: an async executor must +`lease.claim_completion()` and return `Accepted` carrying the leased +`operation_id`; `finish_async_action(token, response)` compares nine fields +including a secret before transitioning the audit exactly once. + +The ordered gate (`invoke_inner`), to be re-implemented verbatim: + +1. registry lookup → `UnknownAction`; +2. admission state: `Suspended` → `dispatcher_busy`, `Quitting` → `app_quitting`; +3. `validate_arguments` → `InvalidArguments`; +4. `validate_precondition` → `StaleTarget`; +5. DirectUser issuer must equal the host's registered issuer; +6. `check_active(policy_epoch)` → `Cancelled` or `PolicyDenied`; +7. live re-check: stored `controller_access != current` → denied; +8. `authorize` → `PolicyDenied`; +9. `availability` → `Disabled{code,message}`; +10. operation-table admission limit; +11. `admit_call` consumes budget; +12. audit `Started` with `descriptor.audit.redact(arguments)`; +13. execute. + +Post-execute normalisation: mismatched ids, schema-invalid results, `Accepted` +without a claimed lease, a terminal-host descriptor returning a non-terminal +status, and duplicate identities all become `Failed`. A claimed lease with a +non-`Accepted` response cancels the invocation so spawned work is revoked. + +Authority is a lease: `advance_authority` bumps the epoch and **revokes every +retained non-DirectUser operation even when the new tuple is broader**. +Shutdown: `begin_quitting()` closes admission, `cancel_all_operations()`, +then `terminalize_shutdown_operations()` is the only way to force-end audit +records. + +## 3. `harness/controller.rs`: the bounded Code Mode bridge + +Constants: queue depth 64, drain batch 16 per UI tick, enqueue timeout 250 ms, +response timeout 30 s, event-wait margin 1 s, max event wait 60 s, max 64 +waiters, response limit 512 KiB, snapshot text limit 128 KiB, semantic page +200, list page 100, cursor capacity 512 with 5 min TTL. + +```rust +pub(crate) trait ControllerUiHost { + fn controller_policy(&self) -> ControllerPolicySnapshot; + fn validate_controller_source(&self, &ControllerProvenance) -> Result<(), ControllerError>; + fn semantic_snapshot(&mut self, scope: &Value) -> Result; + fn semantic_events_after(&self, after: u64, &SemanticEventPredicate, limit) -> Result; + fn action_catalog(&self) -> Vec; + fn resolve_action_call(&self, &ActionCall) -> Result; + fn action_availability(&mut self, &ActionCall) -> Availability; + fn invoke_action(&mut self, ActionCall, TrustedInvocation, &SdkDeliveryBarrier) -> ActionResponse; + fn accepted_terminal_response_sent(&mut self, &ControllerCall); + fn collection(&mut self, ControllerCollectionKind, &ControllerProvenance) -> Result; + fn cancel_task_programs(&mut self, &KernelKey) -> Result; + fn precondition_for_target(&self, &SemanticTarget) -> Option { None } +} +``` + +Lifecycle `OPEN → QUITTING → CLOSED`. `SdkRequestDisposition` is a three-state +CAS (`PENDING | UI_CLAIMED | ABANDONED`): exactly one of caller-abandon and +UI-claim wins; after the UI claims, the response wins every timeout/drop race. +`SdkDeliveryBarrier` tracks the response frame reaching the worker pipe; +`app.quit` waits on it (5 s cap) before destructive shutdown. + +Drain: waiters get half the batch first, then queued requests, then waiters +again; a retained wait alone does not keep the UI loop spinning. Every +request *and every waiter poll* re-runs `authorize_call` against the live +policy (`code_mode_enabled`, `access`, `policy_epoch`, source validation). + +SDK method table: `ui.describe`, `ui.query`, `ui.reveal`, +`ui.current_selection`, `actions.list`, `actions.describe`, +`actions.availability`, `actions.invoke`, `events.wait`, `tasks.list`, +`projects.list`, `transcript.list`, `mcps.list`. Discovery distinguishes +`available`, `needs_arguments` (`arguments_required` reason), and `disabled`. +Cursors bind `(kind, fingerprint, offset, revision, policy_epoch, expiry)`; +a changed revision or epoch invalidates paging. Outgoing list items are +scanned for sensitive key names and every response is size-bounded. + +## 4. `harness/catalog.rs`: the descriptor catalog + +About 180 actions declared through `CatalogSpec` macros with `FieldKind` +(`Bool, Count, Digit, McpServer, Number, Object, String, StringAllowEmpty, +StringArray, UuidString, Choice`). Schemas always set +`additionalProperties: false` with an explicit `required` list. Durable +side-effect actions return `oneOf {operation_id} | {local_applied, durable, +error?}` so the UI can tell in-memory from on-disk. `mcp.add/update` are +secret-bearing. Every `bindable` descriptor requires a registered typed +adapter, so a missing GPUI adapter is a startup/test failure. + +Human-only class: authentication, sign-out, clipboard-image attach, all raw +text-input editing and Vim grammar primitives, `timeline.copy_selected`, +`shortcuts.reset_profile`, and both Code Mode authority setters. A program can +never widen its own authority, log in, or synthesise keystrokes. + +Representative sample: + +| id | effect / policy / recoverability | bindable, terminal | precondition | arguments | +|---|---|---|---|---| +| `app.quit` | ExternalEffect / ControllerCallable / Irreversible | yes, **yes** | none | none | +| `settings.open_section` | Navigate / ControllerCallable / Ephemeral | yes | none | `section` | +| `shortcuts.reset_profile` | MutateMaple / **HumanOnly** / Irreversible | no | Setting | none | +| `account.sign_out` | MutateMaple / **HumanOnly** / Irreversible | no | none | none | +| `auth.password_submit` | ExternalEffect / **HumanOnly** / Irreversible | no | none | `email, password` | +| `task.open` | Navigate / ControllerCallable / Ephemeral | yes | Task | `task_id` | +| `task.set_archived` | MutateMaple / ControllerCallable / Reversible | no | Task | `task_id, archived` | +| `project.set_trusted` | MutateMaple / ControllerCallable / Reversible | no | Project | `canonical_root, trusted` | +| `composer.set_text` | MutateMaple / ControllerCallable / Reversible | no | Draft | `task_id, text` | +| `composer.send` | ExternalEffect / ControllerCallable / Irreversible | yes | Draft | `task_id?` | +| `text_input.paste` | MutateMaple / **HumanOnly** / Reversible | yes | none | none | +| `composer.vim.motion` | Navigate / **HumanOnly** / Ephemeral | yes | none | `motion; count?` | +| `run.stop` | MutateMaple / ControllerCallable / Irreversible | yes | Task | `task_id` | +| `permission.respond` | MutateMaple / ControllerCallable / Irreversible | no | Permission | `request_id, decision` | +| `ui.activate_selected` | MutateMaple / ControllerCallable / Ephemeral | yes | none | none (contextual alias) | +| `code_mode.execute` | ExternalEffect / ControllerCallable / Irreversible | no | Task | `task_id, code` | +| `code_mode.set_controller_access` | MutateMaple / **HumanOnly** / Reversible | no | Setting | `access` | + +## 5. `harness/provenance.rs`: direct-user provenance + +GPUI actions carry no origin, so the window mints an opaque, non-serialisable +token only from observed physical input and consumes it exactly once. +`WindowProvenance` tracks focus/context/keymap generations plus separate key +and pointer dispatch phases. Pointer: the root opens a phase in capture; one +child may consume once while the event bubbles; mouse-up opens a fresh phase. +Key: `begin_key_sequence()` snapshots the three generations and +`consume_resolved_key()` succeeds only if all still match. A pending chord +closes the immediate dispatch window while retaining provenance, which stops a +programmatic `dispatch_action` from stealing the token. + +## 6. `harness/keymap.rs`: the pure keymap compiler + +Limits: 1 MiB file, 8 strokes per sequence, 4096-byte contexts. JSON format: +sections `{"context": "", "bindings": {"": }}` with +directives `"action.id"`, `["action.id", {...}]`, or `null` (an explicit +unbind). A parallel token scanner attaches line/column to every entry. + +`ContextAnalysis` decomposes top-level `&&` conjunctions into facts +(`Present, Absent, Equals, NotEquals`); any `||` makes overlap `Possible`. +Precedence: user layer over template, later over earlier, specificity +informational. Pipeline: validate everything (any diagnostic aborts the whole +compile), assign ids, attach defaults, resolve exact groups (a winning `null` +disables the whole group), classify conflicts (`Possible > Prefix > Shadow > +Exact`), build the prefix trie from install rules. Which-key continuations +pick the terminal maximising `(context depth, precedence)`, matching GPUI's +own resolution order. Install is all-or-nothing with a last-known-good +fallback; files are written atomically (same-directory 0600 temp, fsync, +rename, fsync parent) under an FNV-1a + byte-length revision CAS. + +Vim template context design: `MapleApp && profile == vim && app_vim_mode == +normal && !TextInput && !ApplicationModal`. Negating the leaf component is the +actual shadow, so ordinary text/IME insertion stays with the focused editor. + +## 7. `keymap_runtime.rs`, `shortcut_store.rs` + +The runtime adapter validates by constructing the typed GPUI action, so a +successful compile cannot fail during installation. The shortcut store runs +off the UI thread, requires an expected file revision (never `Any`), parses +and rejects a malformed current file first, recompiles the whole prospective +keymap before writing, and turns "replace conflicting bindings" into explicit +`null` overrides. + +## 8. `palette.rs`, `which_key.rs`, `application_vim.rs` + +Palette ranking ladder: exact id/alias 1000, exact label 950, id/alias prefix +900, label prefix 850, exact shortcut 825, substring 500, else filtered. A +leading `:` is stripped. Missing required arguments become an inline JSON +editor seeded from the schema, not a disabled row. Navigation inside the +palette is itself semantic (`ui.select_next`, `ui.activate_selected`). + +Which-key: 300 ms delay, 5 s manual timeout, rows grouped by category with +`"{category} commands"` labels for pure prefixes, a generation-fenced +`Hidden/Waiting/Visible` machine, and portable keystroke tokens rebuilt from +modifier fields so the projected prefix matches the compiled trie. + +Application Vim count prefix: digits arrive as the typed +`application.vim.count_digit{digit}` action, never as raw keys; an explicit +allowlist of 18 countable actions; leading zero is a no-op; saturates at +999 999; a pending prefix beats an explicit `count` argument. + +## 9. `harness/semantic.rs`, `desktop/controller_runtime.rs`, `backend/code_mode.rs` + +Semantic projection keeps `entries` (navigable) separate from `known` (all +candidates) so a filter is never reported as a deletion. Selection +reconciliation is by stable target, then filtered retention, then a +successor-preferring neighbour search, then region fallback. `RegionGraph` +gives `ctrl-w hjkl` over a semantic graph rather than pixels. + +The GPUI runtime: final-action reauthorisation maps contextual aliases to one +concrete call on both discovery and execution paths; the real host is swapped +for `ActionHost::suspended()` during dispatch so nested dispatch fails with +`dispatcher_busy`; a root/worker tuple or epoch mismatch reports the +controller as `Off` (split-brain fails closed); source validation ties +provenance to the signed-in account and a live task. + +`AppCodeMode` serialises every authority/account transition under one mutex, +requires transitions to advance exactly one epoch or be an exact retry, and +revokes a program with two independent attempts (worker lease and host-bound +model run). History is bounded (64 per task, 64 tasks, 8 MiB). + +## 10. `maple_gpui` Python SDK + +Always importable; Rust is the authority boundary. `SemanticObject(dict)` and +`SemanticPage(tuple)` (a dict would shadow `page.items`). The flagship pattern +is `actions.invoke_and_wait`: capture `event_cursor` **before** invoking, then +`events.wait({kind: action_completed, invocation_id}, after=cursor)`, so a +completion published before the wait registers is still found in the ring. +No polling, no sleep. + +## 11. Invariant checklist + +1. One execution path; `executor_identity()` proves it in tests. +2. Descriptor first: arguments, results and preconditions validated in and out. +3. Human-only actions need a real pointer/key/palette origin. +4. Provenance is a move-only, window-scoped, generation-fenced capability. +5. Authority is a lease; any epoch change cancels retained non-human work. +6. Stored `controller_access` must equal the live tuple at execution time. +7. Split-brain fails closed. +8. One atomic pre-dispatch winner per SDK request. +9. Final-action reauthorisation on the concrete action. +10. Exactly-once terminal completion via a secret-bearing token. +11. Cancellation is a request; `CompletedAfterCancelRequest` records the truth. +12. Everything is bounded (256 ops, 512 audit, 64 queue, 16/tick, 64 waiters, 512 cursors, 512 KiB responses). +13. Revisions everywhere (cursors, keymap files, semantic targets). +14. All-or-nothing keymap install with last-known-good. +15. Redaction by construction. + +## 12. Minimal faithful re-implementation, in priority order + +**Tier 1, the gate:** `ActionId`/`ActionCall`/`ActionResponse`; +`ActionDescriptor` + registry with adapter enforcement; the policy enums and +pure `authorize`; `ProgrammabilityAuthority` + epoch snapshot; `ActionBudget` ++ `TrustedInvocation`; the audit ring; the `ActionHost` gate with async +completion tokens and epoch-based revocation. *(Ported: action, registry, +policy, audit; re-expressed: `host.rs`.)* + +**Tier 2, catalog and ingress:** the declarative catalog with the Human Only +set intact; `DirectUserIngress` and a headless provenance equivalent. +*(Re-expressed: `catalog.rs`; ported: `DirectUserIngress`/`PendingKeyProvenance`.)* + +**Tier 3, the programmable bridge:** `SdkRequest`/`ControllerProvenance`, +disposition and delivery fences, the bounded bridge with waiter fairness, the +13-method dispatcher with cursors and redaction, event waits over the semantic +ring. *(Re-expressed in compact form: `controller.rs`; ported: semantic events.)* + +**Tier 4, keymap and surfaces:** sequence/context analysis, `compile_keymap` +with precedence/conflicts/trie, all-or-nothing install with last-known-good, +atomic file CAS, the palette model, which-key projection, Vim count state. +*(Ported: `keymap.rs` core; re-expressed: `discovery.rs`.)* + +**Tier 5, semantics:** targets, presence, projection, reveal plans, selection +reconciliation, region graph, return stack, revision ledger. *(Ported: +`semantic.rs`.)* + +**Tier 6, optional Python worker:** kernel identities, worker service traits, +the SDK facade shapes and the cursor-before-invoke protocol. *(Not ported; +the bundled CPython runtime landed separately as Maple #897.)* diff --git a/apps/maple-agent/docs/programmable-harness-preview-pr.md b/apps/maple-agent/docs/programmable-harness-preview-pr.md new file mode 100644 index 000000000..ecf0841d7 --- /dev/null +++ b/apps/maple-agent/docs/programmable-harness-preview-pr.md @@ -0,0 +1,350 @@ +> **Archived 2026-09-09:** original description of benthecarman/maple-gpui PR #2 ("Developer preview: make Maple a programmable semantic application"), kept for reference alongside `programmable-harness.md`. Links and file paths refer to the retired `maple-gpui` repository. + + +> **Draft / Developer Preview — preserved integration prototype** +> +> This is an architecture-complete proof of concept for discussion and hands-on +> evaluation. It is intentionally broader than a normal feature PR because the +> value is in proving one end-to-end application contract. It is not a claim +> that native Python is production-contained, that every Vim edge case is +> complete, or that this should merge unchanged. The branch is intentionally +> maintained as one squashed commit targeting `master`; focused production +> changes will be extracted and reviewed separately. + +## What I am asking for + +I would especially value feedback on the architectural direction and the seams +between the crates—not a line-by-line endorsement of roughly 90 files at once. + +The central question is: + +> Should Maple have one typed semantic application contract, with pointer UI, +> shortcuts, Vim, Python, and model-driven workflows acting as clients of that +> contract? + +This PR is now the stable full-system reference and dogfood target, not a +request for line-by-line approval of the entire diff. Focused follow-up PRs will +start from `master` and must stand on their own even if the rest of this preview +never ships. If the direction does not feel right, this remains early enough to +change course without committing the product to the experiment. + +## Context: why build this now? + +`maple-gpui` has already demonstrated that Maple's desktop and agent runtime can +exist outside the Tauri shell. That creates an unusually good point to decide +what the native application's control boundary should be before more UI and +automation features accumulate around screen-local callbacks. + +Several useful features were converging at the same time: + +- editable shortcuts and a command palette; +- whole-application Vim navigation; +- a real modal Vim engine for the composer; +- persistent Python for model computation; +- model-driven control of Maple itself; +- future macros, generated UI, and recursive-language-model workflows. + +Building each directly against GPUI callbacks would produce parallel command +systems. Buttons, keybindings, Vim, and model tools would drift on naming, +availability, authorization, completion, cancellation, and audit behavior. +Coordinate clicking or synthetic keystrokes would add another brittle layer, +especially over virtualized transcript/sidebar content. + +This branch pays the migration cost once: existing semantic controls move onto +one typed action spine, and every new interface uses it. + +## The architectural thesis + +```mermaid +flowchart LR + subgraph Human[Human interfaces] + Pointer[Pointer controls] + Keys[Standard shortcuts] + Palette[Command palette] + AppVim[Application Vim] + ComposerVim[Composer Vim] + end + + subgraph Programmatic[Programmatic interfaces] + Model[Model] + PyTool[python_code] + Worker[Per-task Python worker] + SDK[maple_gpui SDK] + end + + Pointer --> Adapters[Typed GPUI adapters] + Keys --> Adapters + Palette --> Adapters + AppVim --> Adapters + ComposerVim --> Adapters + Model --> PyTool --> Worker --> SDK --> Bridge[Bounded controller bridge] + + Adapters --> Host[ActionHost] + Bridge --> Host + Host --> Gates[Availability + policy + provenance + preconditions + cancellation + audit] + Gates --> Executor[One semantic executor] + Executor --> State[Chat / Settings / Login / backend effects] + State --> Projection[Stable semantic snapshots + events] + Projection --> SDK +``` + +The registry is not metadata beside legacy behavior. A pointer click and a +model request for the same operation converge on the same descriptor, policy +check, executor, result, and event. That is the most important invariant in the +branch. + +### Dependency boundaries + +| Layer | Owns | Deliberately does not own | +| --- | --- | --- | +| `maple-harness` | Wire-safe actions, descriptors, stable targets, policy, provenance types, audit, revisions/events, keymap data | GPUI, effects, account clients, Python | +| `app/src/harness` | Catalog/adapters, `ActionHost`, the one executor, keymap runtime, palette, which-key, application Vim, controller bridge | Python execution or credentials | +| Screen/business modules | Live availability and the operation that owns each mutation/effect | A second authorization or action system | +| `maple-code-mode` | Interpreter discovery, framed protocol, persistent account/task kernels, limits, cancellation, process-group lifecycle, controller transport trait | GPUI, account clients, credentials, Maple action implementations | +| `maple-agent` | Model-facing `python_code`, model-run identity/cancellation, conditional built-in controller skill | GPUI or application action execution | +| Python `maple_gpui` SDK | Discovery, invoke/wait, queries, pagination, events, thin task/workspace helpers | Authority tokens, credentials, GPUI/Rust objects, arbitrary method access | + +The dependency direction is intentional: the portable crates describe and +supervise; the app is the only layer that can finally authorize and execute a +Maple UI operation. + +## Important invariants + +### One action, one implementation + +The checked-in catalog currently contains 166 stable actions. The strict source +audit inventories 219 production activation callbacks: 188 route through the +semantic harness and 31 are explicitly classified local mechanics such as +hover, hit testing, scroll, or selection painting. There are no unexplained +legacy-direct gaps. + +### Availability is not authority + +Screens answer whether an operation currently makes business/UI sense. The root +host separately authorizes who may perform it. A visible enabled control never +becomes proof that a controller may call the same action. + +### Provenance is host-assigned + +Programmatic callers cannot claim `DirectUser`, choose their account/task, +select a policy epoch, or mint a capability. Direct-human provenance is a +bounded, single-use root input capability. Generic actions such as “activate +selected” resolve their concrete backing action and reauthorize it; aliases are +not policy shortcuts. + +### Stable identity survives GPUI refactors and virtualization + +The external contract uses stable action IDs and semantic targets—not vector +indices, focus handles, render element IDs, Rust type names, or callbacks. +Targets carry revision/precondition data so stale state fails closed. Semantic +selection, GPUI focus, text insertion, viewport position, and streaming-follow +state remain separate concepts. + +### Accepted is not completed + +Async operations have correlated invocation/operation identities and semantic +completion events. The SDK's `invoke_and_wait` captures the event cursor before +dispatch so a fast completion cannot be missed. Cancellation revokes future +work; it does not pretend already-committed effects were rolled back. + +### Authority is explicit and fail-closed + +| Caller | Observe | Navigate | Mutate | External effect | Human Only | +| --- | ---: | ---: | ---: | ---: | ---: | +| Direct human | Yes | Yes | Yes | Yes | Yes | +| Controller Off | No | No | No | No | No | +| Controller Read Only | Yes | Yes | No | No | No | +| Controller Full Access | Yes | Yes | Yes | Yes | No | + +Code Mode and Controller access are separate settings and both start Off on +launch, account change, and sign-out. Permanent account/identity operations, +authentication, sign-out, and authority changes remain Human Only. Full Access +intentionally permits ordinary mutations—including conspicuously audited +`permission.respond`—without adding a hidden second confirmation system. That +policy line is an explicit review question, not an accidental side effect. + +## What the preview includes + +### Shortcut system and application Vim + +- Complete Standard and Vim replacement profiles, not stacked partial maps. +- Portable `secondary-*` bindings (Cmd on macOS, Ctrl elsewhere). +- `keymap.json` overrides, null/disabled bindings, validation, last-known-good + reload, conflict detection, recording, per-command/profile reset, and search. +- Registry-generated command palette and passive which-key. +- Semantic application navigation across transcript, sidebar, tasks/projects, + settings, menus, questions, permissions, and composer-region transitions. +- Stable off-screen reveal for virtualized lists and streaming-safe selection. + +Application Vim and composer Vim are intentionally different layers. The +application controller navigates semantic objects/regions. The composer owns a +pure grapheme-safe modal editing engine with Normal/Insert/characterwise Visual +modes, motions, operators, counts, register/paste, undo/redo, text objects, and +structured dot repeat. Dot repeat replays the semantic edit recipe, not the raw +keys. + +### Python Code Mode + +- A separate persistent worker per account/task, outside the GPUI process. +- Deterministic interpreter selection: environment override, saved preference, + future bundle, PATH, then platform fallbacks. +- IPython when importable, with a CPython engine that still supports persistent + state and top-level await. Keeping optional IPython versus simplifying to + CPython-only is a useful review question, not a foundational dependency. +- Framed private protocol, fixed worker bootstrap/SDK, source/frame/output/time + limits, active-kernel cap, process groups, Stop/Reset/Restart, crash recovery, + account/task lifecycle, and exact scratch cleanup checks. +- Model `python_code` calls for a task share one process-local namespace. + +The user-facing placement is deliberately quiet after dogfooding feedback: +disclosure, interpreter state, controller mode, limits, active/retained +programs, lifecycle controls, and audit live only in **Settings → +Programmability**. There is no chat-mounted REPL, compact strip, or global Code +Mode HUD. `code_mode.open` remains a compatibility action that opens Settings. + +### Model-driven `maple_gpui` + +When Controller access is enabled, model Python can discover the current +semantic tree, query stable objects, list compact actions, fetch full schemas, +check availability, invoke and await correlated completion, page bounded +collections, and wait on semantic events. It cannot click coordinates, synthesize +keys, receive Maple credentials, or cache authority through a downgrade. + +## Role of this PR + +The implementation is a vertical architectural proof. It remains useful +because it proves that the registry, migration, Vim layers, Python supervision, +and model-driven controller can operate together. It is not the unit proposed +for production merging. + +The branch history is intentionally squashed to one commit so the complete +preview can be rebased directly as upstream evolves. The original logical +history is preserved on the fork at +`programmable-harness-history-79d8277`; it is provenance, not a second active +development stack. + +Focused production work will proceed one bounded section at a time: + +1. Independently valuable fixes already discovered in upstream-existing code, + only where the defect exists without this preview. +2. A minimal refactor of existing interaction handling that adds no commands, + keybindings, Vim behavior, Code Mode, or controller surface and remains + valuable if none of those features ever ship. +3. Composer Vim as a contained feature. +4. Shortcut customization for the existing command and keybinding set only. +5. Application Vim navigation and its new controls after the foundation and + shortcut surface are independently sound. + +Code Mode, `python_code`, controller policy/projection, and the Python SDK stay +in this preview for now. They are not being split or repositioned while the +earlier sections undergo their own design, review, testing, and fixup cycles. + +PR #2 will remain Draft, continue targeting `master`, and be rebased +occasionally. As focused pieces merge, the preview can shrink without becoming +the Git base for those PRs. + +## How to try it + +The debug build is sufficient for interaction testing: + +```sh +nix develop --no-update-lock-file --command cargo run -p maple-gpui --locked +``` + +After signing in: + +1. Open **Settings → Keyboard Shortcuts**. Search by label, stable action ID, + and key; switch Standard/Vim; inspect or record a conflict; disable/reset an + override. +2. In Vim, exercise `j`/`k`, `gg`/`G`, `Ctrl-W h/j/k/l`, `ga`, `gi`, `:`, and + the Space leader/which-key. The composer shows its own mode and supports + edits such as `ciw…Escape`, movement, then `.` on a different word. +3. Open **Settings → Programmability**, acknowledge the disclosure, and enable + Python Code Mode for this session. Ask the model to assign a Python variable + in one `python_code` call and read/update it in a second call. +4. Set Controller access to **Read Only** and ask: + + > Use `python_code` to import `maple_gpui as maple`, call + > `await maple.ui.describe()`, and report the current screen and active + > region. Do not call any other tool. + +5. Still in Read Only, try a reversible navigation and a settings mutation; + navigation should work and mutation should return a structured policy + denial. Full Access can then be used for a reversible ordinary setting + change, restored immediately afterward. + +The preview requires a usable Python 3.10+ interpreter. The Nix shell includes +Python and IPython; a custom executable can be selected in Settings or with +`MAPLE_CODE_MODE_PYTHON`. + +## Validation + +Current exact head (`653b959`, a tree-identical squash of validated head +`79d8277`): + +- `MAPLE_NIX_XCODE_VERSION=26.5 nix develop --no-update-lock-file --command just ci` + passes on macOS. +- Format, strict semantic-control audit, all four warning-denied Clippy feature + matrices, workspace build/tests, native CPython/IPython worker integration, + doctests, and combined headless tests pass. +- Strict inventory: **219 callbacks = 188 semantic + 31 justified local**. +- The project-menu startup race found during the final exact-head run was fixed + with a deterministic mounted-chat test seam; its focused interaction passes + 100 consecutive runs before the full matrix. + +The vertical implementation was also release-built and exercised live on macOS +before the upstream rebase: Settings/shortcuts, application and composer Vim, +persistent Python, model `python_code`, Read Only/Full Access controller policy, +semantic discovery/invocation/events, lifecycle, and exact app/process identity +were checked. That earlier artifact is not presented as proof of the exact +rebased head; this PR is Draft so the current branch can be tried and the +architecture discussed before any production/promotion claim. + +GitHub's normal Linux/macOS/Windows matrix remains additional evidence for the +squashed head. + +## Honest boundaries and non-goals + +This is a high-quality proof of concept, not a hardened Python sandbox. + +The worker is a separate bounded process with a scrubbed environment, private +scratch/runtime, protocol limits, audit hooks, and process-group cleanup. It +still runs as the user's native OS account. Native modules, direct syscalls, +readable user files/credentials, subprocess escape techniques, or network +access may bypass best-effort Python guardrails. Production containment needs a +platform helper/XPC-style boundary with explicit file/network authority. + +Other deliberate non-goals: + +- full Vim/Neovim/plugin compatibility; +- durable Python namespaces or package/virtualenv management; +- bundled Python in every release artifact; +- generated arbitrary UI, third-party action registration, or multi-window + semantic control; +- transactional rollback of already-completed effects; +- a human chat REPL or global program HUD. + +Known production-hardening work includes a retry-addressable cleanup owner until +process-group death is positively confirmed, concurrent/bounded teardown receipt +capture that survives waiter cancellation, OS-backed physical-input provenance +below GPUI's synthetic dispatch seam, broader platform GUI validation, and +continued UX/accessibility refinement. + +## Questions for review + +1. Is one typed semantic action contract the right long-term boundary for the + native Maple app? +2. Do the crate/dependency seams keep policy and effects in the right owner? +3. Is the Read Only / Full Access / Human Only line understandable and useful, + especially the explicit Full Access behavior for `permission.respond`? +4. Does separating application Vim from composer Vim feel like the right model? +5. Is Settings-only Code Mode visibility the right amount of product surface + for this preview? +6. Should the preview retain optional IPython semantics, or simplify to the + smaller CPython-only engine before going further? +7. Which architectural lessons should survive into the smaller standalone + foundation and feature PRs, even if the rest of this preview is discarded? + +Again: the immediate goal is architectural feedback and a real hands-on trial, +not production sign-off or an expectation that this full diff merges as-is. + diff --git a/apps/maple-agent/docs/programmable-harness.md b/apps/maple-agent/docs/programmable-harness.md new file mode 100644 index 000000000..26039cc1f --- /dev/null +++ b/apps/maple-agent/docs/programmable-harness.md @@ -0,0 +1,3275 @@ +# Maple Programmable Harness + +> **Preserved design, 2026-09-09.** This document is carried over unchanged +> (apart from this preface) from the `maple-gpui` Developer Preview branch, +> benthecarman/maple-gpui PR #2 (`programmable-harness`, head 653b959), before +> that repository is deleted. The full prototype (about 90 files, including the +> GPUI wiring, composer Vim engine, shortcut settings, and the `maple_gpui` +> Python SDK) is **not** ported. What this monorepo keeps is: +> +> - `crates/maple-harness/` — the GPUI-free contract crate (actions, +> registry, policy, semantic snapshot/events, audit, keymap) ported as-is; +> - `crates/maple-harness/src/{host,catalog,controller,discovery}.rs` — a +> compact from-scratch re-expression of the desktop-side concepts (single +> action host with final-action reauthorization, descriptor catalog, +> bounded controller bridge, palette and which-key) written so that the +> ideas compile and are unit-tested without a window; +> - `docs/programmable-harness-distillation.md` — a module-by-module +> reconstruction of the desktop code that was not ported, with the +> invariant checklist and a tiered re-implementation order; +> - `docs/programmable-harness-preview-pr.md` — the original PR description. +> +> Sections below that describe GPUI screens, `app/src/harness/*`, or the +> Python worker refer to the old prototype layout and are historical. Paths +> such as `crates/maple-code-mode/` now mean Maple `apps/maple-agent/crates/`; +> the bundled CPython runtime that this design assumed landed separately as +> the cpython + codemode work (Maple #897), which this branch is stacked on. +> Nothing here is wired into the running Agent; a production version will be +> a fresh design that can cite this one. + +Status: normative implementation specification for an architecture-complete Developer Preview. + +Audience: reviewers, contributors, and future maintainers. + +Target: macOS first. Keep the core portable and keep Linux compiling; Linux desktop behavior can be validated by a Linux reviewer. + +This document is intentionally detailed. It records the product thesis, the decisions already made, the required architecture, the initial user experience, the implementation seams in the current repository, and the validation and review contract. Future changes should not replace these decisions with a smaller unrelated feature, a second command system, key simulation, an embedded arbitrary-code path, or claims of sandboxing that are not true. + +## 1. Executive summary + +Vim mode is not the underlying feature. It is one client of a programmable application. + +Maple should expose every meaningful application operation through one typed, discoverable semantic action system. Buttons, keyboard shortcuts, a command palette, whole-application Vim, the model, and Python programs all use that same system. The model can then navigate or operate Maple by writing short programs against semantic state instead of pretending to press keys or clicking coordinates. + +The architectural center is: + +~~~text +pointer / keyboard / palette / Vim / model / Python / future generated UI + | + v + typed semantic actions + | + v + availability + authority + audit + | + v + one action implementation + | + v + Maple state and backend effects + | + v + semantic events + native GPUI rendering +~~~ + +This unlocks several related experiences without building separate automation stacks: + +- A Standard shortcut profile for ordinary users. +- A Vim profile with application-level modal navigation. +- Real Vim editing in the chat composer. +- A searchable command palette and shortcut editor. +- Model-controlled UI navigation and action chaining. +- A persistent per-task Python/IPython Code Mode. +- A loadable maple_gpui Python controller with Read Only and Full Access policies. +- Later, declarative generated companion views and mini-apps. + +The current branch must implement the real vertical spine, not merely types or mock UI. It is acceptable for long-tail compatibility and hardened process isolation to remain explicit follow-up work. It is not acceptable to leave the central action path, policy checks, cancellation, stable semantic targeting, core Vim commands, Python lifecycle, controller SDK, or live validation as TODOs. + +## 2. Document and review contract + +This document began as the implementation brief for the Developer Preview and +now serves as its design and implementation record. The implementation is +intentionally end to end so reviewers can evaluate the real interaction among +actions, policy, semantic state, Vim, Python, and model control rather than a +set of disconnected abstractions. + +The review contract is: + +1. Treat the product thesis, dependency direction, one-path invariant, + authority model, honest Python boundary, and fail-closed lifecycle as the + architectural center of the proposal. +2. Keep semantic behavior on one typed action path. Do not accept a + metadata-only registry while buttons, shortcuts, or model calls bypass it. +3. Preserve stable wire identities and semantic targets across Rust/GPUI + refactors; do not expose GPUI entities, callbacks, or Rust type names as the + public automation protocol. +4. Require direct-human provenance for Human Only actions and authority + changes. A controller request cannot claim its own actor, transport, + account, task, policy epoch, or capability lease. +5. Treat native Python as a separate process and a useful Developer Preview, + not as a hardened sandbox. Production containment remains an explicit + follow-up. +6. Keep every change buildable and covered at the owning layer. Run the full + repository validation before proposing promotion beyond Developer Preview. +7. Review the implementation in the architectural slices in Section 26 even + when integration and follow-up fixes require additional commits. + +## 3. Quality bar + +This is an ambitious experiment, not a throwaway prototype. + +The desired balance is: + +- Complete architecture and genuine end-to-end behavior. +- Good Rust boundaries and testable pure components. +- Deliberate, understandable UX. +- Honest security language. +- Clean commits and useful documentation. +- TODOs for production hardening and exhaustive edge cases. + +The following are acceptable follow-ups: + +- Exhaustive Vim compatibility. +- A hardened macOS helper or XPC sandbox and equivalent Linux/Windows isolation. +- Rich Python package and environment management. +- Durable Python variables across app restarts. +- Generated UI cards and companion surfaces. +- Dynamic third-party action registration. +- Exhaustive localization and accessibility polish. + +The following are not acceptable shortcuts: + +- Separate button, shortcut, model, and Python implementations of the same operation. +- Key or coordinate simulation as the model control API. +- An action registry that is metadata only while buttons keep bypassing it. +- Action targets based on virtual row indices or GPUI entities. +- A Read Only controller that can mutate through a generic activate command. +- A Python module that directly receives GPUI objects, backend handles, credentials, or Rust pointers. +- Python execution in the GPUI process. +- Unbounded Python output or an infinite loop without a working Stop path. +- Calling native CPython a sandbox merely because cwd and HOME were changed. +- Applying Vim interception to password, login, search, rename, or settings inputs. +- Implementing dot repeat by replaying raw keystrokes. +- Stopping after planning, scaffolding, or unit tests without launching and exercising the app. + +## 4. Product thesis and inspirations + +### 4.1 Vim as an application grammar + +Vim contributes more than familiar movement keys. Its important ideas are: + +- explicit modes; +- composable operators and motions; +- counts; +- text objects; +- repeatable semantic changes; +- command discovery through prefixes and a command line; +- a stable distinction between navigation and insertion. + +Maple applies those ideas at two levels: + +1. Application Vim navigates semantic Maple objects: tasks, projects, transcript items, settings rows, menus, questions, permissions, tool cards, and future annotations. +2. Composer Vim edits text with a true modal state machine. + +The two levels coordinate through shared profiles and context state, but they must not be one giant state machine. A transcript j moves between timeline objects. A composer j moves between logical text lines. + +### 4.2 GPUI and Zed + +The current app already uses GPUI 0.2.2 typed actions, key contexts, bubbling, focus handles, and virtualized lists. GPUI also supports: + +- typed parameterized actions and JSON schemas; +- dynamic action construction; +- multi-stroke bindings; +- boolean key-context predicates; +- clearing and rebuilding the complete keymap; +- enumerating current actions and bindings; +- observing pending multi-stroke input. + +The intended direction follows Zed's useful patterns: + +- Almost all functionality is exposed as actions. +- User keymaps bind action IDs, action arguments, sequences, and context predicates. +- Later, more specific or user bindings override templates. +- A command palette discovers and dispatches available actions. +- Vim state participates in key contexts rather than intercepting every key globally. +- Vim extends beyond the editor into panels and application surfaces. + +Useful primary references: + +- GPUI key dispatch: https://github.com/zed-industries/zed/blob/main/crates/gpui/docs/key_dispatch.md +- Zed key bindings: https://zed.dev/docs/key-bindings +- Zed Vim behavior and contexts: https://zed.dev/docs/vim +- Zed Vim keymap: https://github.com/zed-industries/zed/blob/main/assets/keymaps/vim.json +- Zed Vim engine: https://github.com/zed-industries/zed/blob/main/crates/vim/src/vim.rs +- Zed command palette: https://github.com/zed-industries/zed/blob/main/crates/command_palette/src/command_palette.rs + +Maple should borrow the architectural lessons, not copy Zed wholesale. Maple's objects are conversations, tasks, tools, permissions, and projects rather than buffers and syntax trees. + +### 4.3 Codex-style model viewpoint control + +The motivating experience is a model that can cross the application boundary in a controlled, inspectable way. + +For example, while the user is typing, an assistant response streams into the transcript. Streaming does not steal focus. In Vim: + +1. Escape leaves Insert for composer Normal. +2. ga selects the newest assistant response, or Ctrl-W k moves to the transcript. +3. left-bracket d and right-bracket d move among annotations when annotation objects exist. +4. Enter activates or opens the selected semantic object. +5. gi returns to the composer at the stored insertion point when the draft + revision still matches, or safely to the end of a replaced draft. + +The model can perform the same navigation semantically: + +~~~python +import maple_gpui as maple + +snapshot = await maple.ui.describe() +tasks = await maple.tasks.list(title_contains="Nix flake") +await maple.workspace.open_task(tasks[0].id) +await maple.transcript.focus_next(kind="annotation") +await maple.events.wait( + {"kind": "semantic_selection_changed"}, + after=snapshot.event_cursor, + timeout=10, +) +~~~ + +The model should invoke the inner action directly when changing the visible viewpoint adds no user value. It should navigate visibly when the user needs to see the object or follow the flow. Both paths use the same registry and policy. + +### 4.4 Python and future RLM work + +Python is not only an implementation detail for UI navigation. A persistent +per-task execution environment is useful for general computation and future +recursive-language-model work. In this Developer Preview it is exercised by +model `python_code` calls and inspected from Settings; there is no human-facing +chat REPL. Therefore: + +- General Python Code Mode works when UI control is Off. +- The maple_gpui SDK is a required deliverable in this branch, layered on top of general Python and importable for useful calls only when UI Controller access is Read Only or Full Access. +- The Python process is persistent per Maple task. +- IPython is preferred when available; CPython fallback still supports persistent state and top-level await. +- Code Mode is off by default and clearly labeled Developer Preview. + +### 4.5 Future generated UI + +Generated UI is deliberately not part of this branch, but the action architecture should make it possible later. + +The safe direction is a versioned declarative view document rendered from trusted GPUI components: + +- stacks and grids; +- text and Markdown; +- forms and buttons; +- tables and charts; +- images, code, and diffs; +- tabs, lists, progress, and annotations. + +Events from generated surfaces would request registered actions with schema-validated arguments. Generated surfaces would never compile arbitrary model-generated Rust into Maple's authenticated process. Trusted Maple chrome would identify generated content and generated views could never impersonate permission, credential, or account dialogs. + +Capability-limited WASM or a separate helper process could be considered later. Zed's extension capability model is useful evidence that this boundary deserves care: https://zed.dev/docs/extensions/capabilities + +Generated mini-apps are a consumer of the programmable harness, not a reason to distort the first implementation. + +## 5. Decisions already made + +These are settled unless current source makes one literally impossible: + +1. Application Vim is the priority. Composer Vim must still be complete enough to feel like Vim. +2. The first composer milestone includes dot repeat. +3. Standard and Vim are the only first-party shortcut profiles initially. +4. Selecting Vim replaces the Standard template. It is not layered on top of Standard. +5. User overrides are applied over the selected template and may replace or disable any shortcut. +6. The shortcut settings page is generated from every typed semantic action. +7. Every currently meaningful semantic button/control must migrate through the action path. +8. Pure pointer mechanics may remain local. +9. Kernel identity is per task, not global. +10. A task's Python environment may work with files in that task's opened project root and its private scratch directory. +11. Code Mode exposes no supported Maple network API and applies best-effort guardrails, while the native Developer Preview explicitly admits that the macOS-user Python process may still access the network and is not a hardened OS sandbox. +12. Python Code Mode and the Maple UI Controller are separate settings. +13. The controller modes are Off, Read Only, and Full Access. +14. Read Only permits observation and ephemeral navigation. +15. Full Access permits ordinary model-callable mutations without individual prompts. +16. Human Only actions can never be invoked by a model, Python, macros, or future generated surfaces, even when Full Access is selected. +17. Sign out and permanent account/identity/credential operations are Human Only. +18. Controller-authority and Code Mode enablement changes are direct-human-only so a controller cannot elevate itself. +19. Agent tool permission modes remain conceptually separate from UI controller modes. Per the agreed Full Access rule, permission.respond is nevertheless controller-callable in Full Access and must be conspicuously audited; Read Only cannot respond. Do not silently add a self-approval exception that was not agreed. +20. The implementation is a Developer Preview, but the architecture must be real. +21. The implementation is organized around the six review slices in Section 26; integration and follow-up fixes may be separate commits when that preserves history and buildability. +22. The initial implementation requires automated checks, a release-mode build, + and live desktop validation. Later rebases and documentation-only review + updates are revalidated in proportion to their behavioral risk. +23. Upstream review is for architectural feedback and hands-on evaluation; it does not imply that the preview is production-ready or should be merged unchanged. + +## 6. Scope + +### 6.1 Required in this branch + +- Typed semantic action descriptors and registry. +- Dynamic action availability and disabled reasons. +- Effect, recoverability, and invocation-policy metadata. +- Host-assigned actor/transport provenance. +- Schema validation for arguments and structured results. +- Stable semantic targets and optional action-specific preconditions. +- Central policy enforcement, basic audit, and cancellation. +- Migration of every current semantic control. +- Standard and Vim templates. +- Zed-style user keymap JSON, arbitrary overrides, and null unbinding. +- Context-aware multi-key bindings. +- Conflict detection and a shortcut recorder. +- Searchable Shortcuts settings generated from the registry. +- Per-command and whole-profile reset. +- Unbound-command visibility. +- A root command palette. +- A passive which-key overlay. +- Stable semantic selection for the transcript, sidebar, settings, and modal surfaces. +- Whole-app Vim navigation with the agreed command set. +- Composer Normal, Insert, and characterwise Visual modes. +- Required composer motions, operators, counts, text objects, paste, undo/redo, and dot repeat. +- A visible composer mode indicator and mode-aware cursor. +- Persistent per-task Python worker outside the GPUI process. +- IPython when available and a useful CPython fallback. +- Output, source, protocol, runtime, and active-kernel bounds. +- Stop, Reset, Restart, crash recovery, and process-tree cleanup. +- A Settings-only Code Mode configuration, status, lifecycle, retained-program, + and audit surface; no chat REPL or global HUD. +- Model-facing python_code tool. +- Required maple_gpui SDK, enabled for controller calls only in Read Only or Full Access. +- Semantic state discovery, action discovery/invocation, and event waiting. +- A trusted built-in maple-ui-controller skill. +- Settings, disclosure, audit visibility, and policy revocation. +- Focused unit/integration tests, complete repository CI, macOS release build, and live GUI validation. + +### 6.2 Explicitly deferred + +- Full Vim, Neovim, or plugin compatibility. +- Visual Line and Visual Block, named registers, marks, macros, substitutions, search grammar, and full Ex. +- Named multi-action user macros and transactional action programs. +- A hardened OS sandbox and production containment claims. +- Bundled Python distribution in every release artifact. +- Virtualenv/package-management UX. +- Durable Python namespace across app restarts. +- Broad MIME-rich Python display support. +- Generated cards, panels, or full-screen views. +- Dynamic third-party action registration and schema migration. +- Multi-window semantic navigation. +- Linux GUI validation beyond compilation/tests in this macOS-first branch. +- Retry-addressable process-cleanup ownership after a bounded worker teardown + reports diagnostics. +- Concurrent, bounded teardown-receipt capture with durable ownership across + waiter cancellation. +- Operating-system-backed physical-input provenance below GPUI's synthetic + input seam. + +### 6.3 Annotation boundary + +Codex-style annotations are an inspiration and a semantic target type. The current Maple GPUI source does not have an equivalent first-class transcript annotation transport. This branch must: + +- define stable Annotation semantic targets; +- include previous/next annotation actions and Vim bindings; +- make the behavior work in fixtures and whenever an annotation producer exists; +- return a clear unavailable reason when no annotations exist. + +It must not invent a new model annotation protocol or parse arbitrary Markdown conventions merely to make the keybinding appear active. A first-class annotation producer/transport can be a later project. + +## 7. Pre-implementation baseline + +The design was grounded against `903c377d79d77ab303309b6479f20cbcb4253c02`. +These facts explain the seams the implementation extended; they are historical +baseline context rather than a description of the completed tree. + +### 7.1 Existing root and screens + +- app/src/desktop.rs contained MapleApp and the Screen enum. +- MapleApp owned Login, Chat, Settings, and a parked Chat entity while Settings was open. +- app/src/backend.rs owned the private Tokio runtime and runtime-effect facade. +- app/src/settings.rs persisted AppSettings safely with serde defaults and a serialized background writer. +- app/src/ui/settings.rs had five fixed sections before Keyboard Shortcuts and Programmability were added. + +The completed action host and application semantic controller are rooted at +MapleApp so they operate across Chat, Settings, Login, overlays, and the parked +Chat screen. + +### 7.2 Existing GPUI actions and keymaps + +- app/src/desktop.rs declares QuitApp and hard-codes app keybindings. +- app/src/ui/chat/mod.rs declares the existing chat actions and attaches listeners to the Chat key context. +- app/src/ui/text_input.rs declares editing actions and separately installs TextInput bindings. +- Current contexts are broad: Chat, Transcript, RootMenu, and TextInput. +- PickQuestionOption is parameterized but currently no_json, so it cannot be dynamically built or discovered through a JSON schema. + +GPUI 0.2.2 already supplies App::bind_keys, App::clear_key_bindings, App::build_action, action schemas/documentation, typed dispatch, Window::available_actions, binding lookup, context stacks, and pending keystroke inspection. Window::available_actions is structural/default-buildable availability, not Maple's complete parameterized semantic catalog or business availability; the harness adds that layer explicitly. + +Important reload rule: App::clear_key_bindings clears every binding, including TextInput defaults. A profile reload must compile and reinstall the complete resolved keymap atomically. + +### 7.3 Existing stable state + +ChatScreen already has: + +- stable session IDs; +- AgentTimelineItem IDs; +- timeline_index mapping item IDs to current index and revision; +- separate transcript and sidebar ListState values; +- explicit follow_transcript state; +- selection/reload generation fences; +- per-session timeline revisions; +- stable queue IDs, request IDs, and draft image IDs in several paths. + +Both transcript and sidebar are virtualized. Off-screen rows have no durable GPUI focus handle. Semantic selection therefore must be application state keyed by stable IDs. + +### 7.4 Existing model tool seam + +MapleDeveloperClient in crates/maple-agent/src/agent/developer_tools.rs owns the built-in developer tool catalog. Its call_tool method already receives: + +- the source session ID; +- the session working directory; +- the current model run CancellationToken. + +That is the correct seam for a python_code model tool. + +A task's authoritative identity is the Goose session ID plus account scope. Its canonical project root comes from durable session metadata such as AgentSessionSummary.project_root. Never use the currently selected ChatScreen.project_root for a background task's Python kernel. + +### 7.5 Existing process-management precedent + +The bounded shell implementation already demonstrates: + +- process groups on Unix; +- job objects on Windows; +- bounded output; +- timeouts; +- cancellation; +- descendant cleanup; +- careful drain behavior. + +Reuse those patterns for Python worker supervision instead of inventing a weaker child-process lifecycle. + +## 8. Implemented module boundaries + +The dependency direction is the durable contract. This is the implemented +module map; individual files may still move without changing that contract. + +~~~text +crates/maple-harness/ + src/action.rs wire-safe action and descriptor types + src/registry.rs validated registry and descriptor lookup + src/policy.rs controller policy and host origins + src/semantic.rs semantic targets, snapshot, events, revisions + src/audit.rs bounded/redacted audit records + src/keymap.rs profile-independent keymap data and validation + +crates/maple-code-mode/ + src/interpreter.rs deterministic interpreter discovery/probing + src/protocol.rs framed worker protocol + src/process.rs child lifecycle and fixed runtime materialization + src/kernel.rs per-task actor and state + src/service.rs kernel pool and limits + src/authority.rs normalized session authority tuple + src/limits.rs protocol/output/runtime bounds + src/controller.rs UiControllerTransport trait + python/worker.py fixed worker bootstrap + python/maple_gpui/ generated/thin SDK + +crates/maple-agent/ + existing developer tool integration + Maple-owned embedded maple-ui-controller skill + +app/src/harness/ + catalog.rs descriptors and GPUI adapter registration + host.rs one executor path, policy, audit, and operations + adapters.rs typed GPUI action adapters + provenance.rs bounded direct-user input capabilities + semantic.rs application semantic controller/projection + keymap.rs Standard/Vim templates and context compilation + keymap_runtime.rs resolved-map installation and live reload + shortcut_store.rs serialized profile/override persistence + palette.rs root command palette + which_key.rs pending-chord help overlay + application_vim.rs application Vim controller + controller.rs bounded GPUI request/response bridge + +app/src/ui/ + settings/shortcuts.rs shortcut settings projection and recorder + settings/programmability.rs + Settings-only Code Mode/controller lifecycle state + chat/code_mode.rs retained command/lifecycle integration; not mounted + as a chat REPL or HUD + text_input/vim.rs pure composer Vim engine +~~~ + +Rules: + +- maple-harness has no GPUI dependency. +- maple-code-mode has no GPUI, account client, credential store, or action implementation. +- maple-agent does not depend on app. +- app implements the final GPUI/action host and the UiControllerTransport. +- Python never imports or loads Rust/GPUI internals. +- The external protocol and Python SDK use stable semantic action IDs, not Rust type names. + +## 9. Typed semantic action system + +### 9.1 Stable IDs + +Use validated lowercase dotted IDs that survive Rust refactors: + +- app.quit +- settings.open +- task.new +- task.open +- task.set_archived +- project.set_pinned +- sidebar.set_collapsed +- transcript.focus_next +- timeline.set_tool_expanded +- composer.send +- permission.respond +- account.sign_out + +Do not expose names such as chat::NewTask as the permanent Python/model protocol. The registry maps a stable external ID to its GPUI adapter and one executor. + +ID validation should reject empty segments, uppercase characters, whitespace, and ambiguous aliases. Descriptors have a schema version and may later carry deprecated stable-ID aliases. + +### 9.2 Core types + +The implementation should express at least these concepts: + +~~~rust +pub struct ActionDescriptor { + pub schema_version: u16, + pub id: ActionId, + pub label: String, + pub description: String, + pub category: String, + pub argument_schema: serde_json::Value, + pub result_schema: serde_json::Value, + pub contexts: Vec, + pub effect: ActionEffect, + pub invocation_policy: InvocationPolicy, + pub recoverability: Recoverability, + pub audit: AuditSpec, + pub default_bindings: DefaultBindings, +} + +pub enum ActionEffect { + Observe, + Navigate, + MutateMaple, + ExternalEffect, +} + +pub enum InvocationPolicy { + ControllerCallable, + HumanOnly, +} + +pub enum Recoverability { + Ephemeral, + Reversible, + Irreversible, +} + +pub struct ActionCall { + pub action_id: ActionId, + pub arguments: serde_json::Value, + pub target: Option, + pub precondition: Option, +} + +pub struct ActionPrecondition { + pub domain: RevisionDomain, + pub target_revision: u64, +} + +pub struct ActionResponse { + pub invocation_id: InvocationId, + pub action_id: ActionId, + pub status: ActionStatus, + pub result: Option, + pub error: Option, + pub state_revision: Option, +} +~~~ + +Action errors must be structured: + +- unknown_action; +- invalid_arguments; +- not_applicable; +- unavailable; +- policy_denied; +- stale_target; +- cancelled; +- failed. + +An unavailable response includes a stable reason code and human-readable explanation. Current handlers often silently return while busy or when a target is absent; that is insufficient for a command palette or model. + +### 9.3 Trusted invocation context + +The wire request contains only ActionCall. It cannot choose its own actor, transport, controller mode, account, task, program/run, cancellation token, or policy epoch. + +The host constructs a non-deserializable invocation: + +~~~rust +pub struct TrustedInvocation { + pub invocation_id: InvocationId, + pub source_task: Option, + pub program_id: Option, + pub model_run_id: Option, + pub actor: InvocationActor, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub cancellation: CancellationToken, +} + +pub enum InvocationActor { + DirectUser, + Model, + UserCode, + Internal, +} + +pub enum InvocationTransport { + Pointer, + Keybinding, + CommandPalette, + Python, + Macro, + GeneratedUi, + Internal, +} +~~~ + +Actor and transport are separate because model-via-Python and a future trusted +manual Python surface have different provenance while sharing a transport. +Model-triggered Python has actor Model and transport Python. The reserved +UserCode actor does not gain DirectUser authority. Human Only means actor +DirectUser through a private, approved Pointer, Keybinding, or CommandPalette +entry point—not code that claims a human asked it to act. + +Never infer DirectUser merely because a GPUI action handler ran. App::dispatch_action is callable programmatically and GPUI handlers do not prove physical input. Only private UI-host entry points handling an actual pointer event, a resolved physical key event, or explicit command-palette activation may mint a direct-user invocation token. Adapter constructors and those tokens are not public to model, Python, macros, generated UI, or arbitrary internal callers. Add an adversarial test proving programmatic GPUI dispatch cannot invoke Human Only. + +Multi-stroke key resolution needs provenance even when GPUI dispatches a shorter complete binding after its prefix timeout and no physical event is currently on the stack. The window host creates a non-forgeable, window-scoped PendingKeyProvenance token on the first physical keystroke, carries it only through GPUI's current pending sequence, and consumes it when the resolved keybinding adapter dispatches. Focus/context change, explicit cancellation, timeout with no resolved binding, recorder takeover, or sequence completion clears it. A timeout-resolved shorter binding consumes the surviving token and remains DirectUser/Keybinding; App::dispatch_action without that private token does not. + +Internal is not inherently privileged. Derived/internal follow-up actions inherit the initiating actor, transport, controller access, policy epoch, cancellation, program ID, and model run ID. Truly autonomous maintenance operations require a narrow compile-time allowlist and cannot resolve or activate user-selected Human Only actions. + +The Human Only allow matrix is exhaustive: only (DirectUser, Pointer), (DirectUser, Keybinding), and (DirectUser, CommandPalette) qualify. Reject every other actor/transport tuple, including every Internal combination. Autonomous allowlisted maintenance never invokes or indirectly activates Human Only. + +### 9.4 One-path invariant + +The semantic dispatcher owns the only action executor. + +~~~text +button ----------------------+ +typed GPUI key action -------+ +command palette -------------+--> semantic dispatcher --> executor +model/Python request --------+ +future generated surface ----+ +~~~ + +GPUI actions are typed transport adapters into the dispatcher. They are not a parallel business-logic implementation. Maple's semantic registry enriches GPUI actions; it does not compete with GPUI's action catalog. Every bindable semantic action has a one-to-one typed GPUI adapter whose stable external identity maps to the semantic descriptor, and each adapter delegates to the same executor. Do not replace this with one generic Invoke(String, Value) GPUI action: per-action schemas, contexts, keymap discovery, and documentation must remain visible. + +GPUI is the typed input adapter, not the result channel. The semantic executor owns ActionResponse and asynchronous completion. Human GPUI dispatch may discard a response after audit and UI error handling; controller calls reach the same executor through a bounded request/oneshot bridge. Do not infer operation completion from Window::dispatch_action's return. + +Examples of existing divergence that must disappear: + +- The New Task shortcut and sidebar New Task button currently reach new_session through different callbacks. +- The Toggle Sidebar shortcut and header button mutate the same state through different code. +- The Open Settings shortcut and sidebar gear emit through different callbacks. +- Settings controls directly toggle fields without a discoverable typed action. + +Buttons should dispatch a typed semantic action or call the same dispatcher with a host-created DirectUser/Pointer context. The keymap compiler maps stable action IDs and typed arguments to GPUI actions. Model and Python calls validate the same argument schema and enter the same executor directly. + +### 9.5 Parameterized actions + +Every bindable/programmatic parameterized action must support serde deserialization and a JSON schema. Remove no_json from actions such as PickQuestionOption when they enter this system. + +Result schemas matter too. A model program needs to know whether task.new returns a task ID, settings.open returns a semantic screen target, or an async action returns an accepted run identifier. + +### 9.6 Setters over toggles + +Programmatic actions should be deterministic: + +- task.set_archived with task_id and archived; +- project.set_pinned with root and pinned; +- sidebar.set_collapsed with collapsed; +- composer.set_expanded with expanded; +- task.set_web_enabled with task_id and enabled; +- mcp.set_enabled with server_id and enabled. + +Human convenience toggle actions may remain, but should resolve current state and delegate to the setter. Python/model flows must not rely on stale toggles. + +### 9.7 Availability + +Each action has a side-effect-free availability function evaluated against current semantic state and arguments. It returns: + +~~~rust +pub enum Availability { + Available, + Disabled { + code: DisabledReasonCode, + message: String, + }, +} +~~~ + +Availability is not authority. An action can be applicable but denied by policy. It is also not GPUI structural availability alone. A task action may be available for an off-screen stable target even though no row is rendered. + +### 9.8 Revisions and asynchronous effects + +State snapshots and semantic events carry monotonically increasing global revisions for snapshot coherence and event sequencing. Programmatic calls may also supply an action-declared target revision or precondition returned with the queried target. Compare only the revision domain relevant to that action—such as a task record, draft, setting, or timeline item. Do not reject an unrelated task-open or settings call merely because a streaming transcript advanced the global state revision. If the relevant target changed, return stale_target rather than operating on a surprising object. + +For async actions: + +- accepted is not completed; +- the response must expose a run/invocation ID when work continues; +- audit completion occurs when the actual result is known; +- cancellation may prevent later stages but does not pretend to reverse completed effects; +- a policy lease is rechecked immediately before a deferred effect commits. + +### 9.9 Registry validation + +Startup/tests must fail for: + +- duplicate or invalid stable IDs; +- missing labels/descriptions; +- bindable actions without argument schemas; +- missing result schemas for controller-callable results; +- missing GPUI adapters; +- keymap references to unknown IDs; +- default bindings that cannot be parsed; +- unsafe audit defaults on secret-bearing actions. + +## 10. Authority model + +### 10.1 Controller modes + +UiControllerAccess is a new type and must not reuse the existing agent PermissionMode. + +| Controller mode | Observe | Navigate | Mutate Maple | External effect | Human Only | +|---|---:|---:|---:|---:|---:| +| Off | deny | deny | deny | deny | deny | +| Read Only | allow | allow | deny | deny | deny | +| Full Access | allow | allow | allow | allow | deny | + +Direct-user pointer, keybinding, and command-palette invocations are not constrained by the controller mode. They still obey ordinary action availability. + +Full Access intentionally has no per-action confirmation prompts for ordinary controller-callable actions. It is meant to let the model chain useful workflows. The permanent Stop control and audit provide visibility and revocation. + +### 10.2 Initial Human Only set + +At minimum: + +- account.sign_out; +- account deletion; +- email, password, recovery, MFA, credential, token, and session-revocation operations; +- authentication submission/confirmation operations; +- controller mode and Code Mode authority changes; + +Controller and Code Mode authority changes are direct-human-only because: + +- a Read Only controller must not promote itself to Full Access; + +Settings and MCP mutations are ordinary Full Access actions. Sending a message +to another task is an ordinary Full Access action. Viewing or navigating tasks +is Read Only. `permission.respond` is also an ordinary Full Access mutation, +including for a request associated with the originating model run. This is an +explicit product-policy choice for review: permanent account/identity +operations and authority changes remain Human Only. Keep the agent permission +setting separate, display and audit controller-originated responses clearly, +and do not invent a self-approval prohibition without a later product decision. + +app.quit remains controller-callable in Full Access. It is disruptive but not a permanent account-level effect. Mark its descriptor as a terminal host action. Commit the accepted audit record, queue the terminal SDK response, stop new admission, and only then begin orderly shutdown. The caller may observe accepted_terminal rather than a normal post-shutdown completion. Cancellation before admission prevents it; once shutdown is committed it is not reversible. + +### 10.3 Final-action reauthorization + +Generic actions such as ui.activate_selected are useful, but they are never authority shortcuts. + +The dispatcher must: + +1. Resolve the selected semantic object. +2. Resolve its concrete backing action and arguments. +3. Re-evaluate availability. +4. Re-read the current controller policy and epoch. +5. Apply the concrete action's Human Only/effect policy. +6. Invoke the concrete executor. + +Selecting Sign Out and then calling ui.activate_selected must still be denied to Python/model origins. + +### 10.4 Policy revocation + +Policy is revisioned. + +- Turning Python Code Mode Off cancels active executions and stops kernels. +- Downgrading Full Access to Read Only revokes pending mutation leases. +- Turning the controller Off revokes all SDK requests and event waits. +- A previously imported maple_gpui module remains powerless because Rust checks current policy on every request. +- A stale model tool call offered before settings changed fails closed at execution time. + +## 11. Semantic control migration + +There are roughly seventy-six current on_click sites. Not all are semantic, but every discoverable, bindable, auditable, or programmatically useful result must enter the registry. + +### 11.1 Initial catalog + +| Area | Required action family | +|---|---| +| App/screens | app.quit, settings.open, settings.close, settings.open_section, shortcuts.open, code_mode.open | +| Account/auth | account.sign_out and current sign-in/OAuth operations as Human Only | +| Tasks | task.new, task.open, task.rename, task.set_archived, task.focus_previous, task.focus_next | +| Projects | project.choose, project.switch, project.set_collapsed, project.set_pinned, project.rename, project.reveal, project.remove, project.set_trusted | +| Sidebar | sidebar.focus, sidebar.set_collapsed, sidebar.clear_search, sidebar.set_archived_expanded | +| Transcript | transcript.focus, focus_next, focus_previous, focus_first, focus_last, copy_selection, select_all | +| Timeline | timeline.copy_item, timeline.set_tool_expanded, timeline.open_attachment, timeline.toggle_speech | +| Composer | composer.focus, set_text, send, steer, set_expanded, attach_files, remove_attachment, set_model, set_permission_mode, set_web_enabled, set_mcp_enabled, toggle_recording | +| Text input | every Standard/Vim keymap primitive, including movement, selection, deletion, clipboard, undo/redo, newline, and submit actions | +| Composer Vim | typed command families for motion, operator, count digit, text object, insert entry, Visual, paste, undo/redo, repeat, and cancel | +| Runs/queue | run.stop, queue.steer, queue.begin_edit, queue.remove, queue.discard_edit | +| Questions | question.select_option, question.submit, question.skip | +| Permissions | permission.respond as a Full Access mutation; selection remains Read Only | +| Settings | explicit setter for every persisted preference; prompt save/reset; MCP add/update/set_enabled/remove | +| Utilities | ui.dismiss, ui.reveal, link.open, clipboard.copy | +| Code Mode | code_mode.execute, stop, reset, restart, clear_scratch, set_enabled, set_controller_access | + +The agent must inventory current controls against this table and current source, not assume the table is exhaustive if upstream added a control. Every GPUI action referenced by Standard, Vim, or a user keymap—including TextInput primitives and composer Vim command tokens—has a semantic descriptor and appears in shortcut discovery. Focus-local editor primitives may be Human Only and shortcut-only rather than controller-callable, but they cannot live in an undocumented second action universe. + +### 11.2 Stable action arguments + +Never expose: + +- sidebar or timeline vector indices; +- GPUI Entity or FocusHandle values; +- render-only element IDs; +- the currently visible card as an implicit model target; +- draft attachment array indices. + +Use: + +- task/session IDs; +- canonical project roots or stable project IDs; +- timeline item IDs; +- permission/question/request IDs; +- queue IDs; +- DraftImage.id; +- stable settings keys; +- menu IDs plus item IDs. + +### 11.3 Local gesture allowlist + +These may remain local mechanics: + +- drag-selection position updates; +- mouse hit testing; +- hover; +- scrollbar/wheel movement; +- layout measurement; +- cursor painting; +- event propagation wrappers. + +The semantic result still becomes an action. For example, drop hit testing is local, but attaching the resulting file list is composer.attach_files. A backdrop click is local input, but its result is ui.dismiss. + +Keep a checked-in control inventory with, for every inspected control, its source path/symbol, stable action ID and executor, or explicit local-gesture exemption reason. Add a source-audit test/script which inventories direct semantic on_click and comparable activation callbacks in the relevant UI modules and fails on an unexplained site. The script need not solve arbitrary Rust dataflow; a required adjacent semantic-action/local-gesture marker plus registry validation is sufficient. Review fails on an unexplained direct semantic callback. + +Registry tests must also prove stable action ID to App::build_action to the typed GPUI adapter to the semantic executor. GPUI all_action_names enumerates registered action types and Window::available_actions describes structurally available/default-buildable actions; neither is, by itself, the complete parameterized Maple semantic catalog. + +### 11.4 Root ownership + +MapleApp should own or coordinate the dispatcher because it can route to: + +- current ChatScreen; +- parked ChatScreen while Settings is open; +- SettingsScreen; +- LoginScreen; +- root overlays; +- application shutdown. + +Screen-specific executors can remain methods on those entities. They are reached only through the root dispatch table. + +### 11.5 Native argument acquisition + +Actions that need a human-selected path still use the same semantic action +path. `project.choose` and an empty-path `composer.attach_files` request acquire +their arguments through GPUI's native directory or multi-file picker inside +the owning executor; a cancelled picker completes as a successful no-op. +Explicit controller-supplied paths do not open a picker. Project selection may +fall back to the existing Linux portal/manual-entry flow when the native +directory picker is unavailable. Picker acquisition does not bypass policy, +provenance, operation completion, or cancellation barriers. + +## 12. Semantic UI model + +### 12.1 Five independent concepts + +Do not collapse these into one focus field: + +1. GPUI keyboard focus: which rendered element receives events. +2. Semantic selection: which stable application object is selected by application Vim or model navigation. +3. Text insertion/selection: byte/grapheme positions in a TextInput or rich-text selection. +4. Viewport: which virtual rows happen to be visible. +5. Stream follow: whether appended transcript output keeps the viewport at the bottom. + +Streaming may update a selected timeline item's revision without changing its semantic ID. Revealing a semantic selection may move the viewport without changing text insertion. Focusing a TextInput may move GPUI focus without selecting a transcript item. + +### 12.2 Semantic target types + +Use a serializable enum internally and on the controller wire: + +~~~rust +pub enum SemanticTarget { + App, + Screen { screen: ScreenId }, + Region { region: RegionId }, + Project { canonical_root: String }, + Task { task_id: String }, + TimelineItem { task_id: String, item_id: String }, + Annotation { + task_id: String, + item_id: String, + annotation_id: String, + }, + Permission { request_id: String }, + Question { + request_id: String, + question_id: String, + }, + QueueItem { task_id: String, queue_id: String }, + DraftAttachment { draft_id: u64 }, + Setting { key: String }, + MenuItem { menu_id: String, item_id: String }, +} +~~~ + +Use structural serialization on the wire. A display path such as: + +~~~text +task:/timeline:/annotation: +~~~ + +is useful for logs and UI, but should not require fragile string parsing inside executors. + +### 12.3 Semantic snapshot + +ui.describe returns a redacted, versioned projection rather than Rust state: + +~~~rust +pub struct SemanticSnapshot { + pub schema_version: u16, + pub state_revision: u64, + pub event_cursor: u64, + pub screen: ScreenId, + pub active_region: RegionId, + pub selection: Option, + pub insertion: Option, + pub viewport: ViewportSummary, + pub stream_follow: bool, + pub roots: Vec, +} + +pub struct SemanticNode { + pub target: SemanticTarget, + pub kind: SemanticKind, + pub label: Option, + pub state: SemanticNodeState, + pub available_actions: Vec, + pub children: Vec, +} +~~~ + +The snapshot may include task titles and visible transcript semantics needed for navigation. It must never include: + +- passwords; +- OAuth callbacks/tokens; +- secret MCP headers or environment values; +- raw account credentials; +- hidden permission payloads; +- database handles or paths unrelated to the current product view. + +Redaction applies in Read Only and Full Access. + +### 12.4 Semantic selection state + +Recommended application state: + +~~~rust +pub struct SemanticSelection { + pub region: RegionId, + pub target: Option, + pub anchor: Option, + pub explicit: bool, + pub observed_revision: u64, +} +~~~ + +Keep a bounded region-return stack, not one overwrite-prone previous slot. Each frame records screen, region, stable target, reason, and relevant revision. Entering a transient overlay/dialog pushes; closing it pops exactly that frame. Moving from an application region into the composer pushes the originating region once; repeated composer mode changes do not keep pushing. Composer Normal Escape pops back to that region. gi stores/restores the composer insertion separately and does not pop an unrelated overlay frame. + +If a stored target no longer exists, restore the region and its nearest valid selection. With no prior application region, composer Escape falls back to Transcript in Chat. gi is unavailable outside Chat. Its per-task insertion point carries the draft revision; after draft/task replacement, validate and clamp it to a grapheme boundary, or fall back to the end of the current draft rather than using a stale byte offset. + +### 12.5 Virtualized transcript rules + +Store transcript selection as task ID plus timeline item ID, never as row index. + +Each region exposes one ordered navigable-child projection separate from its raw backing collection. j/k/gg/G and semantic query use that projection. A child is eligible only when it has a stable target and a visible/revealable rendering with nonzero semantic presence. Skip internal todo/state items, filtered objects, empty placeholders, zero-height rows, and implementation-only records unless they deliberately render an accessible semantic object. Ordering matches visible application order. "Newest" means the last eligible projected object; "newest assistant" means the last eligible assistant message object, including a currently streaming assistant message once its semantic row exists. Tests must include hidden and zero-height backing items so selection can never land on an object with no highlight or reveal target. + +Resolve through timeline_index immediately before: + +- reveal; +- copy; +- activate; +- move to neighboring item; +- find annotations/assistant items. + +Reconciliation: + +- Same item ID with a newer content revision: retain selection. +- Items inserted before the target: retain target and resolve its new index. +- Selected item removed: choose the nearest surviving neighbor using the previous ordered-ID list; then fall back to the region itself. +- Task switch: restore that task's prior semantic selection if still valid. +- Selecting away from newest: set follow_transcript false. +- Explicit G or jump-to-latest: select/reveal newest and re-enable follow. +- New chunks never implicitly re-enable follow. +- Filtering may hide a valid target without deleting it; reveal may explicitly clear the filter. + +Do not splice/re-measure the newest streaming item in a way that regresses the repository's existing wheel-scroll protection. + +### 12.6 Sidebar, settings, menus, questions, and permissions + +- Sidebar task rows resolve by task ID. +- Project headers resolve by canonical root. +- Settings rows resolve by stable setting/action key. +- Menus resolve by stable menu ID plus item ID. +- Questions and permissions resolve by request IDs. +- Selecting a permission is Read Only navigation. +- Responding to it is a separate mutation: direct users may invoke it, Read Only cannot, and Full Access controllers may invoke it under the explicit policy in Section 10.2. + +### 12.7 Semantic events + +Expose a bounded, sequence-numbered event stream: + +- screen_changed; +- region_changed; +- semantic_selection_changed; +- target_updated; +- target_removed; +- task_opened; +- action_started; +- action_completed; +- controller_policy_changed; +- code_mode_state_changed. + +events.wait accepts a predicate, an after cursor, and a timeout. This prevents the classic query-then-wait race: + +1. ui.describe returns event_cursor N. +2. Invoke an action. +3. Wait after N. + +If the bounded event buffer overflows, return a resync_required error and require a fresh ui.describe. Do not encourage polling loops. + +## 13. Shortcut profiles and keymap format + +### 13.1 Profile semantics + +Use these first-party profiles: + +- Standard +- Vim + +The resolver is: + +~~~text +selected Standard OR Vim template + | + v + user overrides + | + v + one complete GPUI keymap +~~~ + +Do not install Standard and then overlay Vim. Vim replaces Standard so its modal grammar does not fight ordinary shortcuts. Replacement does not mean ordinary controls stop working: both templates independently include the complete essential keymap for every non-composer input role. If Vim wants Secondary-C in Insert, or ordinary editing in search/login/rename/settings, those bindings must be explicit in the Vim template. + +Changing profile must: + +1. Parse/validate the target template and overrides. +2. Build a complete resolved map, including TextInput essentials. +3. If valid, clear and reinstall all GPUI bindings atomically. +4. Update key contexts and modal state. +5. If invalid, keep the last-known-good map and show errors. + +### 13.2 Persistence + +Persist the selected profile and simple preferences in settings.json: + +~~~json +{ + "keymap_profile": "vim", + "vim_leader": "space" +} +~~~ + +Keep editable overrides under the platform config root documented in README: + +~~~text +/keymap.json +~~~ + +Use a Zed-style JSON array: + +~~~json +[ + { + "context": "MapleApp && profile == vim && region == transcript && app_vim_mode == normal", + "bindings": { + "j": "transcript.focus_next", + "k": "transcript.focus_previous", + "space s n": "task.new", + "secondary-k": null, + "ctrl-enter": [ + "composer.send", + { "steer": true } + ] + } + } +] +~~~ + +Rules: + +- A string is an action with default/no arguments. +- An array is a stable action ID plus typed argument object. +- null disables that sequence in the matching context. +- A sequence is space-separated GPUI keystrokes. +- Context is a GPUI-compatible boolean predicate. +- Unknown fields and invalid action arguments produce surfaced diagnostics. +- Never silently rewrite or discard an invalid hand-edited file. +- Settings UI edits write atomically and preserve a useful formatted representation. + +### 13.3 Precedence and conflict model + +Within one active context: + +- More specific/deeper matching contexts win. +- At equal specificity, later user entries win. +- User overrides load after the selected template. +- null is a real high-precedence unbinding. + +The resolver retains a ResolvedBinding side table with: + +- source template/file and source location; +- context string and parsed predicate; +- key sequence; +- stable action ID and arguments; +- default binding; +- effective/shadowed/disabled state; +- exact/prefix/possible conflicts. + +Conflict detection covers: + +- identical sequences with overlapping contexts; +- one sequence being a prefix of another; +- duplicate user entries; +- invalid IDs or arguments; +- impossible action/context combinations; +- deliberate user shadowing of a template. + +Arbitrary context-overlap proof is difficult. When disjointness cannot be proven, report a possible conflict instead of pretending certainty. + +Prefix bindings are valid. Follow GPUI/Zed behavior: wait briefly for a longer sequence when a shorter complete binding is also a prefix. + +### 13.4 Context tree + +Enrich the existing contexts. A representative tree is: + +~~~text +MapleApp os=macos profile=vim screen=chat app_vim_mode=inactive + Chat + Sidebar region=sidebar app_vim_mode=normal popup=none + Transcript region=transcript app_vim_mode=normal popup=none + Composer region=composer app_vim_mode=inactive + TextInput input_role=composer editor_vim_mode=insert + Overlay overlay=command_palette + Dialog dialog=question +~~~ + +Other input roles include: + +- password; +- login; +- search; +- rename; +- settings; +- prompt; +- question; +- mcp_editor; +- code_editor. + +Application and editor modes are separate keys. Never reuse a broad vim_mode=normal predicate for Sidebar, Transcript, and composer Normal. app_vim_mode is normal only while the application controller owns unmodified navigation; editor_vim_mode is normal/insert/visual only on the opted-in composer. Overlays and dialogs add higher-priority contexts. Do not depend on render-updated key-context fields for operator/count/text-object grammar between rapid keystrokes; the focused editor action handler resolves contextual command tokens directly against its current VimState. + +Only input_role=composer receives the composer Vim engine in the first implementation. Context-specific application bindings must not leak into other inputs. Every ordinary input role retains its complete TextInput essentials while the Vim profile is active, including cursor movement, selection, deletion, clipboard, undo/redo, IME, submit/cancel, and its surface-specific keys. + +### 13.5 Standard template + +Preserve current ordinary behavior, including platform modifier variants and TextInput editing. Add discoverable defaults such as: + +- Secondary-Shift-P: command palette (Cmd-Shift-P on macOS, Ctrl-Shift-P elsewhere). +- Secondary-K Secondary-S: shortcut settings. +- Existing New Task, search, sidebar, archived, settings, project, permission, transcript copy/select-all, menu, and TextInput bindings. + +The exact existing shortcuts should be migrated, not casually changed. The Standard template includes ordinary TextInput essentials for all input roles. + +### 13.6 Vim template + +The Vim profile has its own complete template: + +- Application Normal commands described below. +- Composer modal bindings described below. +- Explicit platform editing bindings in Insert where desired. +- Colon command palette. +- Space leader. +- Ctrl-W region movement. +- Ordinary TextInput essentials for input_role != composer, independent of the Standard template. + +GPUI resolves every remappable stroke and static multi-stroke chord. Application Vim owns semantic selection and count state, but it does not run an independent raw g/bracket/leader chord parser. Composer Vim receives typed command/token actions produced by the effective keymap; it does not hard-code physical h/j/k/l/d/etc. Count digits are typed tokens; the state-aware editor resolver applies the context-sensitive 0 rule synchronously, so the shortcut registry can display and remap them without waiting for a render. The only raw composer interception is the final safety/IME guard that prevents an unmatched printable key from inserting in Normal/Visual and cancels invalid pending grammar. + +Secondary-J/Secondary-K may be user-configurable aliases but are not foundational defaults. The core spatial grammar is Ctrl-W h/j/k/l. This avoids importing tmux shortcuts literally and keeps key choices editable. + +## 14. Shortcut settings UX + +Add a Keyboard Shortcuts settings section generated from the semantic registry. + +### 14.1 Page layout + +Header: + +- Search field accepting label, stable ID, description, category, or keystroke. +- Profile dropdown: Standard or Vim. +- Current state: Default or Modified. +- Reset Profile. +- Open keymap.json. +- Filters: All, Conflicts, Modified, Unbound. + +Each action row shows: + +- human label; +- stable action ID; +- description; +- category; +- supported semantic contexts; +- effect classification; +- current bindings; +- selected-template defaults; +- conflict badges; +- unbound/disabled status; +- unavailable reason when evaluated in the current screen; +- Record/Add; +- Disable; +- Reset action. + +Parameterized actions show their bound arguments. The page itself is keyboard navigable and participates in application Vim. + +### 14.2 Shortcut recorder + +The recorder: + +- activates only after a direct-user click/command; +- intercepts keystrokes and stops normal dispatch while recording; +- records a bounded multi-stroke sequence; +- displays the sequence live; +- Enter commits; +- Escape cancels; +- Backspace removes the latest stroke; +- Clear creates a null override when requested; +- shows exact, prefix, and possible conflicts before commit; +- lets the user replace, keep both with a narrower context, or cancel. + +Use GPUI keystroke interception. Do not make the recorder a global permanent interceptor. + +### 14.3 Reset behavior + +- Reset action removes overrides affecting that action and reveals the current template binding. +- Disable writes an explicit null mapping. +- Reset Profile removes all overrides only after direct-user confirmation in the settings UI. +- Switching templates does not delete user overrides; inactive-context overrides remain visible. + +Named custom profiles can wait. Standard/Vim plus arbitrary overrides are sufficient initially. + +## 15. Command palette and which-key + +### 15.1 Root command palette + +This is distinct from the current composer slash-command palette. + +It searches: + +- action labels; +- stable IDs; +- descriptions; +- categories; +- current keybindings; +- optional Ex-style aliases. + +It shows: + +- current availability and disabled reason; +- action effect/recoverability; +- current shortcut; +- inferred semantic target; +- argument UI for the small number of actions that cannot infer arguments. + +Invocation is a trusted DirectUser/CommandPalette call to the same dispatcher. + +Defaults: + +- Secondary-Shift-P in Standard. +- colon in application Vim Normal. + +The first colon layer can provide aliases such as: + +- settings; +- shortcuts; +- task new; +- project open; +- code; +- quit. + +This is not a promise of a full Vim Ex parser. Colon primarily opens and filters the real action palette. + +### 15.2 Which-key + +Compile effective bindings into a prefix trie. + +When GPUI reports pending multi-stroke input: + +- wait roughly 250 to 400 ms so fast sequences do not flash; +- filter continuations against the current context stack; +- show next keys, labels, and action IDs; +- include the configurable Space leader; +- disappear on completion, cancellation, focus/context change, or timeout. + +The first implementation is a passive preview. GPUI exposes pending input but not a public arbitrary clear operation; do not replace GPUI's dispatcher with a clickable which-key engine. + +## 16. Application Vim + +### 16.1 Ownership + +Application Vim is a controller over semantic actions. It does not synthesize keys and does not reuse the composer editing engine. + +It owns: + +- active semantic region; +- selected semantic target; +- bounded region-return stack; +- count prefix; +- current application mode/context. + +GPUI owns remappable single- and multi-stroke resolution for g, bracket, Ctrl-W, and leader sequences. Application Vim consumes the resulting typed semantic actions. GPUI focus moves only as necessary for keyboard routing. Selection and viewport remain separate. + +### 16.2 Required navigation + +| Intent | Default Vim command | +|---|---| +| Previous/next semantic item in region | k / j | +| First/last semantic item | gg / G | +| Newest assistant response | ga | +| Previous/next assistant response | left-bracket a / right-bracket a | +| Previous/next annotation | left-bracket d / right-bracket d | +| Return to composer last insertion point | gi | +| Move among adjacent regions | Ctrl-W h/j/k/l | +| Search active surface | slash | +| Open action command line/palette | colon | +| Open application command namespaces | configurable Space leader | +| Activate/open selected object | Enter | +| Collapse/parent or expand/child where meaningful | h / l | +| Close top overlay or return to containing surface | Escape | + +Counts apply to j/k and semantic next/previous families where useful. + +The d annotation mnemonic deliberately follows Vim's diagnostic navigation convention. ga is an explicit Maple mnemonic for the newest assistant item. + +### 16.3 Region behavior + +Transcript: + +- j/k move among semantic timeline objects, not rendered lines. +- h/l collapse/expand tool/reasoning/details where applicable. +- Enter opens/activates the selected object. +- y copies canonical visible text where a copy action exists. +- G selects/reveals newest and re-enables stream follow. + +Sidebar: + +- j/k move among task and project rows. +- h collapses or moves to parent. +- l expands or moves to child. +- Enter opens the task/project. + +Settings: + +- j/k move rows. +- h/l may change segmented options only when that row declares such behavior. +- Enter activates the row or enters its control. + +Menus/questions/permissions: + +- j/k move options. +- Enter activates the selected safe/direct-user action. +- Escape closes or returns. +- Permission responses remain separate mutations; Full Access controller behavior follows Section 10.2 and is conspicuously audited. + +Composer: + +- composer-specific Normal commands edit text. +- Ctrl-W movement or ga leaves the composer region through application actions reserved in the composer-Normal keymap context. +- a second Escape from composer Normal returns to the previous application region. + +### 16.4 Leader defaults + +Space is the default leader and is configurable. Initial namespaces should be small and discoverable: + +- Space s: tasks; +- Space p: projects; +- Space a: agents/assistant/annotations; +- Space m: MCPs; +- Space comma: settings; +- Space question-mark: which-key/help. + +Examples such as Space s n for task.new are appropriate. Do not fill every possible sequence merely for completeness; the registry and which-key should make additions easy. + +### 16.5 Streamed-response experience + +When output streams while the composer is active: + +- GPUI insertion focus does not move. +- Composer cursor/selection does not move. +- An explicit transcript selection does not move. +- If the viewport was following newest content, it may continue following. +- If the user navigated away, chunks do not repin it. +- ga explicitly selects the newest assistant object. +- gi returns to the composer and enters Insert at its stored insertion point + when the draft revision still matches, or at the end of a replaced draft. + +### 16.6 Application object operators + +Do not assign generic d/c/p mutations across application objects until each region has an explicit safe meaning. For this milestone: + +- navigation, activation, reveal, and copy are required; +- mutations remain named actions available through palette/leader/keymap; +- a future dd for task archive must call task.set_archived and preserve its action policy. + +This avoids making d mean deletion on one screen, archive on another, and permission denial on a third. + +## 17. Composer Vim + +### 17.1 Scope and integration + +Implement a pure editor engine in app/src/ui/text_input/vim.rs. TextInput holds Option. + +Provide an opt-in builder and live setter: + +~~~rust +TextInput::new(...).vim_enabled(true); + +input.set_vim_enabled(enabled, cx); +input.vim_mode() -> Option; +input.vim_status() -> Option; +~~~ + +Only the main chat composer opts in initially. Password, login, sidebar search, rename, question, prompt, settings, MCP editor, and other TextInput instances remain ordinary text inputs. + +Disabling Vim at runtime: + +- cancels editor count/operator/text-object pending state; +- invalidates the window PendingKeyProvenance and keymap generation so a delayed GPUI g/leader/bracket prefix adapter from the old profile is rejected even if GPUI has not exposed an explicit clear-pending API; +- exits Visual; +- commits or safely terminates any insertion transaction; +- collapses selection to a valid insertion boundary; +- restores current Standard behavior immediately. + +### 17.2 State + +Required persistent modes: + +~~~text +Disabled +Normal +Insert +Visual characterwise +~~~ + +Orthogonal/transient state: + +- count prefix; +- pending operator d/c/y; +- pending text-object prefix i/a; +- Visual anchor and head; +- unnamed register plus characterwise/linewise kind; +- preferred logical column for repeated j/k; +- current insertion undo transaction; +- last structured mutating command for dot; +- last composer insertion location for application gi. + +Use UTF-8 byte offsets at the TextInput boundary. Compute character motions on Unicode grapheme boundaries. Never split a scalar, combining sequence, or emoji cluster. + +Normal's logical cursor rests on a grapheme. Insert uses a boundary between graphemes. Visual is internally inclusive and converts carefully to TextInput's existing end-exclusive selected_range. + +### 17.2.1 Typed Vim command actions + +The pure editor is a state machine over typed commands, not raw key identities. Register descriptor-backed, remappable action families such as: + +~~~text +composer.vim.motion { motion } +composer.vim.begin_operator { operator } +composer.vim.count_digit { digit } +composer.vim.text_object_prefix { inner_or_around } +composer.vim.text_object { object } +composer.vim.enter_insert { placement } +composer.vim.open_line { above_or_below } +composer.vim.toggle_visual +composer.vim.delete_chars +composer.vim.paste { placement } +composer.vim.undo +composer.vim.redo +composer.vim.repeat +composer.vim.cancel +~~~ + +The implementation should use ordinary registry descriptors and typed GPUI action structs. Each command is visible/remappable in Shortcut Settings with its editor mode. For keys whose meaning is grammar-dependent, bind a typed contextual token and let the focused engine resolve it synchronously against current VimState in the same handler—for example i means enter-insert normally and inner after an operator, a means append normally and around after an operator, w means motion normally and Word after an i/a text-object prefix, a second d completes a linewise delete, and 0 means line-start or count digit according to stored count. Correctness must not depend on a key-context update, notification, or render occurring between physical keys. + +All countable command tokens carry no baked-in count. They consume the engine's accumulated count exactly once, defaulting to one; operator and motion counts are stored separately and multiply. Thus 3x deletes three graphemes rather than multiplying a count embedded in x, and d2d applies the operator/count grammar exactly once. + +Static multi-stroke ownership remains in GPUI. In composer Normal, the template resolves: + +- g g to the local first-line motion, consuming the editor count; +- g a to transcript.focus_newest_assistant and leaves the composer; +- g i to composer.focus_last_insertion, which enters Insert even when already in the composer; +- left-bracket/right-bracket a/d, colon, slash, Space leader, and Ctrl-W h/j/k/l to application actions; +- operator then motion/text-object as typed commands interpreted by editor state. + +Neither GPUI and the editor nor application Vim and the editor may simultaneously own the same pending prefix. Unknown or invalid input while an operator/count/text-object is pending is consumed, cancels the pending grammar, reports a short status, and never inserts text, sends, or propagates to Chat. Escape always cancels pending grammar to composer Normal. Cap a parsed count at a documented constant such as 999_999 and report overflow rather than wrapping. A leading 0 is line-start when no count exists and a digit once a nonzero count exists. d2d is linewise delete with count multiplication. + +Editor grammar has no wall-clock timeout: like Vim, a completed d/count/i-or-a prefix waits for its next semantic token until completion, Escape, invalid input, focus loss, task/draft replacement, profile change, or popup takeover. GPUI's timeout applies only to static keymap chords such as gg versus another configured g sequence. Which-key may render editor-pending grammar from VimState separately from GPUI's static pending trie. + +### 17.3 Required motions + +- h: previous grapheme in the logical line. +- l: next grapheme in the logical line. +- j: next logical line while preserving preferred grapheme column. +- k: previous logical line while preserving preferred grapheme column. +- w: beginning of the next lexical run. +- b: beginning of current or previous lexical run. +- e: end of current or next lexical run. +- 0: first column of the logical line. +- dollar: end of the logical line. +- gg: first line. +- count gg: one-based target line. +- G: last line. +- count G: one-based target line. + +Use three predictable lexical classes: + +- whitespace; +- Unicode alphanumeric plus underscore; +- punctuation/other. + +Arrow keys may alias motions while composer Vim is active. + +Normative boundaries: + +- A logical line excludes its newline separator. dollar lands on the final grapheme of a nonempty line, never on the newline or insertion boundary. On an empty line it remains at that line's stable empty Normal position. +- w, b, and e cross logical newlines as whitespace boundaries and then reach the next/previous meaningful lexical run. At document bounds they clamp/no-op. +- An empty document has one virtual empty logical line and a Normal cursor at byte boundary zero. A Normal cursor at end-of-line uses the last grapheme when one exists; the empty-line block is a visual insertion-boundary sentinel, not an invalid byte index. +- A motion returns a semantic endpoint plus characterwise/linewise and inclusive/exclusive metadata. Operators consume that metadata; do not reconstruct ranges from cursor arithmetic. + +### 17.4 Insert commands + +- i: insert before cursor. +- a: insert after cursor. +- I: first non-whitespace insertion point. +- A: logical line end. +- o: create a line below and enter Insert. +- O: create a line above and enter Insert. +- Escape: complete the insertion/change transaction and return to composer Normal. + +o/O should copy the current line's leading indentation. + +Maple-specific Enter behavior: + +- Plain Enter sends the current draft in Insert and Normal, preserving existing chat ergonomics. +- Shift-Enter inserts a newline only in Insert. +- o/O provide Vim-native newline creation without sending. + +### 17.5 Operators and counts + +Required operators: + +- d delete; +- c change and enter Insert; +- y yank to the unnamed register. + +They compose with every milestone motion. + +Repeated operators are linewise: + +- dd; +- cc; +- yy. + +Rules: + +- Operator and motion counts multiply. 2d3w affects six word motions. +- e and dollar targets are inclusive. +- w targets are exclusive. +- cw special-cases a non-whitespace starting position to the equivalent of ce, matching ordinary Vim expectations rather than consuming following whitespace. On whitespace it follows the normal c plus w target. +- j, k, gg, and G targets are linewise. +- c starts one insertion transaction after deletion. +- cc leaves one editable line and should retain leading indentation. +- dd handles first line, last line, and final newline correctly. +- yy produces a linewise register. + +Other required changes: + +- x deletes count graphemes into the unnamed register. +- p/P paste after/before for characterwise content. +- p/P paste below/above for linewise content. +- Counts apply to x, p, P, motions, operators, and dot. +- u undoes count complete Vim changes. +- Ctrl-R redoes count complete changes. + +The unnamed register is required even though full named-register compatibility is deferred. Secondary-C/Secondary-V remain system clipboard operations where the selected profile binds them. + +Linewise behavior is normative: + +- A linewise range includes complete addressed logical lines and their separator when one exists. +- Deleting/yanking a nonfinal line captures its trailing newline. Addressing the final line of a multi-line document without a final newline uses the preceding separator so no orphan blank line remains. +- dd on the only line leaves an empty document. cc on any line leaves one editable line at that location and preserves that line's leading indentation; it enters Insert at the first non-whitespace insertion point. +- After linewise delete, the cursor lands on the first grapheme of the next surviving line, or the previous final line if there is no next line, with the empty-line sentinel rule above. +- A linewise p inserts complete lines below and P above; the cursor lands on the first non-whitespace grapheme of the first pasted line. Behavior is identical whether the document originally ended in a newline. +- dw at the final word deletes to the end of that word. Immediately before a newline, it does not unexpectedly join lines unless the computed w motion actually crosses into the next lexical run; tests pin both cases. + +### 17.6 Visual + +- v enters or toggles characterwise Visual. +- Milestone motions extend the selection. +- d, x, c, and y act immediately. +- p/P replace the selection from the register. +- Escape exits Visual without changing text. +- iw/aw work as Visual targets as well as operator targets. + +Entering v selects the current grapheme immediately; on an empty document it selects the stable empty sentinel without creating an invalid range. Anchor and head are inclusive grapheme positions, so reverse selections include both endpoints when converted to TextInput's end-exclusive range. After Visual y, collapse to Normal on the former selection start. After d/x, use the deletion cursor rule. After c, enter Insert at the deletion start in the same change transaction. + +Visual p/P replaces the selection with the pre-command unnamed register and then stores the displaced selected text in the unnamed register, matching Vim's swap-like register behavior. The placement distinction does not move insertion outside the selected range for characterwise Visual replacement. Add single-grapheme, reverse-direction, combining-mark, and emoji tests. + +Visual Line, Visual Block, multiple cursors, and named registers are deferred. + +### 17.7 Text objects + +iw: + +- selects the current lexical run; +- on whitespace, selects the next meaningful run when possible; +- uses semantic boundaries, not a remembered byte range. + +aw: + +- selects iw plus trailing whitespace; +- when no trailing whitespace exists, includes leading whitespace; +- preserves predictable behavior at line/document boundaries. + +This semantic representation is necessary so ciw can repeat on a differently sized word. + +### 17.8 Dot repeat + +Dot is required and must repeat the last complete mutating semantic command. + +It must not replay raw key events. + +Required examples: + +~~~text +ciwhello then dot changes the next current word to hello +A! then dot appends ! at another logical line +3x then dot deletes three graphemes again +ohello then dot opens another line containing hello +~~~ + +Use structured recipes such as: + +~~~rust +pub enum RepeatRecipe { + Insert { + entry: InsertEntry, + inserted: InsertDelta, + }, + Operator { + operator: Operator, + target: MotionOrTextObject, + count: usize, + inserted: Option, + }, + DeleteChars { + count: usize, + }, + Paste { + placement: PastePlacement, + register: RegisterSnapshot, + count: usize, + }, + VisualChange { + operator: VisualOperator, + grapheme_span: usize, + register: Option, + inserted: Option, + }, + OpenLine { + placement: OpenLinePlacement, + indentation: IndentationPolicy, + inserted: InsertDelta, + }, +} +~~~ + +Use exactly one recipe for each change. o/O use OpenLine, not Insert, so there is no overlapping representation. Store semantic targets such as InnerWord, not original offsets. Store the committed insertion delta, not key events, so IME input, keyboard layout, and custom bindings do not alter repeat behavior. + +Visual d/c/p also update last_change. Normalize the inclusive selection to a grapheme_span independent of original direction/offset. Repeat applies the same operator to that many graphemes beginning at the new Normal cursor, using the saved pre-command register for paste and the committed InsertDelta for change. If fewer graphemes remain, clamp according to the ordinary deletion/change boundary rule; zero-span/empty-sentinel mutations that change nothing do not replace last_change. + +InsertDelta is a normalized edit script relative to the insertion entry point, not merely the final inserted string. It records ordered committed text replacements/deletions and cursor-relative edits produced during the transaction, including Backspace/Delete, selection replacement, cursor movement followed by edit, and IME committed replacement. It does not record raw physical keys or uncommitted composition. Replaying applies the normalized edits to the new semantic entry point; if a required local precondition cannot be satisfied, the repeat fails harmlessly without corrupting text. + +Additional rules: + +- count dot runs the saved recipe that many times without replacing it; +- motions, yanks, mode transitions, undo, and redo do not replace last_change; +- a no-op change does not become last_change; +- one dot command, including a count such as 2., is one undo unit for the entire counted replay; +- a failed repeat leaves last_change intact and reports a harmless status. + +### 17.9 Undo transactions + +The current TextInput snapshot/typing-run history is not sufficient for Vim composition. ciwhello Escape must undo as one change, not a deletion plus multiple insertion records. + +Add an explicit edit transaction: + +1. Begin on i/a/I/A/o/O/c. +2. Lazily record the pre-change snapshot at first mutation. +3. Suppress nested history entries while Insert continues. +4. Include the operator deletion and insertion in one transaction. +5. Commit on Escape. +6. If nothing changed, add no history entry. +7. Clear redo on a new committed edit. +8. Treat dot replay as one nested-disabled transaction. + +set_text, send/clear, disabling Vim, task replacement, and external draft restoration must deliberately terminate/reset transient Vim state. + +Use this deterministic termination contract: + +| Event during Insert/change | History | last_change | register | resulting Vim state | +|---|---|---|---|---| +| Escape | commit one transaction if mutated | update from committed semantic recipe | preserve/update only if operator changed it | Normal | +| Send or clear after successful send | commit draft edit for local history, then clear/reset task draft history | update only from the completed edit; do not make send a dot recipe | preserve | Normal on the new empty draft | +| composer.set_text or external draft restore | close current transaction without synthesizing a repeat recipe, replace text, clear undo/redo tied to the old draft | clear | preserve only if its contents are independent text; otherwise clear deliberately | Normal with clamped cursor | +| Task/screen switch | commit a real local edit into that task's draft, cancel all pending grammar, store insertion revision | keep that task's committed recipe only while its draft revision remains compatible | retain an unnamed register per task/draft; never expose it in another task | inactive until restored | +| Switch to Standard/disable Vim | commit a real edit, cancel grammar/Visual, collapse safely | retain only for restoration if the same draft revision returns; otherwise clear | preserve for same draft only | Disabled | +| Mouse click/caret move in composer | commit current insertion transaction if mutated, cancel operator/count/text-object/Visual, translate to the clicked grapheme or empty sentinel | retain the just-committed recipe | preserve the current task's register | Normal; never half-pending | + +Queue draft edit/restore follows the external-draft rule and carries a draft revision. No transient byte offset, undo entry, or dot recipe may be applied to a different task or incompatible draft revision. + +Mouse policy is intentionally Vim-like for this milestone: a direct click inside the composer places the block cursor and enters Normal, regardless of the previous editor mode. A click outside commits any active insertion transaction, leaves the editor in inactive Normal, pushes/reconciles the destination application region, and moves GPUI focus off TextInput. Double/triple-click rich selection behavior is deferred rather than leaving an ambiguous Visual/Insert state. + +### 17.10 Key interception and existing composer behavior + +Typed Vim command handlers run: + +1. after a truly modal popup has consumed its keys; +2. before ordinary TextInput actions and platform text insertion. + +In Normal and Visual: + +- consume unknown unmodified printable characters; +- do not allow them to fall into GPUI text insertion; +- make arrow/Home/End/Backspace/Delete action handlers mode-aware. + +The raw-key hook is only this unmatched-printable/IME safety guard. It must not hard-code command bindings or count digits. Remapping composer.vim.motion or composer.vim.begin_operator in keymap.json must change actual behavior, remove the old effective binding, update conflicts/which-key, and leave the pure engine unaware of the physical key. + +The existing composer on_key hook handles slash-palette movement and prompt-history Up/Down. Gate it: + +- Insert: current slash/history behavior remains. +- Normal/Visual: Vim owns printable commands and motions. +- Focused menus/questions/permissions: their modal context shadows composer Vim. + +The current Chat type-to-compose behavior must be disabled in application Vim Normal or it will turn j/k/g/leader into draft text. Preserve it in Standard. + +Escape precedence: + +- marked IME composition resolves/cancels safely first; +- Insert Escape enters composer Normal and stops propagation; +- Visual/operator Escape cancels to composer Normal; +- composer Normal Escape enters application Normal/restores prior region; +- only an unconsumed Chat-level Escape may reach existing menu-close/run-stop behavior. + +### 17.11 Mode UI + +Render a compact composer badge: + +- NORMAL; +- INSERT; +- VISUAL; +- optionally NORMAL · 3d while a count/operator is pending. + +Do not show it in Standard. + +Cursor: + +- Insert: current thin caret. +- Normal: block covering the current grapheme. +- Visual: selection highlight plus a distinct head. +- Empty/end-of-line Normal: stable minimum-width block. + +Mode-only changes must notify/render even when draft text did not change. + +### 17.12 Composer/application transition + +- Vim profile composer starts in Normal. +- i/a/I/A/o/O enter Insert. +- Insert Escape returns to composer Normal. +- Ctrl-W region motion or ga can leave composer Normal for application navigation. +- Composer Normal Escape returns to the previous semantic region. +- Application gi focuses the composer and enters Insert at the saved insertion point. + +This gives both real composer Normal mode and the fast streamed-response navigation that motivated the feature. + +### 17.12.1 Focus and routing table + +The following is normative. "Focus owner" means the GPUI element that receives the next dispatch, not merely a semantic highlight. + +| Current state/input | Consumer | Focus owner after | Semantic region/mode after | Propagation | +|---|---|---|---|---| +| Composer Insert, Escape | composer editor | TextInput | composer, editor Normal | stop | +| Composer Visual or pending operator/count, Escape | composer editor | TextInput | composer, editor Normal with pending state cleared | stop | +| Composer Normal, Escape | application transition action | prior region focus proxy, Transcript fallback | popped prior region, application Normal | stop after focus moved off TextInput | +| Application region, gi | application action | TextInput | composer Insert at revision-checked last insertion | stop | +| Composer Normal, ga | transcript action | transcript/application focus proxy | Transcript at newest assistant | stop | +| Composer Normal, Ctrl-W h/j/k/l | region action | destination region focus proxy | destination application region | stop | +| Task/screen switch | root semantic controller | new screen's region focus proxy, or TextInput only if explicitly restored | reconciled selection; transient editor grammar cancelled | stop | +| Popup/dialog opens | popup/dialog | popup/dialog focus | underlying app/editor state suspended | shadow underlying bindings | +| Popup/dialog closes | popup/dialog then return stack | exact validated prior focus proxy | prior region/editor state, with stale targets reconciled | stop | + +Before leaving composer Normal, move GPUI focus off TextInput synchronously so the next application key cannot re-enter the editor. Overlays shadow both application and composer maps. At application root, Escape reaches the existing Chat close/run-stop behavior only when no overlay, dialog, pending Vim grammar, region-return transition, or editor transition consumed it. + +Key ownership while composer Normal is focused: + +- local editor: h/j/k/l, w/b/e, 0/dollar, gg/G, operators, text objects, counts, v, x, p/P, u/Ctrl-R, dot, i/a/I/A/o/O; +- reserved application: ga, gi, bracket a/d, Ctrl-W h/j/k/l, colon, slash, Space leader; +- colon opens the root action palette; slash opens the current application's search action, using Transcript/task search as the Chat fallback. It is not a composer Vim text-search grammar in this milestone and never inserts slash in Normal; +- Enter sends the draft; Shift-Enter is unavailable in Normal/Visual and must not insert or send; +- Visual Enter is unavailable and consumed; +- popup/dialog bindings win over both groups. + +The effective keymap, not a second parser, implements this precedence. A shorter prefix and longer complete chord use GPUI's pending-input timeout and which-key display. + +### 17.13 Composer tests + +Pure-engine tests must cover: + +- ASCII, combining characters, emoji, punctuation, whitespace, empty and multiline text; +- preferred column across uneven lines; +- 0 versus count parsing, including 10j; +- gg/G with and without counts; +- motion inclusivity/exclusivity; +- cw special case, word motions across newlines, empty documents, and final-newline operations; +- 2d3w; +- dd/cc/yy at first/last lines and trailing-newline boundaries; +- iw/aw on words, punctuation, and whitespace; +- characterwise/linewise register and p/P; +- forward/reverse Visual selections; +- single-grapheme/reversed Unicode Visual selection and Visual paste register replacement; +- undo/redo transaction boundaries; +- dot for insert, append, open line, ciw, delete, paste, Visual d/c/p, insertion Backspace/replacement/IME commit, and counted repeat including one-unit 2.; +- no-op commands not affecting history or dot. + +GPUI integration tests must prove: + +- Standard TextInput behavior is unchanged. +- Only the composer opts in. +- Search, login, rename, settings, question, and MCP inputs keep ordinary editing while the Vim profile is active. +- Normal printable commands do not insert. +- Insert still uses EntityInputHandler and IME. +- Escape precedence is correct. +- Enter/Shift-Enter behavior matches the contract. +- Slash palette and prompt history still work. +- Live profile switching safely resets state. +- Mode badge and cursor follow state. +- One u undoes all of ciwhello Escape. +- Remapping a composer motion/operator changes the real command, disables the old binding, and updates Shortcut Settings/which-key. +- Composer Normal j moves the caret while Transcript j moves semantic selection. +- ga, gi, colon, slash, leader, bracket commands, and Ctrl-W route according to the table from composer Normal. +- Rapid diw, 10j, d2d, and pending Escape work with no intervening render/context refresh. +- Invalid editor grammar never inserts, sends, or reaches Chat; Escape/focus/profile changes cancel it, while editor grammar has no time timeout. +- Static GPUI chord timeout/cancellation and shorter-binding dispatch preserve/clear key provenance correctly. +- Composer Escape cannot reach Chat run-stop before the documented final application-root stage. +- Profile switching mid-Insert, delayed old-profile prefix dispatch, mouse-to-Normal, and external draft replacement follow the termination table. + +## 18. Python Code Mode + +### 18.1 Settings and default state + +Expose two controls backed by one normalized runtime authority tuple: + +~~~rust +pub enum PythonCodeMode { + Off, + DeveloperPreview, +} + +pub enum UiControllerAccess { + Off, + ReadOnly, + FullAccess, +} + +pub struct ProgrammabilityAuthority { + pub code_mode: PythonCodeMode, + pub controller: UiControllerAccess, +} +~~~ + +Defaults: + +- Python Code Mode: Off. +- Maple UI Controller: Off. + +The actual authority tuple is session-scoped in this Developer Preview and starts Off/Off on every process launch, account change, or sign-out. Persist the one-time disclosure acceptance and interpreter preference, not active Code Mode or controller authority. This fail-closed choice prevents a failed downgrade write or stale file from silently restoring Python/Full Access after a crash. A future decision to persist authority requires a separate reviewed revocation/startup design. + +Normalize every transition atomically: code_mode=Off implies controller=Off, and ReadOnly/FullAccess is invalid unless code_mode=DeveloperPreview. The controller selector is disabled while Code Mode is Off. Turning Code Mode Off cancels active Python executions, revokes controller leases, and stops all task kernels. + +### 18.2 Disclosure + +Enabling Developer Preview requires a one-time direct-user disclosure in the UI. + +Required language: + +> Developer Preview: Python runs with your macOS user permissions. Maple provides project/scratch paths, no supported network API, and best-effort Python guardrails, but this is not a hardened sandbox. Python code may access other files, processes, credentials available to your user, or the network. Enable it only for tasks you trust. + +The status UI should say: + +~~~text +Project files: intended read/write +Scratch: read/write +Network: no supported Maple API; native Python may still connect +Process isolation: separate worker; not an OS security boundary +~~~ + +Do not say merely Sandboxed or Network disabled. + +Python's own documentation explicitly warns that Python-level audit hooks are not a sandbox. That warning should inform both implementation and copy: https://docs.python.org/3/library/sys.html#sys.addaudithook + +### 18.3 Kernel identity + +Use: + +~~~rust +pub struct KernelKey { + pub account_scope: String, + pub task_id: String, +} +~~~ + +The task ID is the durable Goose session ID. + +Each kernel records: + +- canonical project root loaded from durable task metadata; +- private task scratch path; +- kernel generation; +- interpreter path/version/engine; +- active execution ID; +- last-used time; +- controller policy epoch. + +Before every execution, verify that: + +- the task still exists; +- it still belongs to the account; +- its canonical project root is valid and unchanged. + +If not, stop the kernel and return a structured error. Never fall back to the selected UI project, HOME, or another task's root. + +Every caller supplies only account/task identity. The backend reloads durable +task metadata and derives the canonical root rather than trusting a path copied +from whichever task the UI currently shows. Derive the scratch directory from +an account-scope digest plus a sanitized/hashed task ID, create it with +owner-only permissions, and validate the exact resolved target before deletion. + +### 18.4 Lifecycle + +- Lazy-create on the first Python call for a task. +- Persist namespace while the app runs, even when another task is selected. +- Serialize to one active execution per kernel. +- Return busy rather than building an unbounded queue. +- Interrupt attempts to stop the current execution while retaining state if safe. +- Reset clears the user namespace and pending async tasks in the same worker. +- Restart terminates the process group/job, reaps the direct worker, performs best-effort cleanup of tracked descendants, and launches a fresh interpreter. +- Hard timeout, protocol corruption, or failed interrupt kills and marks/restarts the worker. +- Task deletion first tombstones the kernel key, revokes admission/leases, stops and reaps the direct worker plus tracked descendants, and only then removes the exact validated scratch directory. +- Task archive retains the kernel. +- Logout kills/reaps all direct workers for that account and best-effort tracked descendants. +- App quit stops admission, cancels executions, terminates each process group/job, reaps each direct worker, and verifies tracked/cooperative descendants exit where observable. + +Cap active kernels, initially eight. Eviction must be explicit in result/UI because it loses in-memory state. Prefer least-recently-used idle eviction; never evict a running kernel. If all eight are running, a ninth request returns a structured capacity_exceeded immediately rather than waiting indefinitely. Clear Scratch is allowed only after its kernel is idle/stopped; otherwise stop first or return busy. + +Use actor ownership so service/global locks are not held while waiting on Python or GPUI. + +### 18.5 Interpreter resolution + +The Developer Preview may use an external native interpreter, but resolution must be deterministic and visible: + +1. Explicit `MAPLE_CODE_MODE_PYTHON` path. +2. The saved explicit interpreter preference. +3. A future bundled interpreter if present. +4. Resolved `python3` from the login/current PATH. +5. Common platform paths as a final developer fallback. + +Requirements: + +- Resolve an executable path, not an arbitrary shell command. +- Require Python 3.10 or newer and probe version/engine dependencies before accepting. +- Select IPython only when it imports successfully in the isolated probe; + otherwise use the CPython engine. +- Probe a changed preference before publishing it. On success, atomically swap + the preference and stop existing kernels; an invalid explicit path fails + closed instead of silently falling through. +- Show path, Python version, and IPython/CPython engine in Settings. +- Refuse an unusable interpreter with a clear error. +- The Nix dev shell provides a pinned nixpkgs Python plus IPython so validation + is reproducible and does not rely on Apple's system Python. + +Bundling Python in every release artifact and environment/package management are explicit follow-ups. + +### 18.6 Worker process boundary + +Python runs outside the GPUI process. + +Use private inherited pipes, not a loopback TCP server: + +- host-to-worker framed requests on a dedicated inherited descriptor; +- worker-to-host framed protocol on a second dedicated inherited descriptor; +- ordinary child stdout and stderr on separate capture pipes; +- user-code stdin replaced with a controlled EOF/error stream rather than either protocol descriptor. + +This keeps input(), sys.stdin.buffer.read(), os.read(0, ...), user print, native output, and subprocess output from stealing or corrupting protocol frames. If dedicated bidirectional descriptors are temporarily impossible on one platform, fail or use a clearly isolated equivalent; do not quietly place host messages on user stdin or mix arbitrary native stdout with JSON framing. + +After bootstrap handoff, mark protocol descriptors close-on-exec/non-inheritable so ordinary subprocesses do not receive them; preserve only stdout/stderr capture inheritance where required. The fixed worker retains the descriptors privately, while user code still runs in the same native process and therefore is not treated as adversarially isolated. + +The worker has a concurrent control architecture: + +- one dedicated protocol-reader thread continuously reads host frames during execution; +- Python/IPython user execution remains on the Python main thread; +- sdk_result delivery is posted thread-safely into the active asyncio loop/future; +- controller_changed, cancellation, and shutdown update atomic/thread-safe control state immediately; +- one serialized protocol writer owns every worker-to-host frame; +- a second execute request while one is active receives busy and is never run concurrently. + +Cooperative Python cancellation can be scheduled through the loop. When Python or native code prevents it, the Rust host performs the platform-specific interrupt/termination sequence; protocol-reader liveness alone is not mistaken for execution cancellation. + +Protocol: + +- fixed protocol version; +- four-byte big-endian frame length; +- UTF-8 JSON payload; +- maximum frame size; +- kernel generation; +- reject unknown, oversized, duplicate-terminal, stale-generation, and out-of-order messages. + +Use a common envelope with version, kind, kernel_generation, and a message sequence. Correlation fields are kind-specific rather than fake IDs on connection messages: + +- hello/ready carry a random handshake_nonce and generation, no program/execution; +- execute carries request_id, program_id, execution_id, and optional model_run_id; +- cancel carries request_id plus the exact program_id/execution_id; +- reset/restart/shutdown carry a control request_id and generation; +- controller_changed carries policy_epoch and generation; +- sdk_call/sdk_result carry sdk_request_id plus program_id/execution_id; +- display/completed/fatal carry program_id/execution_id when execution-scoped, otherwise a control request or fatal generation. + +Validate an explicit state machine: Spawning to Handshaking to Idle to Running(program, execution) to Interrupting to Idle/Dead. Only controller_changed, matching sdk_result, matching cancel, and shutdown are accepted concurrently with Running. Duplicate/out-of-order terminal messages, generation mismatches, an SDK result for a nonpending request, or execution output while Idle are protocol corruption and force restart. + +Host-to-worker: + +- hello; +- execute; +- reset; +- restart; +- cancel; +- shutdown; +- controller_changed; +- sdk_result. + +Worker-to-host: + +- ready; +- display; +- sdk_call; +- completed; +- fatal. + +Raw stdout and stderr capture pipes are the sole source of ordinary output events, including os.write, native libraries, and subprocesses. Attribute their bytes to the active kernel generation/program/execution. A host-side aggregator assigns one merge sequence to stdout, stderr, protocol display, and terminal arrivals as their respective pumps deliver them; cross-descriptor ordering is best-effort and must be labeled as such. At terminal completion the worker flushes Python streams and writes an execution-specific high-entropy barrier to each raw pipe before emitting completed. The host strips the barriers and does not surface terminal completion until both capture pumps have observed them or a bounded drain timeout forces restart. Output after a barrier indicates a lingering/background producer, is marked contaminated, and forces a restart rather than being attributed to a later cell. + +Use a fixed checked-in bootstrap script. Do not generate Python source ad hoc from Rust. + +The protocol channel is private by process construction, but native Python is not a security boundary. A hostile program can enumerate or corrupt inherited descriptors; treat that as protocol failure and restart, not as a sandbox escape that Maple claims to prevent. + +### 18.7 Execution engine + +Prefer IPython InteractiveShell.run_cell_async when the selected interpreter has IPython. Official API reference: https://ipython.readthedocs.io/en/stable/api/generated/IPython.core.interactiveshell.html + +Provide a CPython fallback: + +- one persistent globals namespace; +- compile with ast.PyCF_ALLOW_TOP_LEVEL_AWAIT; +- a persistent event-loop strategy; +- capture the final expression; +- retain imports and variables between calls. + +Example: + +~~~python +x = 41 +x + 1 +~~~ + +returns 42 and leaves x available for the next cell. + +Capture: + +- interleaved stdout/stderr events; +- final text representation; +- structured exception type, message, and traceback; +- bounded text/plain rich-display representation where feasible; +- engine/version; +- duration; +- truncation; +- whether forced restart occurred. + +At execution completion, cancel unowned pending async tasks. Also detect Python-managed threads and tracked/cooperative process-group descendants created by the cell. Code Mode does not promise persistent background work in this milestone: give observable work a short cooperative grace period, then mark the kernel contaminated and force Restart if it remains or writes after the output barrier. A native extension can create unenumerable threads or a subprocess can setsid/double-fork out of the group; cleanup is best-effort under the same non-sandbox Developer Preview disclosure. A soft interrupt may leave partially mutated Python globals; show that fact and offer/perform Restart according to the result. Persistent variables are useful; invisible background jobs are not. + +### 18.8 Bounds + +Initial defaults: + +| Resource | Limit | +|---|---:| +| Source per execution | 128 KiB | +| Protocol frame | 1 MiB | +| Captured output total | 256 KiB | +| Single result/display item | 64 KiB | +| Display item count | 16 | +| Default execution time | 60 seconds | +| SDK calls per execution | 256 | +| Interrupt grace | 500 to 750 ms | +| Active kernels | 8 | + +Make limits constants/config with tests. + +An output flood cancels the execution and terminates/restarts when necessary. It does not continue producing discarded data indefinitely. + +### 18.9 Cancellation and Stop + +Every execution has an always-present host-generated `program_id` and +`execution_id`. A model-triggered execution additionally has the owning +`model_run_id` and Goose `CancellationToken`. The protocol retains a UserCode +origin for trusted compatibility paths, but this preview does not mount a +human execution surface in chat. + +Stopping: + +1. Revoke the execution's UI capability lease. +2. Mark the execution cancelled so late sdk_call frames are rejected. +3. Request a cooperative loop/interpreter interrupt, then use the platform-appropriate hard-interrupt mechanism if needed. On Unix this may include a process-group signal; do not assume Windows has an equivalent SIGINT path. +4. Wait the short grace period. +5. Terminate the process group/job if it does not stop, reap the direct worker, and verify tracked/cooperative descendants exited where observable; report the native containment limit honestly. +6. Restart or mark the kernel stopped, and report what happened. + +Once cancelled, an execution ID can never invoke another action. A +model-triggered Stop revokes the execution lease and cancels the owning Goose +run as well, so that same run cannot call `python_code` again or invoke another +controller action. Track stopped model run IDs at the host boundary until the +run is terminal. + +While any Python/model UI program runs, or a failed teardown retains an exact +cleanup identity, its Stop/Retry Stop control remains visible in Settings > +Programmability. There is intentionally no global program HUD. + +Cancellation does not undo completed actions. Audit/result copy must say so. + +### 18.10 Intended filesystem/network posture + +Launch with: + +- cwd set to the task's canonical project root; +- HOME and temp paths pointed at private task scratch; +- Command::env_clear followed by a minimal explicit allowlist needed for locale, interpreter execution, project/scratch metadata, and platform runtime behavior; +- no intentionally passed Maple/OpenSecret auth tokens, credential paths, proxy variables, dynamic-loader injection variables, cloud credentials, or unrelated parent environment; +- no database handles; +- explicit MAPLE_PROJECT_ROOT and MAPLE_SCRATCH_DIR metadata. + +The ordinary intended capability is: + +- project root: read/write; +- task scratch: read/write; +- network: no supported Maple API and best-effort ordinary Python guardrails; native Python still has the macOS user's ability to connect; +- outside-project files and subprocesses: discouraged/guarded through ordinary Python APIs where practical, but still reachable by native Python under the user's OS permissions. + +The worker may install best-effort import/audit guards to prevent ordinary socket, subprocess, outside-write, or credential access. These are optional ergonomics and useful guardrails, not the authority boundary. Native modules, ctypes, direct syscalls, or reading the user's existing app/auth files may bypass them. "No Maple auth tokens" means Maple does not pass tokens into the child; it does not claim the native worker cannot discover files its macOS user can read. Tests and UI must reflect the honest Developer Preview boundary, and one failed socket call is never evidence of a sandbox. + +Production hardening is a separate sandboxed macOS helper/XPC design with no network entitlement and explicit file grants, plus equivalent Linux/Windows containment. + +## 19. User-visible Programmability controls + +The Developer Preview's human-facing surface is Settings-only. Python +execution remains model-driven through `python_code`. + +### 19.1 Entry point + +- Settings > Programmability is the only visible Code Mode/controller surface. +- `code_mode.open` remains a stable compatibility action and navigates there. +- Code Mode actions are excluded from command-palette discovery and the + default Standard/Vim keymaps. +- No chat strip, expandable human REPL, or global program HUD is mounted. + +### 19.2 Surface + +Settings > Programmability contains: + +- Developer Preview disclosure and explicit session-only Off-by-default copy; +- Python Code Mode Off/Developer Preview and Maple UI Controller + Off/Read Only/Full Access; +- interpreter preference plus resolved path, Python version, and + IPython/CPython engine; +- limits and current enforcement summary; +- active account/task kernels, source task, state, current or most recent + action, elapsed time, and generation; +- retained model-program and failed-teardown records; +- Stop/Retry Stop, Reset, Restart, and Clear Scratch lifecycle controls; +- bounded execution history and action audit with actor/transport labels. + +The surface discloses that all model `python_code` calls for the same +account/task share one process-local namespace. Variables, imported modules, +in-memory data, and secrets created by one call may be read or changed by a +later call. Reset/Restart visibly destroy that shared state and are audited. +Clear Scratch additionally follows the preconditions in Section 18.4. + +### 19.3 Authority + +Code Mode and controller-mode changes are direct-human-only. Settings reflects +the normalized runtime tuple and cannot restore active authority from disk. + +## 20. Model-facing python_code tool + +Add python_code to MapleDeveloperClient only when Developer Preview is currently enabled. + +Suggested discriminated input: + +~~~json +{ + "operation": "execute", + "code": "x = 41\nx + 1", + "timeout_seconds": 30 +} +~~~ + +Other operations: + +~~~json +{ "operation": "status" } +{ "operation": "reset" } +{ "operation": "restart" } +~~~ + +General Python works with the controller Off. + +Result includes: + +- kernel generation; +- program ID; +- execution ID; +- interpreter/engine; +- status; +- stdout; +- stderr; +- result; +- structured error; +- duration; +- truncation; +- cancelled/timed-out; +- forced restart. + +The tool is absent from list_tools when Code Mode is Off. A stale already-described invocation rechecks current policy and fails closed. + +execute, reset, and restart act on the source task's shared model-call kernel. +Sequential `python_code` calls share its namespace; reset/restart therefore +visibly destroy shared in-memory state. The tool description says so and +audit/history attributes the operation to the model. This is not protected by +UI Controller mode because general Python itself is allowed with the +controller Off. Clear Scratch is not a model tool operation in the first +milestone; it remains an explicit Settings action with the idle/stopped and +exact-target checks in Section 18.4. + +### 20.1 Runtime bridge + +Recommended ownership: + +~~~text +GPUI main thread + MapleApp semantic dispatcher + UiController request receiver + ^ + | bounded mpsc + oneshot + v +AgentBackend private Tokio runtime + CodeModeService + ^ + | framed process protocol + v +per-task Python worker +~~~ + +Do not route request/response controller calls through AgentServiceEvent, which is a cloneable one-way event stream. Use a separate bounded channel carrying: + +- request ID; +- host-assigned source task/run/execution; +- ActionCall or semantic query; +- oneshot response; +- cancellation/policy epoch. + +The GPUI root owns a foreground-executor receiver task/notifier that awaits channel readiness and explicitly wakes/schedules a cx.update on the GPUI event loop. Do not depend on an incidental render frame or input event. Each wake drains a bounded batch, then yields and reschedules itself if work remains so rendering/input stays responsive. Dropping MapleApp/window cancels the receiver and closes pending oneshots. + +Initial bridge contract: + +- bounded queue depth: 64 requests; +- UI drain batch: at most 16 requests per frame/tick, then yield for rendering/input; +- enqueue timeout: 250 ms, returning controller_busy/queue_full; +- ordinary response timeout: 30 seconds; events.wait has its own explicit maximum of 60 seconds plus a small broker drain margin; +- app unavailable/quitting, dropped receiver/oneshot, and closed window return structured ui_unavailable/cancelled errors; +- cancellation removes or tombstones queued work and is checked again on the UI thread; +- no kernel/service/global lock is held while enqueueing or awaiting a UI response. + +Async actions return accepted plus an operation ID and register an ActionExecutionHandle in a bounded operation table. That handle owns cancellation/backend task state and emits exactly one terminal event/result. Recheck policy immediately before submitting an irreversible backend effect. Before submission cancellation can prevent it; after submission it may complete and audit completed_after_cancel_request. + +A ProgramRecord owns the root Python execution plus every operation handle it started. When the cell returns accepted, the execution can become terminal and the kernel can accept a later cell, but the ProgramRecord, its visible Stop/audit state, policy lease, and bounded operation budget remain until all handles reach terminal status. A terminal Python execution cannot originate new SDK calls; its already-created handles may only finish/cancel under current policy. Multiple surviving ProgramRecords are shown separately or by a Stop All control and count against the global operation bound. + +Stop or controller downgrade after cell completion still cancels/revokes the surviving handles. Reset/Restart first Stop every nonterminal ProgramRecord for that task, wait the bounded grace, and then reset/replace the worker; already-submitted irreversible effects may still finish and must be audited. A new execution gets a new ProgramRecord and cannot borrow a prior record's lease or action budget. + +### 20.2 maple-agent seam + +MapleAgentHostResources should receive an optional trait object or broker handle defined below app, such as PythonCodeModeHost. MapleDeveloperClient uses it for python_code without depending on GPUI. + +The current developer-tool call context has a source session and CancellationToken but no stable owning model-run identity. Add a Maple-owned run-control seam rather than pretending the token is an ID: + +~~~rust +pub struct MapleModelRunContext { + pub run_id: ModelRunId, + pub task_id: String, + pub cancellation: CancellationToken, + pub control: Arc, +} + +pub trait ModelRunControl { + fn cancel_run(&self, run_id: &ModelRunId) -> Result<(), RunControlError>; + fn is_terminal(&self, run_id: &ModelRunId) -> bool; +} +~~~ + +Create/register the run ID where Maple starts a model generation, carry the context through the agent/developer-tool host resources into call_tool, and remove/tombstone it only when the run is terminal. Stop calls cancel_run and the token; terminal cleanup removes the registry entry. A tool invocation cannot mint or choose this ID. + +Forward the source session ID and run context unchanged as authoritative identity/cancellation context. Treat the supplied working directory only as a consistency hint: the Code Mode backend independently reloads durable project metadata and derives the canonical root for every human and model execution. + +## 21. maple_gpui SDK + +### 21.1 Availability + +The fixed maple_gpui package is installed in every Maple worker so code has one stable import contract. Rust enables its controller transport only when access is Read Only or Full Access. When controller is Off: + +- general Python still works; +- importing the same full `maple_gpui` SDK succeeds, while every host call + raises a structured `ControllerDisabled` error; +- the model-facing controller skill is not advertised. + +An old module reference after downgrade cannot retain power because Rust rechecks every call. + +### 21.2 Generic API + +Required initial API: + +~~~python +import maple_gpui as maple + +status = await maple.ui.status() +tasks = await maple.tasks.list(limit=100) +target = next((task for task in tasks if not task.active), None) +assert target is not None, "create a second harmless task first" + +catalog = await maple.actions.list( + query="task", + available_only=False, +) +descriptor = await maple.actions.describe("task.open") + +result = await maple.actions.invoke_and_wait( + "task.open", + {"task_id": target.id}, + precondition=target.precondition, + timeout=10, +) +~~~ + +Then add thin generated/domain conveniences: + +~~~python +tasks = await maple.tasks.list() +await maple.workspace.open_task(tasks[0].id) +await maple.transcript.focus_next(kind="annotation") +~~~ + +The generic discovery/invoke API is authoritative. Action listings are compact +and expose `needs_arguments`; use `actions.describe` to fetch full argument +schemas. Prefer `invoke_and_wait` when completion matters because it captures +the event cursor before invocation and cannot miss a fast completion. Plain +`invoke` plus `events.wait` remains available for workflows that deliberately +manage their own cursor. Convenience wrappers delegate to this API. + +### 21.3 SDK transport + +SDK calls are sdk_call frames on the worker's existing private protocol. + +Python sends: + +- method; +- JSON arguments; +- optional target/action precondition; +- its local request ID. + +It does not send a trusted actor, transport, controller mode, policy epoch, account, or capability token. + +Rust attaches: + +- account scope; +- source task; +- model run ID when applicable; +- host-generated program ID; +- kernel generation; +- execution ID; +- invocation actor (Model for model-triggered code; UserCode only for a trusted + compatibility path) and transport Python; +- current controller policy/epoch; +- cancellation. + +### 21.4 Required capabilities + +maple.ui: + +- describe; +- status; +- query; +- reveal; +- current_selection. + +maple.actions: + +- list; +- describe; +- availability; +- invoke. +- invoke_and_wait. + +maple.events: + +- wait. + +Thin namespaces: + +- tasks; +- projects; +- workspace; +- transcript; +- composer; +- settings; +- MCPs where descriptors exist. + +The SDK never receives: + +- GPUI Entity, Window, FocusHandle, App, Context, or callback; +- Rust pointers or arbitrary method names; +- OpenSecret credentials/tokens; +- SQLite connections; +- MapleAgentService or backend handles; +- raw secret settings. + +The Python worker's filesystem root remains its source task even if it navigates the visible Maple app to another task. + +Use tasks consistently as the public product/SDK namespace. Do not ship both tasks and sessions in the first API. A later sessions alias may be added only as a documented compatibility alias. + +### 21.5 Bounded discovery and pagination + +Every potentially large API accepts scope plus limit and opaque cursor where relevant: + +- ui.describe(scope="visible" | target, max_nodes, cursor); +- ui.query(..., limit, cursor); +- actions.list(..., limit, cursor); +- tasks.list(..., limit, cursor); +- transcript.list/focus helpers over a bounded semantic projection. + +Initial limits: + +| Surface | Initial bound | +|---|---:| +| Semantic nodes per page | 200 | +| Snapshot exposed text per page | 128 KiB | +| Action/query/task results per page | 100 | +| Controller response payload | 512 KiB | +| Semantic event ring | 2,048 events | +| Concurrent event waiters | 64 | +| Maximum event wait | 60 seconds | +| Action audit ring | 512 records | +| In-flight async operation table | 256 | + +Return next_cursor when truncated. If a page cannot fit the response cap, reduce it or return result_too_large; never exceed the 1 MiB protocol frame. Expired cursors/event history return resync_required and direct the caller to a fresh describe. Cursors include scope/revision/generation integrity so they cannot silently page through a different task or policy epoch. + +### 21.6 Discovery-first behavior + +The controller is semantic and introspectable. Models should: + +1. describe/query; +2. retain stable IDs and revision/event cursor; +3. inspect action availability; +4. invoke an action; +5. wait for a structured event if completion matters. + +They should not: + +- click coordinates; +- synthesize shortcut keys; +- sleep/poll when an event exists; +- guess stable IDs; +- cache authority; +- assume completed mutations are rolled back on Stop. + +## 22. Built-in maple-ui-controller skill + +Current Maple disables Goose's general built-in skills in TrustAwareSkillsClient. Do not turn all of them on. + +Add a small Maple-owned embedded skill catalog: + +- Advertise maple-ui-controller only when controller access is enabled. +- Intercept its load_skill request. +- Return an include_str-backed trusted SKILL.md. +- Optionally expose it through Maple's slash-command skill listing. +- Omit it entirely when controller is Off. + +TrustAwareSkillsClient merges only this Maple-owned descriptor/instruction into its existing catalog; do not enable Goose's other built-ins. Catalog preparation is not authority: intercepting a load_skill call rechecks current Code Mode, controller access, source task, and policy epoch. A skill advertised earlier but loaded after controller disablement returns ControllerDisabled. If access changes mid-turn, any already-loaded prose remains harmless because every SDK call is independently reauthorized. + +The skill teaches: + +- persistent per-task Python behavior; +- how to import maple_gpui; +- discovery-first workflow; +- stable IDs and action-specific preconditions; +- Read Only versus Full Access; +- events.wait rather than polling; +- structured errors and cancellation; +- no coordinate clicking or key synthesis; +- completed effects are not rolled back; +- small navigation/chaining examples. + +The skill grants no capability. It documents a capability Rust already enabled. + +## 23. Audit and visible run state + +### 23.1 Audit record + +Use a bounded in-memory ring initially: + +~~~rust +pub struct ActionAuditRecord { + pub sequence: u64, + pub invocation_id: InvocationId, + pub program_id: Option, + pub model_run_id: Option, + pub timestamp_ms: i64, + pub duration_ms: Option, + pub actor: InvocationActorSummary, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub action_id: ActionId, + pub target: Option, + pub arguments: RedactedArguments, + pub effect: ActionEffect, + pub decision: PolicyDecision, + pub outcome: AuditOutcome, +} +~~~ + +Record: + +- accepted or denied decision; +- terminal completion/failure/cancel state; +- completed_after_cancel_request when an irreversible race actually occurred. + +Do not report async work as completed when it was merely spawned. + +### 23.2 Redaction + +Redaction is descriptor-driven and deny-by-default. + +Safe allowlisted fields may include: + +- stable task ID; +- action ID; +- boolean setter value; +- non-secret setting key; +- target kind. + +Never retain: + +- passwords; +- OAuth callbacks; +- tokens; +- secret headers; +- MCP secret environment values; +- raw account identifiers if a scoped digest suffices; +- arbitrary full Python output in the action audit. + +Python code/output already appears in its execution cell/tool timeline. Do not duplicate it into an indefinite action log. + +### 23.3 Visible state + +Settings > Programmability shows active and retained model/Python UI programs: + +- source task; +- Running/Stopping state; +- current or most recent action; +- elapsed time; +- exact program/execution identity where needed for cleanup; +- Stop or Retry Stop; +- bounded Code Mode history and action-audit details. + +There is no global HUD. `code_mode.open` provides a stable semantic route back +to the Settings surface. + +## 24. Cancellation model + +Every model/Python program has: + +- host-generated program ID; +- optional owning model run ID; +- execution ID; +- kernel generation; +- CancellationToken; +- current policy epoch; +- bounded action-call budget. + +Check cancellation: + +- before parsing/validation; +- before policy; +- before UI-thread dispatch; +- before any deferred external effect commits; +- before every later action in a chain; +- before resolving an event wait. + +Stopping a program: + +- revokes the controller lease immediately; +- prevents subsequent actions; +- cancels event waits; +- interrupts/terminates Python; +- attempts to cancel tracked backend work; +- reports effects that already completed. + +For model-triggered Python, Stop also cancels and tombstones the owning Goose +run at the developer-tool boundary, so that run cannot submit a fresh +`python_code` or another controller invocation. Controller calls carry both +actor and transport provenance throughout. + +No UI copy should imply transactional rollback. + +## 25. Settings persistence and fail-safe behavior + +Add serde-defaulted fields to AppSettings for: + +- keymap_profile; +- vim_leader; +- one-time Developer Preview disclosure acceptance if needed; +- optional interpreter path preference. + +Keep ProgrammabilityAuthority in runtime/account state, not AppSettings, for this Developer Preview. If older experimental fields exist, deserialize them tolerantly but normalize/ignore them to runtime Off/Off. + +Requirements: + +- Unknown persisted enum values fail only that field to the safer default through tolerant field-local deserialization; unrelated valid settings survive. serde(default) alone is not sufficient if an unknown enum would reject the entire document. +- Invalid keymap JSON leaves the last-known-good keymap active. +- Settings writer remains serialized/coalesced. +- An action result distinguishes local state applied from durable write failed when persistence matters. +- The first Off-to-DeveloperPreview transition completes and durably records the direct-user disclosure before enabling. If that write fails, remain Off. Once disclosure acceptance is durable, session authority transitions do not wait on settings persistence. +- Reductions revoke immediately and atomically normalize the runtime tuple. Because active authority is not persisted and every launch starts Off/Off, a crash/restart after any downgrade cannot resurrect old access. +- The existing serialized/coalesced settings writer must provide an awaited success/failure path for disclosure acceptance; fire-and-forget persistence is insufficient. +- Saved-auth startup may show local chat immediately while validation runs in + the background. Definitive rejection returns to Login and compare-clears only + the record that was loaded; timeout/network/server unavailability preserves + local history and credentials. Code Mode account synchronization waits for + that restore gate. +- Sign out closes Code Mode admission and confirms account-worker teardown + before clearing in-memory credentials and compare-clearing the captured + persisted record. A teardown failure retains the cleanup identity and + credentials needed to retry instead of reporting a false successful logout. + +## 26. Implementation slices and review map + +The exact diffs and commit boundaries may shift with integration work, but +preserve these architectural review boundaries. + +### Slice 1: Typed action core and semantic control routing + +Primary responsibilities: + +- maple-harness core types; +- registry and descriptor validation; +- policy matrix and Human Only; +- host-assigned actor/transport provenance; +- structured errors/results; +- audit ring; +- root dispatcher/GPUI bridge; +- initial stable semantic target types; +- migration of every current semantic control; +- checked-in control inventory and direct-callback audit; +- deterministic setters; +- tests for uniqueness, schemas, one-path execution, policy, generic-activation reauthorization, and redaction; +- architecture document in its then-current form. + +The slice cannot leave half of the buttons using direct semantic callbacks. + +### Slice 2: Keymap profiles and shortcut tooling + +Primary responsibilities: + +- Standard/Vim templates; +- keymap.json parser/resolver; +- complete atomic GPUI binding reload; +- null unbinding; +- context expansion; +- conflict/prefix reporting; +- Shortcuts settings page; +- shortcut recorder; +- command palette; +- which-key; +- settings persistence; +- tests for references, arguments, precedence, conflicts, recorder, reload, Standard regressions, application-chord/composer-token remapping, and ordinary inputs under the Vim profile. + +### Slice 3: Semantic application navigation + +Primary responsibilities: + +- semantic selection controller; +- stable transcript/sidebar/settings/menu/question/permission targets; +- reveal and reconciliation; +- stream-follow separation; +- application Vim commands and leader; +- ga, bracket a, bracket d, gi, Ctrl-W regions, colon, slash; +- stable semantic events/snapshots; +- tests for virtualized identities and streaming. + +### Slice 4: Composer Vim + +Primary responsibilities: + +- pure composer Vim engine; +- modes, motions, operators, counts, register, Visual, iw/aw; +- explicit edit transactions; +- structured dot repeat; +- TextInput integration; +- mode badge/cursor; +- application/composer transition; +- focused Unicode and regression tests. + +### Slice 5: Per-task Python Code Mode + +Primary responsibilities: + +- maple-code-mode crate; +- interpreter discovery; +- Nix Python/IPython development dependency; +- worker/bootstrap and framed protocol; +- persistent CPython/IPython execution; +- limits; +- process groups; +- Stop/Reset/Restart/cleanup; +- kernel manager keyed by account/task; +- Settings-only Code Mode status, lifecycle, and disclosure surface; +- model python_code tool for general computation; +- lifecycle/cancellation tests. + +### Slice 6: maple_gpui controller and end-to-end integration + +Primary responsibilities: + +- bounded GPUI request/oneshot bridge; +- SDK state/action/event APIs; +- controller Off/Read Only/Full Access; +- dynamic policy epoch/revocation; +- trusted built-in skill; +- audit/run status integration; +- model action chains; +- complete docs and final tests; +- any small live-validation fixes. + +Every slice should remain internally coherent and covered by focused tests. The +complete stack must be formatted, warning-clean for every supported feature +set, and pass the full repository test matrix. + +## 27. Required automated tests + +### 27.1 Action core + +- stable ID validation; +- unique descriptors; +- action arguments and result schemas serialize; +- every bindable descriptor has a GPUI adapter; +- every current semantic control reaches the same executor from pointer and typed action paths; +- Read Only allows observe/navigate and denies mutation/external; +- Full Access allows ordinary mutation/external; +- Human Only rejects Model/UserCode actors and Python/Macro/GeneratedUi transports even under Full Access; +- direct pointer/key/palette can invoke Human Only when available; +- programmatic GPUI dispatch cannot mint DirectUser; +- an actual multi-stroke key and a timeout-resolved shorter prefix consume valid window PendingKeyProvenance, while focus change/cancellation clears it and identical App::dispatch_action remains non-direct; +- internal follow-up calls inherit provenance and policy rather than becoming privileged; +- exhaustively test every actor/transport tuple: Human Only allows only DirectUser plus Pointer/Keybinding/CommandPalette, and every Internal tuple is denied; +- controller access changes cannot be controller-invoked; +- permission.respond is denied in Read Only and succeeds/audits in Full Access, including same-run responses as explicitly agreed; +- ui.activate_selected reauthorizes the concrete action; +- relevant target-precondition mismatch returns stale_target while unrelated global streaming revisions do not; +- stable ID to App::build_action to typed adapter reaches the semantic executor; +- one instrumented harmless action invoked through pointer, physical-key GPUI adapter, command palette, model/Python bridge, and generic activation reaches the identical executor identity plus policy/audit hooks exactly once per call; +- the checked-in control inventory has no unexplained semantic callback; +- sensitive fields never appear in audit JSON; +- cancellation prevents subsequent calls; +- async terminal status is recorded correctly. + +### 27.2 Keymaps + +- Standard and Vim templates never stack; +- every binding resolves to a registered stable action; +- every bound argument validates; +- null becomes an effective unbinding; +- user overrides beat template at equal context depth; +- exact, prefix, shadow, and possible conflicts are classified; +- invalid file preserves last-known-good; +- complete reload restores TextInput/default essentials; +- Vim-profile search/login/rename/settings/question/MCP inputs retain ordinary editing; +- profile switch safely resets modal state; +- recorder captures and cancels correctly; +- command palette uses current availability; +- which-key trie matches current context/prefix; +- Standard current shortcuts and type-to-compose behavior remain. +- user-remapped application chord and composer motion/operator replace the old binding and update which-key/settings. + +### 27.3 Semantic navigation + +- j/k/gg/G use stable IDs; +- off-screen reveal resolves through current virtual row index; +- a selected streaming item survives revision updates; +- insertion/reordering before selection does not move identity; +- removed selection chooses deterministic neighbor; +- leaving newest disables follow; +- streaming does not repin; +- G restores latest/follow; +- task switch restores valid per-task selection; +- popup contexts shadow application/composer; +- ga and bracket a traverse correct role/kind; +- bracket d returns unavailable with no annotations and works with fixtures; +- gi restores composer insertion; +- generic activation rechecks policy; +- hidden/internal/zero-height timeline records are absent from the navigable projection. + +### 27.4 Composer Vim + +All tests listed in Section 17.13 are required. + +### 27.5 Worker/core + +- fragmented and oversized frames; +- input()/raw stdin cannot consume control frames; +- handshake/version mismatch; +- complete envelope/state-machine validation for handshake, execute, sdk_call/result, cancel, controller change, reset/restart, shutdown, and terminal messages; +- protocol descriptors are non-inheritable by ordinary subprocesses before user code runs; +- duplicate/out-of-order/stale generation messages; +- CPython fallback; +- optional IPython selection; +- persistent variables/imports; +- top-level await; +- raw Python, os.write, native/subprocess stdout/stderr, display, final expression, exception, barrier ordering, and post-barrier contamination; +- sdk_result completes while the interpreter main thread awaits it; +- concurrent controller downgrade/cancel/shutdown reaches the protocol reader during execution; +- reset/restart state clearing; +- source/output/frame/time bounds; +- infinite loop cancellation; +- infinite output cancellation; +- worker crash/protocol corruption recovery; +- direct-worker reaping, process-group/job cleanup, and best-effort tracked-descendant cleanup; +- Python-managed thread/tracked-subprocess contamination forces restart, while native escape limitations are disclosed; +- per-task and per-account isolation; +- task root verification; +- eight-kernel cap/idle eviction and capacity_exceeded for a ninth request while all run; +- task deletion/Clear Scratch races and exact scratch target validation; +- env_clear/minimal allowlist with known credential, proxy, loader-injection, and auth variables absent; +- shutdown reaps every direct worker and reports any observable tracked-descendant cleanup failure. + +Use two test tiers: deterministic protocol/supervision tests with a fixture worker, and native CPython/IPython integration tests inside the Nix environment. If IPython is unavailable in a non-Nix CI job, report that optional engine test as explicitly skipped; do not silently count a fixture as native coverage. + +### 27.6 Agent/model integration + +- python_code absent when Off; +- general Python succeeds with controller Off; +- source task project root is used even when another UI task is selected; +- Goose cancellation reaches the worker; +- Maple-owned model run IDs are unforgeable, cancel_run reaches the owning generation, and terminal cleanup removes/tombstones the registry entry; +- model Stop cancels/tombstones the owning run so it cannot call python_code or controller actions again; +- logout/task deletion clean up kernels; +- stale tool calls recheck current policy; +- tool result is bounded; +- maple-ui-controller skill appears only when controller enabled; +- a skill advertised before disablement cannot be loaded afterward; +- sequential model executions demonstrably share the documented namespace, + including Reset/Restart behavior. + +### 27.7 SDK/controller + +- Read Only describe/query/navigation succeeds; +- Read Only mutation fails; +- Full Access ordinary mutation succeeds; +- permanent account/identity Human Only actions fail under Full Access; +- generic activation cannot bypass; +- schema and stale revision errors are structured; +- pagination, result_too_large, expired cursor/resync, and response caps work; +- bridge queue-full/enqueue-timeout/UI-closed/app-quitting/dropped-response paths are structured and release waiters; +- an SDK request wakes and completes against a totally idle GPUI loop with no render/input event; +- no service/kernel/global lock is held while awaiting GPUI; +- downgrade while a call is queued fails closed; +- no SDK call occurs after cancellation; +- describe redacts secure fields; +- events.wait is sequence-safe, bounded, timed, and cancellable; +- retained SDK object after downgrade has no authority; +- a cell returning accepted retains a visible/stoppable ProgramRecord until every handle terminates; Stop/downgrade/Reset/Restart and a later new execution obey the lifetime contract. + +### 27.8 Settings + +- defaults are Standard, Code Mode Off, controller Off; +- unknown values fail safe; +- disclosure is required; +- controller cannot enable while Code Mode Off; +- disabling immediately revokes active work; +- keymap parse failure is visible and non-destructive; +- settings write errors are surfaced; +- first disclosure write failure leaves runtime Off; +- every downgrade revokes immediately, and crash/restart/account change always returns runtime authority to Off/Off; +- a stale/unknown legacy Code Mode/controller field cannot activate authority or discard unrelated settings. + +## 28. Repository validation + +Run focused tests throughout. Before upstream review, run the repository's +complete single-host checks from the Nix environment. Release/live validation +is a separate gate and should be repeated in proportion to code changes; an +upstream rebase or documentation-only follow-up does not require rebuilding a +previously proven release artifact merely to request architectural feedback. + +The repository currently defines: + +~~~text +just fmt +just ci +just release +~~~ + +`just ci` is the authoritative single-host pre-commit command. It covers: + +- cargo fmt check; +- the strict semantic-control inventory; +- Clippy for the default, combined headless, ACP-only, and proxy-only feature + sets with warnings denied; +- workspace build/tests; +- headless tests. + +It does not reproduce GitHub's cross-OS matrix or separate Linux release job. +`just release` is also separate. + +Also run focused package/test commands that make failure diagnosis legible. + +Do not treat a zero-test filter as validation. + +### 28.1 Nix/macOS + +Use the repository flake and external Xcode/Metal development path. `just` is +a repository prerequisite and must already be available on PATH; the flake +currently supplies the pinned build/runtime dependencies but not `just` itself. + +~~~text +MAPLE_NIX_XCODE_VERSION= nix develop --no-update-lock-file --command just ci +MAPLE_NIX_XCODE_VERSION= nix develop --no-update-lock-file --command cargo build --release -p maple-gpui --locked +~~~ + +On macOS the shell honors `MAPLE_NIX_XCODE_VERSION` first, then a valid +inherited `DEVELOPER_DIR`, `/Applications/Xcode.app`, and finally the +installation selected by `xcode-select`. It derives `SDKROOT` with +`/usr/bin/xcrun` and selects that Xcode's compiler for native dependencies. +Verify the optional Metal toolchain as documented in README. Keep the pure Nix +build/runtime-shader path working where practical. + +Release builds use fat LTO and one codegen unit. They can be quiet and memory-heavy for a long time. Do not declare a hang while rustc/linker processes are active. Be kind to the big-memory LTO. + +Adding Python/IPython to the dev shell must preserve: + +- aarch64-darwin; +- aarch64-linux; +- x86_64-linux; +- pure package behavior where Python is not yet bundled into the app output. + +The macOS release build and GUI behavior are the primary gate. Keep non-desktop/headless feature sets compiling. + +## 29. Live macOS validation + +Automated tests are not sufficient for the implementation milestone. Launch +the exact newly built macOS app and exercise that app through direct or +automated UI interaction. + +Record: + +- git commit; +- binary path and hash; +- running process executable path; +- interpreter path/version; +- enabled profile/controller; +- visible screenshots or precise UI observations. + +Controller correctness must not depend solely on external model credentials. +First exercise the same broker/SDK path with an in-process or deterministic +agent/controller fixture, then perform the live model-driven exercises when a +configured model service is available. If it is unavailable, report that exact +limitation and the fixture evidence; do not silently waive or pretend the +model exercise ran. + +Do not grant macOS privacy/security permissions during validation without a new direct instruction. + +### 29.1 Settings and shortcuts + +1. Open Settings. +2. Verify Keyboard Shortcuts and Programmability sections. +3. Search actions by label, stable ID, and key. +4. Switch Standard to Vim and back. +5. Confirm Standard shortcuts remain ordinary. +6. Record a custom binding. +7. Create/inspect a conflict. +8. Disable with null. +9. Reset one action and the profile. +10. Verify unbound/conflict filters. +11. Open the command palette. +12. Trigger a leader sequence and see which-key. +13. Verify portable Secondary shortcuts resolve to Cmd on macOS. +14. Exercise native project and attachment pickers, including cancellation; + verify explicit controller paths do not open a picker. + +### 29.2 Application Vim + +1. Navigate task/project/sidebar rows with j/k/h/l. +2. Open a task with Enter. +3. Navigate transcript with j/k/gg/G. +4. Verify selected row highlight and off-screen reveal. +5. Use ga and bracket a. +6. Verify bracket d is honestly unavailable if no annotation objects exist. +7. Use Ctrl-W region movement. +8. Use colon palette and Space leader. +9. Start/observe streaming, navigate away, and verify it does not steal selection or repin. +10. Use G to return to newest/follow. +11. Use gi to return to the stored composer insertion, then replace the draft + externally and verify gi safely lands at the current draft end. + +### 29.3 Composer Vim + +Exercise, at minimum: + +- Insert/Normal/Visual transitions; +- h/j/k/l, w/b/e, 0/dollar, gg/G; +- i/a/I/A/o/O; +- dw, d$, dd; +- cw, ciw, cc; +- yw, yiw, yy; +- x, p/P; +- counts such as 3w, 2dd, 2d3w; +- u and Ctrl-R; +- Visual yank/delete/change; +- ciwhello Escape followed by dot on another word; +- A! Escape followed by dot; +- Standard profile typing after switching back; +- slash palette/history/send/Shift-Enter regressions. + +Accessibility automation may not perfectly emulate Vim timing. Supplement UI +automation with deterministic engine/integration tests and report which +behaviors were directly observed. + +### 29.4 General Python with controller Off + +Use sequential model `python_code` calls to: + +- assign a variable and use it later; +- print stdout and stderr; +- execute top-level await; +- raise and display an exception; +- Reset and verify state clears in a later call; +- Restart and verify generation changes in Settings; +- create/read a fixture in the task project; +- create/read scratch metadata; +- attempt outside-project read and a socket connection. + +For the native Developer Preview, any successful escape must match the warning and report, not contradict UI claims. +Verify execution history, interpreter state, and lifecycle controls only in +Settings > Programmability; no chat REPL or global Code Mode HUD should appear. + +### 29.5 Controller Read Only + +Ask the model through python_code to: + +- import maple_gpui; +- describe current state; +- list/search actions; +- list tasks; +- open/navigate a task or transcript item; +- wait for a semantic event; +- attempt a settings mutation and receive policy_denied; +- attempt sign out and receive policy_denied. + +Verify the UI visibly follows navigation where appropriate. + +### 29.6 Controller Full Access + +Exercise reversible ordinary actions: + +- open Settings/Shortcuts/Programmability; +- switch a view/section; +- set a reversible ordinary preference and restore it; +- open another task; +- send a harmless test message only in a task expressly used for validation; +- inspect/configure an MCP only if a reversible local test fixture exists; +- exercise permission.respond against an expressly created harmless validation prompt and verify it succeeds with conspicuous Full Access audit provenance; +- verify sign out and controller/Code Mode self-escalation remain denied from Python; +- as the final controller exercise only, verify app.quit returns/records accepted_terminal before orderly shutdown, then relaunch for the lifecycle checks if needed. + +Use discovery and action invocation, not coordinates/keys. + +### 29.7 Stop and lifecycle + +1. Start an infinite loop. +2. Open Settings > Programmability and press the matching Stop control. +3. Verify UI remains responsive. +4. Verify worker/process group disappears or restarts. +5. Run a later model `python_code` call successfully. +6. Start an output flood and repeat. +7. Use two tasks and verify separate namespaces/scratch/root. +8. Downgrade controller during a wait/pending call and verify revocation. +9. Sign out directly and verify all account kernels stop. +10. Quit and verify no worker process remains. +11. Relaunch and verify runtime Code Mode/controller authority is Off/Off even if it had been Full Access before quit. +12. Inspect audit entries for actor, transport, action, decision, result, and redaction. +13. Relaunch with saved credentials and verify optimistic local-chat restore, + background validation, and subsequent Code Mode account synchronization. + +## 30. Review and promotion gates + +Before proposing promotion beyond Developer Preview: + +- the branch and worktree are clean and based on current upstream; +- `just ci` passes, including the strict semantic-control inventory; +- release-mode compilation succeeds; +- the exact built process is launched and identified; +- Settings, Standard shortcuts, application/composer Vim, Python Code Mode, + and model-driven `maple_gpui` workflows are exercised live; +- platform-specific validation is identified separately from portable + compile/test evidence; +- deviations from this design are documented with their rationale; +- production-hardening TODOs remain explicit rather than being hidden behind + Developer Preview language. + +An upstream proposal should summarize the architectural context, review map, +exact checks, live behaviors, honest Python boundary, and non-goals. Opening a +review does not authorize a merge, release, signing, or installation. + +## 31. Acceptance checklist + +The experiment is complete only if all are true: + +- [ ] One action executor serves pointer, shortcuts, palette, Vim, model, and Python. +- [ ] Every current semantic control is registered or explicitly justified as local gesture mechanics. +- [ ] Actions have stable IDs, typed args/results, documentation, availability, effect, policy, and recoverability. +- [ ] Actor, transport, and controller authority are host-assigned. +- [ ] Read Only cannot mutate. +- [ ] Full Access can perform ordinary controller-callable mutations without per-action prompts. +- [ ] Permanent account/identity Human Only actions reject every non-direct actor/transport, including generic activation. +- [ ] Agent tool permission remains a separate setting, while Full Access permission.respond behavior matches the explicit product decision and is audited. +- [ ] Stable semantic selection survives virtualization and streaming. +- [ ] Standard and Vim are replacement templates with arbitrary overrides. +- [ ] Shortcut settings, recorder, conflicts, palette, and which-key work. +- [ ] Application Vim covers chat, transcript, sidebar, tasks/projects, settings, menus, questions, permissions, tools, and annotation-ready navigation proven by fixtures; a live app with no annotation producer honestly reports unavailable. +- [ ] Composer Vim includes every agreed command and structured dot repeat. +- [ ] Standard inputs remain unaffected. +- [ ] Python is persistent per account/task and outside GPUI. +- [ ] Python has bounded protocol/output/runtime/kernels and working Stop/Reset/Restart. +- [ ] General Python works with controller Off. +- [ ] maple_gpui discovery/action/event chaining works. +- [ ] Current policy is rechecked on every SDK action. +- [ ] Runtime Code Mode/controller authority is one normalized session tuple and relaunch/account change starts Off/Off. +- [ ] Built-in controller skill is present only when enabled. +- [ ] Security disclosure is honest. +- [ ] just ci and release build pass. +- [ ] Exact release app was launched and validated through UI. +- [ ] Upstream review material explains the context, architecture, validation, and remaining hardening without claiming production readiness. + +## 32. Future direction + +Once this vertical spine is proven, the most interesting next layers are: + +1. Saved named action programs/macros bindable like ordinary actions. +2. Transactional or parallel composition semantics. +3. Hardened native Code Mode helpers. +4. Durable Python environments and RLM orchestration. +5. Versioned declarative generated UI. +6. Capability-limited extensions. +7. Multi-window and remote-session semantic control. + +The core thesis should remain: + +> Maple is not a GUI with AI automation bolted onto it. Maple is a programmable, typed, semantic application whose human and model interfaces are clients of the same harness. + +## 33. Developer Preview implementation boundaries + +The Settings-only placement is incorporated throughout the normative sections +above. The implementation also records two proof-of-concept boundaries that +must remain explicit during review and production hardening. + +### 33.1 Developer Preview teardown receipt + +The proof-of-concept implementation closes Code Mode admission before an Off +transition, drains already-published admission reservations, and captures the +exact active program/execution identities from every responsive account-kernel +actor that is successfully observed before worker interruption begins. +Settings unions that atomic backend receipt with the last account-wide status +poll and retained `ActionHost` ProgramRecords. +This prevents a failed Off transition from losing a pure Python program or a +retained controller program merely because the 250 ms presentation poll had +not observed it yet. Stale account/policy completions cannot merge into or +retarget a newer teardown generation. + +One Developer Preview boundary remains for production hardening: the worker +service removes kernel handles and the actor exits after its bounded shutdown +attempt even when process cleanup returns diagnostics. `Retry Stop` therefore +revalidates the retained exact identity and clears a target that is already +terminal, but it is not guaranteed to issue a second operating-system cleanup +attempt against the same handle. A production implementation should retain a +separate retry-addressable cleanup owner until process-group termination is +positively confirmed. + +The proof-of-concept receipt capture is serial and has no per-actor timeout, +and the receipt is returned to one transition waiter. Production hardening +should capture actor status concurrently with explicit bounds and durably own +the receipt across waiter cancellation, task failure, or process interruption. + +### 33.2 GPUI pointer-origin limitation + +GPUI 0.2.2 exposes window-wide pointer capture, which the Developer Preview +uses so occluding popups cannot bypass the root provenance boundary. Its public +`PlatformInput` representation does not distinguish native hardware input from +an in-process synthetic dispatch, however. The preview therefore proves a +bounded, single-use GPUI pointer-dispatch capability, not operating-system +attestation of a physical device event. The controller/model/Python surfaces +have no API to synthesize GPUI input, and programmatic semantic action dispatch +still receives no token. Production hardening should add an origin-bearing GPUI +API or mint direct-user provenance below the public synthetic dispatch seam. diff --git a/apps/maple-agent/docs/python-code-mode.md b/apps/maple-agent/docs/python-code-mode.md new file mode 100644 index 000000000..d5276ab67 --- /dev/null +++ b/apps/maple-agent/docs/python-code-mode.md @@ -0,0 +1,116 @@ +# Python scratchpad + +Maple's built-in `python_code` tool runs bundled CPython 3.13.15 in the task's +working directory. It is available to Maple-owned desktop and ACP tasks through +their existing permission flow. Its display name is **Python**. + +```json +{"code":"answer = 40\nanswer + 2"} +``` + +Variables, functions, imports, and `_` survive later tool calls and model turns. +Each task has its own interpreter. The final non-`None` expression is displayed +and stored in `_`. Top-level `await` runs on the same continuously active asyncio +loop, so async clients and background tasks can survive between cells. An ordinary +coroutine returned as the final expression is displayed without being awaited. +Use awaitable APIs instead of `asyncio.run()` inside this loop. Synchronous calls +such as `time.sleep()` or `subprocess.run()` pause background asyncio work until +they return. + +Python calls submitted together execute one at a time in submission order, after +those Python calls' permissions are resolved. Denied calls do not hold a place in the +queue. At most 32 approved Python calls are admitted per batch, including the +active call; excess calls return a tool error without running. Reset is part of +that order, so later cells see the new namespace. Stop discards waiting calls. +Use `asyncio.gather` inside a cell for concurrent async work. Other tools remain +concurrent and provide no ordering guarantee relative to Python. + +The bundle provides the standard library. Project modules can be explicitly +imported from the task root; compatible dependency directories can be explicitly +added to `sys.path`. A shell-created virtual environment does not change the +retained interpreter, and the shared bundle is not a package-install destination. +The existing raw execution tools remain available for separate environments. + +## Permissions and results + +Python is explicitly ask-before in Maple's tool policy. Auto mode uses its +existing one-shot approval behavior; other modes and ACP use their existing +approval routing. Python source is not classified as read-only. The desktop +permission card shows complete multiline source and the reset flag in a scrollable +pane. Approval authorizes ordinary native Python filesystem, network, and process +access. Background work can continue after the foreground call completes. + +Results include the worker generation and execution ID, captured stdout/stderr, +final value or traceback, omitted-byte counts, and state-loss notices. First use +identifies the exact interpreter and working directory. Expanded Python cards keep +their code and output available after a generated summary appears. Raw/native and +subprocess output is labelled unattributed; late Python output retains its original +execution ID and is consumed by a later call. Large outputs should be written to +files. Runtime retention limits do not impose a Python memory sandbox. + +## Reset, Stop and task ownership + +The model can reset its own scratchpad: + +```json +{"code":"", "reset":true} +``` + +Empty code with reset releases the worker after cleanup without starting another. +Nonempty code with reset waits for cleanup, then starts a fresh generation. The +desktop task menu offers **Reset Python** for retained state, including settled +tasks. It resolves current state at click time and confirms success after cleanup. +Finish or Stop a running/preparing task before using that menu action. Externally +leased tasks are controlled through their owning ACP session. + +Stop retires an unfinished admitted Python cell and its state. Cancelling a later +model step preserves an already completed cell's namespace and background work. +Reset ends retained background work explicitly. Ordinary Python exceptions keep +partial assignments and external effects; there is no rollback or replay. + +Archive, project removal, deletion, owner replacement, and app/runtime shutdown +retire affected Python state. Archive and project removal update visibility without +waiting for process cleanup. Context compaction and settling a task preserve Python. +Restored/reopened tasks start fresh after prior cleanup; state is not serialized. + +There are four worker slots per Maple service, including workers still starting +or cleaning up. Desktop and standalone ACP processes have separate services. No +task is automatically evicted. Capacity errors name only tasks the caller may see, +and explain how to free a slot. An owning ACP client can close its session or +connection; idle `session/cancel` does not reset Python. + +## Implementation and delivery + +The GPUI-free [runtime crate](../crates/maple-code-mode/README.md) owns framing, +bounded output, process supervision and capacity. Maple binds it to existing +account/task/context authority. Reconstructed developer clients share that task +binding. Goose-created subagents construct their own clients and have no Python +capability in this feature. + +An opt-in scheduler in the pinned Goose fork orders the supported `python_code` +and `developer__python_code` names together. It schedules cold tool streams after +permission decisions in both Goose loop implementations; waiting calls do not +prepare Python, resolve PATH or start workers. This is a bounded batch scheduler, +not a durable job queue or an RLM child scheduler. Direct adapter calls and +unsupported recovered tool-name spellings retain the runtime's busy guard. + +Package resolution and the existing bounded login-PATH probe run only for an +approved first execution, outside Maple lifecycle locks. Every admission rechecks +the installed context and run cancellation. The retained environment removes the +context's inherited scrub keys and the five named Buzz bridge values; ordinary +custom values and explicit context PATH remain supported. Windows environment +matching respects case-insensitive names. The existing shell environment behavior +is unchanged. + +Standalone builds use hash-pinned Python Build Standalone 20260901 artifacts. Nix +retains locked nixpkgs' exact CPython closure. The app launches the packaged +executable with `-I -B -u`, without PATH fallback or runtime downloads. Development +preparation happens through `just python-prepare`; `just build`, `just test`, and +`just debug-app` prepare their required resources. Complete archives include the +interpreter, worker, manifest and upstream license notices. + +CPython-only execution deliberately simplifies PR #2's optional-IPython PoC. That +historical full-system experiment remains preserved. This feature adds no Maple +Python SDK, controller, semantic projection, RLM, Settings toggle or shortcuts. +Future SDK/RLM work needs a separate reviewed scope; no unused engine abstraction +or host-RPC protocol is introduced in anticipation of it. diff --git a/apps/maple-agent/flake.nix b/apps/maple-agent/flake.nix index ae758907e..ef969a813 100644 --- a/apps/maple-agent/flake.nix +++ b/apps/maple-agent/flake.nix @@ -19,6 +19,7 @@ let supportedSystems = [ "aarch64-darwin" + "x86_64-darwin" "aarch64-linux" "x86_64-linux" ]; @@ -30,6 +31,33 @@ ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; forPackageSystems = nixpkgs.lib.genAttrs packageSystems; + # The store reference in runtime.json retains exactly the declared CPython + # and its stdlib/native dependency closure in the installed Maple output. + mkPythonRuntime = + pkgs: + let + python = pkgs.python313; + version = python.version; + manifest = { + protocol_version = 1; + implementation = "cpython"; + inherit version; + distribution = "nix-${python.name}-${builtins.baseNameOf (toString python)}"; + executable = "${python}/bin/python3.13"; + worker = "worker.py"; + }; + in + assert version == "3.13.15"; + pkgs.runCommand "maple-python-${version}" { nativeBuildInputs = [ python ]; } '' + runtime="$out/share/maple-gpui/python" + mkdir -p "$runtime/licenses" + cp ${./crates/maple-code-mode/python/worker.py} "$runtime/worker.py" + cat > "$runtime/runtime.json" <<'JSON' + ${builtins.toJSON manifest} + JSON + tar -xOf ${python.src} Python-${version}/LICENSE > "$runtime/licenses/Python-LICENSE.txt" + ${python}/bin/python3.13 -I -B -c 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; assert sys.version_info[:3] == (3, 13, 15); assert not sysconfig.get_config_var("Py_GIL_DISABLED")' + ''; in { packages = forPackageSystems ( @@ -49,6 +77,7 @@ cargo = rustToolchain; rustc = rustToolchain; }; + pythonRuntime = mkPythonRuntime pkgs; linuxRuntimeInputs = with pkgs; [ libxcb libxkbcommon @@ -62,13 +91,17 @@ xorg.libXi xorg.libXtst ]; - linuxBuildInputs = with pkgs; [ - alsa-lib - fontconfig - freetype - ] ++ linuxRuntimeInputs; + linuxBuildInputs = + with pkgs; + [ + alsa-lib + fontconfig + freetype + ] + ++ linuxRuntimeInputs; in { + python-runtime = pythonRuntime; default = rustPlatform.buildRustPackage { pname = "maple-gpui"; version = "0.1.0"; @@ -103,19 +136,23 @@ "zed-font-kit-0.14.1-zed" = "sha256-KXygi0olNQi5yM8eaJVykNDtbPMDjT+cWPBF8UrtXR4="; "zed-scap-0.0.8-zed" = "sha256-BihiQHlal/eRsktyf0GI3aSWsUCW7WcICMsC2Xvb7kw="; "cua-driver-sdk-0.23.2" = "sha256-769sMyUJU32sjNKQ0ozmwiEEh8rTHgu2ikqdoLrHuZE="; - "goose-1.47.0" = "sha256-STodRA8jEWr5pmOxOKlNGzmg5h8s4GWZWtOYqfaJTLM="; + "goose-1.47.0" = "sha256-CmRMAMlCnfW4NdSREHO+HA2seWu1RkbLHxU0JhCS5XU="; }; }; - nativeBuildInputs = with pkgs; [ - clang - cmake - pkg-config - ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.makeWrapper ]; + nativeBuildInputs = + with pkgs; + [ + clang + cmake + pkg-config + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.makeWrapper ]; - buildInputs = - [ pkgs.libiconv ] - ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; + buildInputs = [ + pkgs.libiconv + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; cargoBuildFlags = [ "-p" @@ -126,6 +163,11 @@ # Keep the package derivation focused on producing the release binary. doCheck = false; + postInstall = '' + mkdir -p "$out/share/maple-gpui" + cp -R ${pythonRuntime}/share/maple-gpui/python "$out/share/maple-gpui/python" + ''; + # GPUI loads the Wayland and Vulkan libraries at runtime, so they are # not discovered by ELF dependency scanning. Prefer the NixOS GPU # driver link and retain Mesa as a portable fallback elsewhere. @@ -172,11 +214,14 @@ xorg.libXi xorg.libXtst ]; - linuxBuildInputs = with pkgs; [ - alsa-lib - fontconfig - freetype - ] ++ linuxRuntimeInputs; + linuxBuildInputs = + with pkgs; + [ + alsa-lib + fontconfig + freetype + ] + ++ linuxRuntimeInputs; isDarwin = pkgs.stdenv.hostPlatform.isDarwin; mkDevShell = if isDarwin then pkgs.mkShellNoCC else pkgs.mkShell; xcrun = pkgs.writeShellScriptBin "xcrun" '' @@ -185,20 +230,34 @@ in { default = mkDevShell { - packages = with pkgs; [ - clang - cmake - pkg-config - rustToolchain - just - python3 - ] ++ pkgs.lib.optionals isDarwin [ xcrun ]; + packages = + with pkgs; + [ + clang + cmake + pkg-config + rustToolchain + python313 + just + ] + ++ pkgs.lib.optionals isDarwin [ xcrun ]; + + buildInputs = [ + pkgs.libiconv + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; - buildInputs = - [ pkgs.libiconv ] - ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; + # ARM64 Linux is a Nix-only delivery target. Other development + # platforms still prepare PBS unless Nix is selected explicitly. + MAPLE_CODE_MODE_NIX_RUNTIME_MANIFEST = "${mkPythonRuntime pkgs}/share/maple-gpui/python/runtime.json"; shellHook = '' + if [ "${system}" = "aarch64-linux" ]; then + export MAPLE_CODE_MODE_DISTRIBUTION="''${MAPLE_CODE_MODE_DISTRIBUTION:-nix}" + fi + if [ "''${MAPLE_CODE_MODE_DISTRIBUTION:-pbs}" = "nix" ]; then + export MAPLE_CODE_MODE_RUNTIME_MANIFEST="''${MAPLE_CODE_MODE_RUNTIME_MANIFEST:-$MAPLE_CODE_MODE_NIX_RUNTIME_MANIFEST}" + fi if [ -z "''${CI:-}" ] \ && [ "''${MAPLE_GPUI_DISABLE_SHARED_CARGO_BUILD_DIR:-0}" != "1" ] \ && [ -z "''${CARGO_BUILD_BUILD_DIR:-}" ] \ @@ -211,10 +270,12 @@ if [ -n "''${CARGO_BUILD_BUILD_DIR:-}" ]; then echo "maple-gpui Cargo build cache: $CARGO_BUILD_BUILD_DIR" fi - '' + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux '' + '' + + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux '' export LD_LIBRARY_PATH="${pkgs.addDriverRunpath.driverLink}/lib:${pkgs.lib.makeLibraryPath linuxRuntimeInputs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export VK_ADD_DRIVER_FILES="${pkgs.addDriverRunpath.driverLink}/share/vulkan/icd.d:${pkgs.mesa}/share/vulkan/icd.d''${VK_ADD_DRIVER_FILES:+:$VK_ADD_DRIVER_FILES}" - '' + pkgs.lib.optionalString isDarwin '' + '' + + pkgs.lib.optionalString isDarwin '' maple_nix_valid_developer_dir() { [ -d "$1" ] \ && [ -x "$1/usr/bin/xcodebuild" ] \ diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 0ab60ac2f..6504837d4 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -35,18 +35,31 @@ clippy: cargo clippy -p maple-gpui --locked --no-default-features --features proxy -- -D warnings # Build and run the tests for the workspace and for the headless build. -test: +test: python-test + python3 -m unittest discover -s scripts/tests RUSTFLAGS="-D warnings" cargo build --workspace --all-targets --locked RUSTFLAGS="-D warnings" cargo test --workspace --locked RUSTFLAGS="-D warnings" cargo test -p maple-gpui --locked {{headless}} +# Prepare the selected pinned runtime; ordinary debug builds use PBS even in Nix. +python-prepare: + python3 scripts/prepare-python.py + +# Run the focused Python protocol/language suite on the selected interpreter. +python-test: python-prepare + python3 scripts/test-python-worker.py + +# Exercise the debug worker using the same package resolver as the app. +code-mode-smoke: python-prepare + cargo run -p maple-code-mode --bin code-mode-smoke --locked + # Build the debug binary. -build: +build: python-prepare cargo build -p maple-gpui # Stage the debug binary in a stable macOS app bundle. A stable bundle identity # is required for honest Accessibility and Screen Recording permission tests. -debug-app: build +debug-app: build code-mode-smoke ./scripts/macos-debug-app.sh # Build the release binary. @@ -66,7 +79,7 @@ headless: run *ARGS: build RUST_LOG=warn,maple_gpui=debug ./target/debug/maple-gpui {{ARGS}} -# Build the release binary and copy it to dist/ with a version, commit, and SHA-256. +# Archive the release binary and Python in dist/ with version, commit, and SHA-256. dist: release #!/usr/bin/env bash set -euo pipefail @@ -82,8 +95,9 @@ dist: release esac name="maple-gpui-${version}-${rev}${dirty}-${target}" mkdir -p dist - cp target/release/maple-gpui "dist/${name}" - (cd dist && shasum -a 256 "${name}" | tee "${name}.sha256") + python3 scripts/prepare-python.py --distribution pbs --destination target/release/runtime/python + python3 scripts/package-archive.py --binary target/release/maple-gpui \ + --runtime target/release/runtime/python --name "$name" # Remove this checkout's target/ without wiping the shared Cargo cache. clean-local: diff --git a/apps/maple-agent/scripts/check-python-package.py b/apps/maple-agent/scripts/check-python-package.py new file mode 100644 index 000000000..ddc64230b --- /dev/null +++ b/apps/maple-agent/scripts/check-python-package.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Exercise a copied PBS runtime offline, away from its original path and CWD.""" + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import tarfile +import zipfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--smoke", type=Path, required=True) + parser.add_argument("--runtime", type=Path, default=Path("target/debug/runtime/python")) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="maple package ") as temporary: + root = Path(temporary) + # Exercise the same archive writer used by release CI using debug code. + spec = importlib.util.spec_from_file_location("maple_archive", Path(__file__).with_name("package-archive.py")) + packager = importlib.util.module_from_spec(spec) + spec.loader.exec_module(packager) + archive = packager.package(args.smoke.resolve(), args.runtime.resolve(), "Relocated Maple 日本語", root / "archives") + if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as source: + source.extractall(root) + else: + with tarfile.open(archive) as source: + source.extractall(root, filter="data") + bundle = root / "Relocated Maple 日本語" + runtime = bundle / "runtime/python" + smoke = bundle / args.smoke.name + cwd = root / "arbitrary task directory" + cwd.mkdir() + manifest = json.loads((runtime / "runtime.json").read_text()) + if not manifest["distribution"].startswith("pbs-"): + raise ValueError("Relocation check requires the portable PBS fixture") + environment = os.environ.copy() + environment.pop("MAPLE_CODE_MODE_RUNTIME_MANIFEST", None) + environment["PATH"] = "" + environment["PYTHONHOME"] = "invalid ignored PYTHONHOME" + environment["PYTHONPATH"] = "invalid ignored PYTHONPATH" + subprocess.run([ + str(runtime / manifest["executable"]), "-I", "-B", "-u", "-c", + 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; ' + 'assert sys.version_info[:3] == (3, 13, 15); ' + 'assert not sysconfig.get_config_var("Py_GIL_DISABLED"); ' + 'assert "" not in sys.path', + ], cwd=cwd, env=environment, check=True, timeout=30) + subprocess.run([str(smoke)], cwd=cwd, env=environment, check=True, timeout=60) + size = sum(path.stat().st_size for path in runtime.rglob("*") if path.is_file() and not path.is_symlink()) + print(f"Relocated offline PBS archive passed: {size:,} installed bytes") + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/macos-debug-app.sh b/apps/maple-agent/scripts/macos-debug-app.sh index a06928de7..219c71358 100755 --- a/apps/maple-agent/scripts/macos-debug-app.sh +++ b/apps/maple-agent/scripts/macos-debug-app.sh @@ -17,6 +17,15 @@ if [[ ! "$bundle_id" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]; then exit 1 fi +python_source="$repo_root/target/debug/runtime/python" +worker_smoke="$repo_root/target/debug/code-mode-smoke" + +python3 "$repo_root/scripts/prepare-python.py" --distribution pbs +if [[ ! -x "$worker_smoke" ]]; then + echo "CodeMode debug smoke binary missing; run 'just code-mode-smoke' first" >&2 + exit 1 +fi + if [[ ! -x "$binary_source" ]]; then echo "debug binary not found at $binary_source; run 'just build' first" >&2 exit 1 @@ -52,7 +61,8 @@ contents_dir="$staged_bundle/Contents" frameworks_dir="$contents_dir/Frameworks" binary_destination="$contents_dir/MacOS/maple-gpui" -mkdir -p "$contents_dir/MacOS" "$frameworks_dir" +mkdir -p "$contents_dir/MacOS" "$frameworks_dir" "$contents_dir/Resources" +cp -R "$python_source" "$contents_dir/Resources/python" cp "$repo_root/app/macos/Info.plist" "$contents_dir/Info.plist" python3 "$repo_root/scripts/macos-debug-plist.py" "$contents_dir/Info.plist" cp "$binary_source" "$binary_destination" @@ -91,6 +101,15 @@ while IFS= read -r -d '' runtime_library; do "$runtime_library" done < <(/usr/bin/find "$frameworks_dir" -type f -name '*.dylib' -print0) +# Sign every regular Mach-O payload, including stdlib extension modules and +# libpython, before sealing the outer bundle. Symlink aliases share that code. +while IFS= read -r -d '' python_code; do + if /usr/bin/file -b "$python_code" | /usr/bin/grep -q 'Mach-O'; then + /usr/bin/codesign --force --sign "$codesign_identity" --timestamp=none \ + "$python_code" + fi +done < <(/usr/bin/find "$contents_dir/Resources/python" -type f -print0) + # Rust's linker gives arm64 executables an ad hoc signature, but copying that # executable into an app bundle does not bind Info.plist or seal the bundle. # TCC would then record a grant that the relaunched app cannot satisfy. Sign @@ -117,6 +136,11 @@ if /usr/bin/grep -Fq "is implemented in both" "$smoke_stderr"; then exit 1 fi +# Execute the actual signed nested interpreter and native stdlib extensions. +"$contents_dir/Resources/python/bin/python3.13" -I -B -u -c \ + 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; assert sys.version_info[:3] == (3, 13, 15); assert not sysconfig.get_config_var("Py_GIL_DISABLED")' +"$worker_smoke" --manifest "$contents_dir/Resources/python/runtime.json" + # Build and validate away from the destination so a failed packaging step # leaves the developer's last working bundle untouched. Replace only the # explicit, validated .app path once the new bundle is complete. diff --git a/apps/maple-agent/scripts/package-archive.py b/apps/maple-agent/scripts/package-archive.py new file mode 100644 index 000000000..c8695d27f --- /dev/null +++ b/apps/maple-agent/scripts/package-archive.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Archive a built Maple executable together with its prepared PBS runtime.""" + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import tempfile + + +def package(binary, runtime, name, output): + if Path(name).name != name or name in ("", ".", ".."): + raise ValueError("Archive name must be one filename component") + manifest = json.loads((runtime / "runtime.json").read_text()) + if not manifest["distribution"].startswith("pbs-"): + raise ValueError("Portable archives require the pinned PBS runtime; Nix has its own closure") + if not binary.is_file() or not (runtime / manifest["executable"]).is_file() or not (runtime / manifest["worker"]).is_file(): + raise ValueError("Archive requires the built executable and complete prepared Python runtime") + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".maple-archive-", dir=output) as temporary: + staging = Path(temporary) / name + staging.mkdir() + shutil.copy2(binary, staging / binary.name) + shutil.copytree(runtime, staging / "runtime/python", symlinks=True) + shutil.copy2(Path(__file__).resolve().parent.parent / "LICENSE", staging / "LICENSE") + archive_format = "zip" if binary.suffix.lower() == ".exe" else "gztar" + archive = Path(shutil.make_archive(str(Path(temporary) / name), archive_format, temporary, name)) + destination = output / archive.name + archive.replace(destination) + with destination.open("rb") as source: + checksum = hashlib.file_digest(source, "sha256").hexdigest() + destination.with_name(destination.name + ".sha256").write_text(f"{checksum} {destination.name}\n") + print(destination) + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--output-dir", type=Path, default=Path("dist")) + args = parser.parse_args() + package(args.binary.resolve(), args.runtime.resolve(), args.name, args.output_dir.resolve()) + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/prepare-python.py b/apps/maple-agent/scripts/prepare-python.py new file mode 100644 index 000000000..749e3d88b --- /dev/null +++ b/apps/maple-agent/scripts/prepare-python.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Prepare pinned build-time Python resources. Never used by product startup.""" + +import argparse +import contextlib +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import shutil +import sys +import tarfile +import tempfile +import urllib.request + +REPO = Path(__file__).resolve().parent.parent +PINS = Path(__file__).with_name("python-runtime.json") +RECEIPT = ".prepared.json" +LICENSES = Path(__file__).with_name("python-licenses") + + +def digest(path): + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def host_target(): + key = (platform.system(), platform.machine().lower()) + targets = { + ("Darwin", "arm64"): "aarch64-apple-darwin", + ("Darwin", "x86_64"): "x86_64-apple-darwin", + ("Linux", "x86_64"): "x86_64-unknown-linux-gnu", + ("Windows", "amd64"): "x86_64-pc-windows-msvc", + } + if key not in targets: + raise ValueError(f"No PBS delivery for {key}; Linux ARM64 must use the explicit Nix fixture") + return targets[key] + + +@contextlib.contextmanager +def preparation_lock(path): + """Kernel-owned lock is automatically released after a failed preparation.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+b") as handle: + if os.name == "nt": + import msvcrt + + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def download(asset, cache, offline): + archive = cache / (asset["sha256"] + ".tar.gz") + cache.mkdir(parents=True, exist_ok=True) + if archive.is_file() and digest(archive) == asset["sha256"]: + return archive + if offline: + raise ValueError(f"Verified Python archive missing from {cache}; run just python-prepare online first") + # The digest, not an existing filename or an HTTP response, authorizes extraction. + with tempfile.NamedTemporaryFile(dir=cache, suffix=".download", delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + print(f"Downloading {asset['url']}", file=sys.stderr) + with urllib.request.urlopen(asset["url"], timeout=60) as response: + shutil.copyfileobj(response, temporary) + temporary.flush() + if digest(temporary_path) != asset["sha256"]: + raise ValueError("Python archive SHA-256 does not match scripts/python-runtime.json") + except BaseException: + temporary.close() + temporary_path.unlink(missing_ok=True) + raise + os.replace(temporary_path, archive) + return archive + + +def safe_extract(archive, destination): + """Keep the archive's python/ root; reject traversal, external links and devices.""" + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + if len(members) > 50000 or sum(member.size for member in members) > 1024**3: + raise ValueError("Python archive exceeds extraction limits") + for member in members: + name = PurePosixPath(member.name) + if ( + name.is_absolute() + or not name.parts + or name.parts[0] != "python" + or ".." in name.parts + or "\\" in member.name + or ":" in member.name + ): + raise ValueError(f"Unsafe Python archive path: {member.name!r}") + if not (member.isfile() or member.isdir() or member.issym() or member.islnk()): + raise ValueError(f"Unsupported Python archive entry: {member.name!r}") + if member.issym() or member.islnk(): + link = PurePosixPath(member.linkname) + if link.is_absolute() or "\\" in member.linkname or ":" in member.linkname: + raise ValueError(f"Unsafe Python archive link: {member.name!r}") + target = (destination / (name.parent if member.issym() else Path()) / link).resolve() + if not target.is_relative_to((destination / "python").resolve()): + raise ValueError(f"Python archive link escapes its root: {member.name!r}") + # data_filter additionally resolves symlink chains against the extraction + # destination and strips unsafe permissions. Requires build Python >=3.13. + source.extractall(destination, members=members, filter="data") + return destination / "python" + + +def inventory(root): + files = {} + for path in sorted(root.rglob("*")): + name = path.relative_to(root).as_posix() + if name == RECEIPT: + continue + if path.is_symlink(): + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError(f"Staged Python link escapes its root: {name}") + files[name] = {"link": os.readlink(path)} + elif path.is_file(): + files[name] = {"sha256": digest(path), "executable": bool(path.stat().st_mode & 0o111)} + return files + + +def reusable(root, identity): + try: + receipt = json.loads((root / RECEIPT).read_text()) + return receipt["identity"] == identity and receipt["files"] == inventory(root) + except (OSError, ValueError, KeyError): + return False + + +def replace_directory(source, destination): + """Publish complete staging by rename, retaining the old tree on failure.""" + backup = destination.with_name(destination.name + ".previous") + if backup.exists(): + if not destination.exists(): + backup.rename(destination) + else: + shutil.rmtree(backup) + had_previous = destination.exists() + if had_previous: + destination.rename(backup) + try: + source.rename(destination) + except BaseException: + if had_previous: + backup.rename(destination) + raise + if had_previous: + shutil.rmtree(backup) + + +def prepare_pbs(target, destination, cache, worker, offline=False): + pins = json.loads(PINS.read_text()) + if target not in pins["targets"]: + raise ValueError(f"No pinned Python distribution for {target}") + asset = pins["targets"][target] + manifest = { + "protocol_version": 1, + "implementation": pins["implementation"], + "version": pins["version"], + "distribution": f"pbs-{pins['release']}-{target}", + "executable": asset["executable"], + "worker": "worker.py", + } + identity = {"manifest": manifest, "archive_sha256": asset["sha256"], "worker_sha256": digest(worker), "licenses": inventory(LICENSES)} + destination.parent.mkdir(parents=True, exist_ok=True) + with preparation_lock(destination.with_name(destination.name + ".lock")): + if reusable(destination, identity): + print(f"Reusing verified Python runtime: {destination}") + return destination / "runtime.json" + archive = download(asset, cache, offline) + with tempfile.TemporaryDirectory(prefix=".python-stage-", dir=destination.parent) as temporary: + runtime = safe_extract(archive, Path(temporary)) + if not (runtime / asset["executable"]).is_file(): + raise ValueError(f"Python archive lacks {asset['executable']}") + if not list(runtime.rglob("*LICENSE*")) and not list(runtime.rglob("*license*")): + raise ValueError("Python archive lacks license notices") + # install_only excludes upstream's top-level dependency notices. + # Ship the pinned release's complete notice set alongside notices + # already present in the Python installation. + shutil.copytree(LICENSES, runtime / "licenses" / "python-build-standalone", dirs_exist_ok=True) + shutil.copy2(worker, runtime / "worker.py") + (runtime / "runtime.json").write_text(json.dumps(manifest, indent=2) + "\n") + (runtime / RECEIPT).write_text(json.dumps({"identity": identity, "files": inventory(runtime)}, indent=2) + "\n") + replace_directory(runtime, destination) + print(f"Prepared Python runtime: {destination}") + return destination / "runtime.json" + + +def verify_nix(manifest_path): + if not manifest_path or not Path(manifest_path).is_absolute(): + raise ValueError("Nix fixture requires explicit MAPLE_CODE_MODE_RUNTIME_MANIFEST from nix develop") + path = Path(manifest_path) + manifest = json.loads(path.read_text()) + pins = json.loads(PINS.read_text()) + if ( + manifest.get("protocol_version") != 1 + or manifest.get("implementation") != "cpython" + or manifest.get("version") != pins["version"] + or not manifest.get("distribution", "").startswith("nix-") + or not manifest.get("executable", "").startswith("/nix/store/") + or not Path(manifest["executable"]).is_file() + or not (path.parent / manifest["worker"]).is_file() + or not (path.parent / "licenses" / "Python-LICENSE.txt").is_file() + ): + raise ValueError(f"Invalid declared Nix Python installation: {path}") + print(f"Using declared Nix Python runtime: {path}") + return path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distribution", choices=["pbs", "nix"], default=os.environ.get("MAPLE_CODE_MODE_DISTRIBUTION", "pbs")) + parser.add_argument("--target") + parser.add_argument("--destination", type=Path, default=REPO / "target/debug/runtime/python") + parser.add_argument("--cache", type=Path, default=REPO / "target/python-cache") + parser.add_argument("--worker", type=Path, default=REPO / "crates/maple-code-mode/python/worker.py") + parser.add_argument("--manifest", default=os.environ.get("MAPLE_CODE_MODE_RUNTIME_MANIFEST")) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + try: + if sys.version_info < (3, 13): + raise ValueError("Preparation requires build Python 3.13; use nix develop -c just python-prepare") + if args.distribution == "nix": + verify_nix(args.manifest) + else: + prepare_pbs(args.target or host_target(), args.destination.resolve(), args.cache.resolve(), args.worker.resolve(), args.offline) + except (OSError, ValueError, tarfile.TarError) as error: + parser.exit(1, f"Python preparation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE b/apps/maple-agent/scripts/python-licenses/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt new file mode 100644 index 000000000..0601b45e0 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt @@ -0,0 +1,126 @@ +The following is the license that applies to this copy of the Berkeley DB +software. For a license to use the Berkeley DB software under conditions +other than those described here, or to purchase support for this software, +please contact Oracle at berkeleydb-info_us@oracle.com. + +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +/* + * Copyright (c) 1990, 2013 Oracle and/or its affiliates. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Redistributions in any form must be accompanied by information on + * how to obtain complete source code for the DB software and any + * accompanying software that uses the DB software. The source code + * must either be included in the distribution or be available for no + * more than the cost of distribution plus a nominal fee, and must be + * freely redistributable under reasonable conditions. For an + * executable file, complete source code means the source code for all + * modules it contains. It does not include source code for modules or + * files that typically accompany the major components of the operating + * system on which the executable file runs. + * + * THIS SOFTWARE IS PROVIDED BY ORACLE ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR + * NON-INFRINGEMENT, ARE DISCLAIMED. IN NO EVENT SHALL ORACLE BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN + * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +/* + * Copyright (c) 1990, 1993, 1994, 1995 + * The Regents of the University of California. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ +/* + * Copyright (c) 1995, 1996 + * The President and Fellows of Harvard University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY HARVARD AND ITS CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL HARVARD OR ITS CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +/*** + * ASM: a very small and fast Java bytecode manipulation framework + * Copyright (c) 2000-2005 INRIA, France Telecom + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holders nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt new file mode 100644 index 000000000..a5c4cdbc3 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt @@ -0,0 +1,37 @@ +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2010 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@bzip.org +bzip2/libbzip2 version 1.0.6 of 6 September 2010 diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt new file mode 100644 index 000000000..1007a8052 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt @@ -0,0 +1,771 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +Licenses and Acknowledgements for Incorporated Software +======================================================= + +This section is an incomplete, but growing list of licenses and acknowledgements +for third-party software incorporated in the Python distribution. + +Mersenne Twister +---------------- + +The :mod:`_random` module includes code based on a download from +http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/MT2002/emt19937ar.html. The following are +the verbatim comments from the original code:: + + A C-program for MT19937, with initialization improved 2002/1/26. + Coded by Takuji Nishimura and Makoto Matsumoto. + + Before using, initialize the state by using init_genrand(seed) + or init_by_array(init_key, key_length). + + Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura, + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. The names of its contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Any feedback is very welcome. + http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html + email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space) + + +Sockets +------- + +The :mod:`socket` module uses the functions, :func:`getaddrinfo`, and +:func:`getnameinfo`, which are coded in separate source files from the WIDE +Project, http://www.wide.ad.jp/. :: + + Copyright (C) 1995, 1996, 1997, and 1998 WIDE Project. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the project nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE PROJECT AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE PROJECT OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + + +Asynchronous socket services +---------------------------- + +The :mod:`asynchat` and :mod:`asyncore` modules contain the following notice:: + + Copyright 1996 by Sam Rushing + + All Rights Reserved + + Permission to use, copy, modify, and distribute this software and + its documentation for any purpose and without fee is hereby + granted, provided that the above copyright notice appear in all + copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of Sam + Rushing not be used in advertising or publicity pertaining to + distribution of the software without specific, written prior + permission. + + SAM RUSHING DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, + INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN + NO EVENT SHALL SAM RUSHING BE LIABLE FOR ANY SPECIAL, INDIRECT OR + CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, + NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +Cookie management +----------------- + +The :mod:`http.cookies` module contains the following notice:: + + Copyright 2000 by Timothy O'Malley + + All Rights Reserved + + Permission to use, copy, modify, and distribute this software + and its documentation for any purpose and without fee is hereby + granted, provided that the above copyright notice appear in all + copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + Timothy O'Malley not be used in advertising or publicity + pertaining to distribution of the software without specific, written + prior permission. + + Timothy O'Malley DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS, IN NO EVENT SHALL Timothy O'Malley BE LIABLE FOR + ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS + ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + + +Execution tracing +----------------- + +The :mod:`trace` module contains the following notice:: + + portions copyright 2001, Autonomous Zones Industries, Inc., all rights... + err... reserved and offered to the public under the terms of the + Python 2.2 license. + Author: Zooko O'Whielacronx + http://zooko.com/ + mailto:zooko@zooko.com + + Copyright 2000, Mojam Media, Inc., all rights reserved. + Author: Skip Montanaro + + Copyright 1999, Bioreason, Inc., all rights reserved. + Author: Andrew Dalke + + Copyright 1995-1997, Automatrix, Inc., all rights reserved. + Author: Skip Montanaro + + Copyright 1991-1995, Stichting Mathematisch Centrum, all rights reserved. + + + Permission to use, copy, modify, and distribute this Python software and + its associated documentation for any purpose without fee is hereby + granted, provided that the above copyright notice appears in all copies, + and that both that copyright notice and this permission notice appear in + supporting documentation, and that the name of neither Automatrix, + Bioreason or Mojam Media be used in advertising or publicity pertaining to + distribution of the software without specific, written prior permission. + + +UUencode and UUdecode functions +------------------------------- + +The :mod:`uu` module contains the following notice:: + + Copyright 1994 by Lance Ellinghouse + Cathedral City, California Republic, United States of America. + All Rights Reserved + Permission to use, copy, modify, and distribute this software and its + documentation for any purpose and without fee is hereby granted, + provided that the above copyright notice appear in all copies and that + both that copyright notice and this permission notice appear in + supporting documentation, and that the name of Lance Ellinghouse + not be used in advertising or publicity pertaining to distribution + of the software without specific, written prior permission. + LANCE ELLINGHOUSE DISCLAIMS ALL WARRANTIES WITH REGARD TO + THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL LANCE ELLINGHOUSE CENTRUM BE LIABLE + FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT + OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + Modified by Jack Jansen, CWI, July 1995: + - Use binascii module to do the actual line-by-line conversion + between ascii and binary. This results in a 1000-fold speedup. The C + version is still 5 times faster, though. + - Arguments more compliant with Python standard + + +XML Remote Procedure Calls +-------------------------- + +The :mod:`xmlrpc.client` module contains the following notice:: + + The XML-RPC client interface is + + Copyright (c) 1999-2002 by Secret Labs AB + Copyright (c) 1999-2002 by Fredrik Lundh + + By obtaining, using, and/or copying this software and/or its + associated documentation, you agree that you have read, understood, + and will comply with the following terms and conditions: + + Permission to use, copy, modify, and distribute this software and + its associated documentation for any purpose and without fee is + hereby granted, provided that the above copyright notice appears in + all copies, and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + Secret Labs AB or the author not be used in advertising or publicity + pertaining to distribution of the software without specific, written + prior permission. + + SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD + TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- + ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR + BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY + DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS + ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE + OF THIS SOFTWARE. + + +test_epoll +---------- + +The :mod:`test_epoll` module contains the following notice:: + + Copyright (c) 2001-2006 Twisted Matrix Laboratories. + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Select kqueue +------------- + +The :mod:`select` module contains the following notice for the kqueue +interface:: + + Copyright (c) 2000 Doug White, 2006 James Knight, 2007 Christian Heimes + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + + +SipHash24 +--------- + +The file :file:`Python/pyhash.c` contains Marek Majkowski' implementation of +Dan Bernstein's SipHash24 algorithm. It contains the following note:: + + + Copyright (c) 2013 Marek Majkowski + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + + Original location: + https://github.com/majek/csiphash/ + + Solution inspired by code from: + Samuel Neves (supercop/crypto_auth/siphash24/little) + djb (supercop/crypto_auth/siphash24/little2) + Jean-Philippe Aumasson (https://131002.net/siphash/siphash24.c) + + +strtod and dtoa +--------------- + +The file :file:`Python/dtoa.c`, which supplies C functions dtoa and +strtod for conversion of C doubles to and from strings, is derived +from the file of the same name by David M. Gay, currently available +from http://www.netlib.org/fp/. The original file, as retrieved on +March 16, 2009, contains the following copyright and licensing +notice:: + + /**************************************************************** + * + * The author of this software is David M. Gay. + * + * Copyright (c) 1991, 2000, 2001 by Lucent Technologies. + * + * Permission to use, copy, modify, and distribute this software for any + * purpose without fee is hereby granted, provided that this entire notice + * is included in all copies of any software which is or includes a copy + * or modification of this software and in all copies of the supporting + * documentation for such software. + * + * THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED + * WARRANTY. IN PARTICULAR, NEITHER THE AUTHOR NOR LUCENT MAKES ANY + * REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY + * OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + * + ***************************************************************/ + +expat +----- + +The :mod:`pyexpat` extension is built using an included copy of the expat +sources unless the build is configured ``--with-system-expat``:: + + Copyright (c) 1998, 1999, 2000 Thai Open Source Software Center Ltd + and Clark Cooper + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +cfuhash +------- + +The implementation of the hash table used by the :mod:`tracemalloc` is based +on the cfuhash project:: + + Copyright (c) 2005 Don Owens + All rights reserved. + + This code is released under the BSD license: + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the author nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + OF THE POSSIBILITY OF SUCH DAMAGE. + + +libmpdec +-------- + +The :mod:`_decimal` module is built using an included copy of the libmpdec +library unless the build is configured ``--with-system-libmpdec``:: + + Copyright (c) 2008-2020 Stefan Krah. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + +W3C C14N test suite +------------------- + +The C14N 2.0 test suite in the :mod:`test` package +(``Lib/test/xmltestdata/c14n-20/``) was retrieved from the W3C website at +https://www.w3.org/TR/xml-c14n2-testcases/ and is distributed under the +3-clause BSD license:: + + Copyright (c) 2013 W3C(R) (MIT, ERCIM, Keio, Beihang), + All Rights Reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of works must retain the original copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the original copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the W3C nor the names of its contributors may be + used to endorse or promote products derived from this work without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt new file mode 100644 index 000000000..ce9e59392 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt @@ -0,0 +1,21 @@ +Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper +Copyright (c) 2001-2022 Expat maintainers + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt new file mode 100644 index 000000000..b065516e4 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt @@ -0,0 +1,942 @@ +The following is the 'standard copyright' agreed upon by most contributors, +and is currently the canonical license preferred by the X.Org Foundation. +This is a slight variant of the common MIT license form published by the +Open Source Initiative at http://www.opensource.org/licenses/mit-license.php + +Copyright holders of new code should use this license statement where +possible, and insert their name to this list. Please sort by surname +for people, and by the full name for other entities (e.g. Juliusz +Chroboczek sorts before Intel Corporation sorts before Daniel Stone). + +See each individual source file or directory for the license that applies +to that file. + +Copyright (C) 2003-2006,2008 Jamey Sharp, Josh Triplett +Copyright © 2009 Red Hat, Inc. +Copyright 1990-1992,1999,2000,2004,2009,2010 Oracle and/or its affiliates. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + ---------------------------------------------------------------------- + +The following licenses are 'legacy' - usually MIT/X11 licenses with the name +of the copyright holder(s) in the license statement: + +Copyright 1984-1994, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. + +X Window System is a trademark of The Open Group. + + ---------------------------------------- + +Copyright 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1994, 1996 X Consortium +Copyright 2000 The XFree86 Project, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of the X Consortium shall +not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization +from the X Consortium. + +Copyright 1985, 1986, 1987, 1988, 1989, 1990, 1991 by +Digital Equipment Corporation + +Portions Copyright 1990, 1991 by Tektronix, Inc. + +Permission to use, copy, modify and distribute this documentation for +any purpose and without fee is hereby granted, provided that the above +copyright notice appears in all copies and that both that copyright notice +and this permission notice appear in all copies, and that the names of +Digital and Tektronix not be used in in advertising or publicity pertaining +to this documentation without specific, written prior permission. +Digital and Tektronix makes no representations about the suitability +of this documentation for any purpose. +It is provided ``as is'' without express or implied warranty. + + ---------------------------------------- + +Copyright (c) 1999-2000 Free Software Foundation, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +FREE SOFTWARE FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of the Free Software Foundation +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization from the +Free Software Foundation. + + ---------------------------------------- + +Code and supporting documentation (c) Copyright 1990 1991 Tektronix, Inc. + All Rights Reserved + +This file is a component of an X Window System-specific implementation +of Xcms based on the TekColor Color Management System. TekColor is a +trademark of Tektronix, Inc. The term "TekHVC" designates a particular +color space that is the subject of U.S. Patent No. 4,985,853 (equivalent +foreign patents pending). Permission is hereby granted to use, copy, +modify, sell, and otherwise distribute this software and its +documentation for any purpose and without fee, provided that: + +1. This copyright, permission, and disclaimer notice is reproduced in + all copies of this software and any modification thereof and in + supporting documentation; +2. Any color-handling application which displays TekHVC color + cooordinates identifies these as TekHVC color coordinates in any + interface that displays these coordinates and in any associated + documentation; +3. The term "TekHVC" is always used, and is only used, in association + with the mathematical derivations of the TekHVC Color Space, + including those provided in this file and any equivalent pathways and + mathematical derivations, regardless of digital (e.g., floating point + or integer) representation. + +Tektronix makes no representation about the suitability of this software +for any purpose. It is provided "as is" and with all faults. + +TEKTRONIX DISCLAIMS ALL WARRANTIES APPLICABLE TO THIS SOFTWARE, +INCLUDING THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE. IN NO EVENT SHALL TEKTRONIX BE LIABLE FOR ANY +SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA, OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE, OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR THE PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +(c) Copyright 1995 FUJITSU LIMITED +This is source code modified by FUJITSU LIMITED under the Joint +Development Agreement for the CDE/Motif PST. + + ---------------------------------------- + +Copyright 1992 by Oki Technosystems Laboratory, Inc. +Copyright 1992 by Fuji Xerox Co., Ltd. + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Oki Technosystems +Laboratory and Fuji Xerox not be used in advertising or publicity +pertaining to distribution of the software without specific, written +prior permission. +Oki Technosystems Laboratory and Fuji Xerox make no representations +about the suitability of this software for any purpose. It is provided +"as is" without express or implied warranty. + +OKI TECHNOSYSTEMS LABORATORY AND FUJI XEROX DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL OKI TECHNOSYSTEMS +LABORATORY AND FUJI XEROX BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE +OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1990, 1991, 1992, 1993, 1994 by FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of FUJITSU LIMITED +not be used in advertising or publicity pertaining to distribution +of the software without specific, written prior permission. +FUJITSU LIMITED makes no representations about the suitability of +this software for any purpose. +It is provided "as is" without express or implied warranty. + +FUJITSU LIMITED DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL FUJITSU LIMITED BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + + +Copyright (c) 1995 David E. Wexelblat. All rights reserved + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL DAVID E. WEXELBLAT BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of David E. Wexelblat shall +not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization +from David E. Wexelblat. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name OMRON not be used in +advertising or publicity pertaining to distribution of the software without +specific, written prior permission. OMRON makes no representations +about the suitability of this software for any purpose. It is provided +"as is" without express or implied warranty. + +OMRON DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL OMRON BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTUOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1985, 1986, 1987, 1988, 1989, 1990, 1991 by +Digital Equipment Corporation + +Portions Copyright 1990, 1991 by Tektronix, Inc + +Rewritten for X.org by Chris Lee + +Permission to use, copy, modify, distribute, and sell this documentation +for any purpose and without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. +Chris Lee makes no representations about the suitability for any purpose +of the information in this document. It is provided \`\`as-is'' without +express or implied warranty. + + ---------------------------------------- + +Copyright 1993 by Digital Equipment Corporation, Maynard, Massachusetts, +Copyright 1994 by FUJITSU LIMITED +Copyright 1994 by Sony Corporation + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the names of Digital, FUJITSU +LIMITED and Sony Corporation not be used in advertising or publicity +pertaining to distribution of the software without specific, written +prior permission. + +DIGITAL, FUJITSU LIMITED AND SONY CORPORATION DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL DIGITAL, FUJITSU LIMITED +AND SONY CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + + +Copyright 1991 by the Open Software Foundation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of Open Software Foundation +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. Open Software +Foundation makes no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OPEN SOFTWARE FOUNDATION DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL OPEN SOFTWARE FOUNDATIONN BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1990, 1991, 1992,1993, 1994 by FUJITSU LIMITED +Copyright 1993, 1994 by Sony Corporation + +Permission to use, copy, modify, distribute, and sell this software and +its documentation for any purpose is hereby granted without fee, provided +that the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of FUJITSU LIMITED and Sony Corporation +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. FUJITSU LIMITED and +Sony Corporation makes no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +FUJITSU LIMITED AND SONY CORPORATION DISCLAIMS ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL FUJITSU LIMITED OR SONY CORPORATION BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE +USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright (c) 1993, 1995 by Silicon Graphics Computer Systems, Inc. + +Permission to use, copy, modify, and distribute this +software and its documentation for any purpose and without +fee is hereby granted, provided that the above copyright +notice appear in all copies and that both that copyright +notice and this permission notice appear in supporting +documentation, and that the name of Silicon Graphics not be +used in advertising or publicity pertaining to distribution +of the software without specific prior written permission. +Silicon Graphics makes no representation about the suitability +of this software for any purpose. It is provided "as is" +without any express or implied warranty. + +SILICON GRAPHICS DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE. IN NO EVENT SHALL SILICON +GRAPHICS BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH +THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1991, 1992, 1993, 1994 by FUJITSU LIMITED +Copyright 1993 by Digital Equipment Corporation + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of FUJITSU LIMITED and +Digital Equipment Corporation not be used in advertising or publicity +pertaining to distribution of the software without specific, written +prior permission. FUJITSU LIMITED and Digital Equipment Corporation +makes no representations about the suitability of this software for +any purpose. It is provided "as is" without express or implied +warranty. + +FUJITSU LIMITED AND DIGITAL EQUIPMENT CORPORATION DISCLAIM ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +FUJITSU LIMITED AND DIGITAL EQUIPMENT CORPORATION BE LIABLE FOR +ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER +IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1992, 1993 by FUJITSU LIMITED +Copyright 1993 by Fujitsu Open Systems Solutions, Inc. +Copyright 1994 by Sony Corporation + +Permission to use, copy, modify, distribute and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of FUJITSU LIMITED, +Fujitsu Open Systems Solutions, Inc. and Sony Corporation not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. +FUJITSU LIMITED, Fujitsu Open Systems Solutions, Inc. and +Sony Corporation make no representations about the suitability of +this software for any purpose. It is provided "as is" without +express or implied warranty. + +FUJITSU LIMITED, FUJITSU OPEN SYSTEMS SOLUTIONS, INC. AND SONY +CORPORATION DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, +IN NO EVENT SHALL FUJITSU OPEN SYSTEMS SOLUTIONS, INC., FUJITSU LIMITED +AND SONY CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE +OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1987, 1988, 1990, 1993 by Digital Equipment Corporation, +Maynard, Massachusetts, + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Digital not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +DIGITAL DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +DIGITAL BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + ---------------------------------------- + +Copyright 1993 by SunSoft, Inc. +Copyright 1999-2000 by Bruno Haible + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the names of SunSoft, Inc. and +Bruno Haible not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. SunSoft, Inc. and Bruno Haible make no representations +about the suitability of this software for any purpose. It is +provided "as is" without express or implied warranty. + +SunSoft Inc. AND Bruno Haible DISCLAIM ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS, IN NO EVENT SHALL SunSoft, Inc. OR Bruno Haible BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1991 by the Open Software Foundation +Copyright 1993 by the TOSHIBA Corp. + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of Open Software Foundation and TOSHIBA +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. Open Software +Foundation and TOSHIBA make no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OPEN SOFTWARE FOUNDATION AND TOSHIBA DISCLAIM ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL OPEN SOFTWARE FOUNDATIONN OR TOSHIBA BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1988 by Wyse Technology, Inc., San Jose, Ca., + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name Wyse not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +WYSE DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +DIGITAL BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + ---------------------------------------- + + +Copyright 1991 by the Open Software Foundation +Copyright 1993, 1994 by the Sony Corporation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of Open Software Foundation and +Sony Corporation not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior permission. +Open Software Foundation and Sony Corporation make no +representations about the suitability of this software for any purpose. +It is provided "as is" without express or implied warranty. + +OPEN SOFTWARE FOUNDATION AND SONY CORPORATION DISCLAIM ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL OPEN +SOFTWARE FOUNDATIONN OR SONY CORPORATION BE LIABLE FOR ANY SPECIAL, +INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1992, 1993 by FUJITSU LIMITED +Copyright 1993 by Fujitsu Open Systems Solutions, Inc. + +Permission to use, copy, modify, distribute and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of FUJITSU LIMITED and +Fujitsu Open Systems Solutions, Inc. not be used in advertising or +publicity pertaining to distribution of the software without specific, +written prior permission. +FUJITSU LIMITED and Fujitsu Open Systems Solutions, Inc. makes no +representations about the suitability of this software for any purpose. +It is provided "as is" without express or implied warranty. + +FUJITSU LIMITED AND FUJITSU OPEN SYSTEMS SOLUTIONS, INC. DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL FUJITSU OPEN SYSTEMS +SOLUTIONS, INC. AND FUJITSU LIMITED BE LIABLE FOR ANY SPECIAL, INDIRECT +OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1993, 1994 by Sony Corporation + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Sony Corporation +not be used in advertising or publicity pertaining to distribution +of the software without specific, written prior permission. +Sony Corporation makes no representations about the suitability of +this software for any purpose. It is provided "as is" without +express or implied warranty. + +SONY CORPORATION DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL SONY CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1986, 1998 The Open Group +Copyright (c) 2000 The XFree86 Project, Inc. + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +X CONSORTIUM OR THE XFREE86 PROJECT BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Except as contained in this notice, the name of the X Consortium or of the +XFree86 Project shall not be used in advertising or otherwise to promote the +sale, use or other dealings in this Software without prior written +authorization from the X Consortium and the XFree86 Project. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation, NTT Software Corporation, + and Nippon Telegraph and Telephone Corporation +Copyright 1991 by the Open Software Foundation +Copyright 1993 by the FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of OMRON, NTT Software, NTT, and +Open Software Foundation not be used in advertising or publicity +pertaining to distribution of the software without specific, +written prior permission. OMRON, NTT Software, NTT, and Open Software +Foundation make no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OMRON, NTT SOFTWARE, NTT, AND OPEN SOFTWARE FOUNDATION +DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT +SHALL OMRON, NTT SOFTWARE, NTT, OR OPEN SOFTWARE FOUNDATION BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1988 by Wyse Technology, Inc., San Jose, Ca, +Copyright 1987 by Digital Equipment Corporation, Maynard, Massachusetts, + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name Digital not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +DIGITAL AND WYSE DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL DIGITAL OR WYSE BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + + +Copyright 1991, 1992 by Fuji Xerox Co., Ltd. +Copyright 1992, 1993, 1994 by FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Fuji Xerox, +FUJITSU LIMITED not be used in advertising or publicity pertaining +to distribution of the software without specific, written prior +permission. Fuji Xerox, FUJITSU LIMITED make no representations +about the suitability of this software for any purpose. +It is provided "as is" without express or implied warranty. + +FUJI XEROX, FUJITSU LIMITED DISCLAIM ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL FUJI XEROX, +FUJITSU LIMITED BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA +OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 2006 Josh Triplett + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + ---------------------------------------- + +(c) Copyright 1996 by Sebastien Marineau and Holger Veit + + + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +HOLGER VEIT BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Except as contained in this notice, the name of Sebastien Marineau or Holger Veit +shall not be used in advertising or otherwise to promote the sale, use or other +dealings in this Software without prior written authorization from Holger Veit or +Sebastien Marineau. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation, NTT Software Corporation, + and Nippon Telegraph and Telephone Corporation +Copyright 1991 by the Open Software Foundation +Copyright 1993 by the TOSHIBA Corp. +Copyright 1993, 1994 by Sony Corporation +Copyright 1993, 1994 by the FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of OMRON, NTT Software, NTT, Open +Software Foundation, and Sony Corporation not be used in advertising +or publicity pertaining to distribution of the software without specific, +written prior permission. OMRON, NTT Software, NTT, Open Software +Foundation, and Sony Corporation make no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +OMRON, NTT SOFTWARE, NTT, OPEN SOFTWARE FOUNDATION, AND SONY +CORPORATION DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT +SHALL OMRON, NTT SOFTWARE, NTT, OPEN SOFTWARE FOUNDATION, OR SONY +CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER +IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 2000 by Bruno Haible + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Bruno Haible not +be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. Bruno Haible +makes no representations about the suitability of this software for +any purpose. It is provided "as is" without express or implied +warranty. + +Bruno Haible DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN +NO EVENT SHALL Bruno Haible BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE +OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright © 2003 Keith Packard + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of Keith Packard not be used in +advertising or publicity pertaining to distribution of the software without +specific, written prior permission. Keith Packard makes no +representations about the suitability of this software for any purpose. It +is provided "as is" without express or implied warranty. + +KEITH PACKARD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL KEITH PACKARD BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright (c) 2007-2009, Troy D. Hanson +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ---------------------------------------- + +Copyright 1992, 1993 by TOSHIBA Corp. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, provided +that the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of TOSHIBA not be used in advertising +or publicity pertaining to distribution of the software without specific, +written prior permission. TOSHIBA make no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +TOSHIBA DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +TOSHIBA BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + + ---------------------------------------- + +Copyright IBM Corporation 1993 + +All Rights Reserved + +License to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of IBM not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +IBM DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS, AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS, IN NO EVENT SHALL +IBM BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation, NTT Software Corporation, + and Nippon Telegraph and Telephone Corporation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of OMRON, NTT Software, and NTT +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. OMRON, NTT Software, +and NTT make no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OMRON, NTT SOFTWARE, AND NTT, DISCLAIM ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS, IN NO EVENT SHALL OMRON, NTT SOFTWARE, OR NTT, BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt new file mode 100644 index 000000000..64492ad80 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt @@ -0,0 +1,21 @@ +Copyright 1988, 1993, 1994, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt new file mode 100644 index 000000000..52c8707dc --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt @@ -0,0 +1,29 @@ +Copyright (c) 1992, 1993 + The Regents of the University of California. All rights reserved. + +This code is derived from software contributed to Berkeley by +Christos Zoulas of Cornell University. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt new file mode 100644 index 000000000..acb2f7a07 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt @@ -0,0 +1,21 @@ +libffi - Copyright (c) 1996-2019 Anthony Green, Red Hat, Inc and others. +See source files for details. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +``Software''), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt new file mode 100644 index 000000000..2d7885199 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt @@ -0,0 +1,13 @@ +Copyright (C) The XZ Utils authors and contributors + +Permission to use, copy, modify, and/or distribute this +software for any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL +THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt new file mode 100644 index 000000000..ec87a77f1 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt @@ -0,0 +1,27 @@ +Copyright (C) 1996, 1997 Theodore Ts'o. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, and the entire permission notice in its entirety, + including the disclaimer of warranties. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ALL OF +WHICH ARE HEREBY DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +USE OF THIS SOFTWARE, EVEN IF NOT ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt new file mode 100644 index 000000000..54bfbe5b0 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt @@ -0,0 +1,30 @@ +Copyright (C) 2001-2006 Bart Massey, Jamey Sharp, and Josh Triplett. +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the names of the authors +or their institutions shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this +Software without prior written authorization from the +authors. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt new file mode 100644 index 000000000..c7688a928 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt @@ -0,0 +1,24 @@ +Copyright (c) 2008-2020 Stefan Krah. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt new file mode 100644 index 000000000..3a2297536 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt @@ -0,0 +1,29 @@ +Copyright 2018-2020,2021 Thomas E. Dickey +Copyright 1998-2017,2018 Free Software Foundation, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, distribute with modifications, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE ABOVE COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name(s) of the above copyright +holders shall not be used in advertising or otherwise to promote the +sale, use or other dealings in this Software without prior written +authorization. + +-- vile:txtmode fc=72 +-- $Id: COPYING,v 1.10 2021/01/01 09:54:30 tom Exp $ diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt new file mode 100644 index 000000000..5b5ccdc96 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt @@ -0,0 +1,124 @@ + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a double license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt new file mode 100644 index 000000000..49cc83d2e --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt new file mode 100644 index 000000000..68b36ebd1 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt @@ -0,0 +1,23 @@ +All of the code and documentation in SQLite has been dedicated to the public +domain by the authors. All code authors, and representatives of the companies +they work for, have signed affidavits dedicating their contributions to the +public domain and originals of those signed affidavits are stored in a firesafe +at the main offices of Hwaci. Anyone is free to copy, modify, publish, use, +compile, sell, or distribute the original SQLite code, either in source code form +or as a compiled binary, for any purpose, commercial or non-commercial, and by +any means. + +The previous paragraph applies to the deliverable code and documentation in +SQLite - those parts of the SQLite library that you actually bundle and ship +with a larger application. Some scripts used as part of the build process (for +example the "configure" scripts generated by autoconf) might fall under other +open-source licenses. Nothing from these build scripts ever reaches the final +deliverable SQLite library, however, and so the licenses associated with those +scripts should not be a factor in assessing your rights to copy and use the +SQLite library. + +All of the deliverable code in SQLite has been written from scratch. No code has +been taken from other projects or from the open internet. Every line of code can +be traced back to its original author, and all of those authors have public +domain dedications on file. So the SQLite code base is clean and is +uncontaminated with licensed code from other projects. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt new file mode 100644 index 000000000..d8049cd9e --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt @@ -0,0 +1,40 @@ +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState +Corporation and other parties. The following terms apply to all files +associated with the software unless explicitly disclaimed in +individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt new file mode 100644 index 000000000..5323a3fc3 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt @@ -0,0 +1,54 @@ +Copyright (c) 1993-1999 Ioi Kim Lam. +Copyright (c) 2000-2001 Tix Project Group. +Copyright (c) 2004 ActiveState + +This software is copyrighted by the above entities +and other parties. The following terms apply to all files associated +with the software unless explicitly disclaimed in individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +---------------------------------------------------------------------- + +Parts of this software are based on the Tcl/Tk software copyrighted by +the Regents of the University of California, Sun Microsystems, Inc., +and other parties. The original license terms of the Tcl/Tk software +distribution is included in the file docs/license.tcltk. + +Parts of this software are based on the HTML Library software +copyrighted by Sun Microsystems, Inc. The original license terms of +the HTML Library software distribution is included in the file +docs/license.html_lib. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt new file mode 100644 index 000000000..5eb28a147 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt @@ -0,0 +1,21 @@ + + Copyright (C) 1995-2017 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/apps/maple-agent/scripts/python-licenses/PROVENANCE.json b/apps/maple-agent/scripts/python-licenses/PROVENANCE.json new file mode 100644 index 000000000..437a01f0e --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/PROVENANCE.json @@ -0,0 +1,87 @@ +{ + "release": "20260901", + "commit": "4bb01f09aaf362c71e891be4a41cb6d6ddf830b3", + "note": "Upstream individual license notice set; applicability varies by target and included components. Original notices in the installation are preserved as well.", + "files": { + "LICENSE": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE", + "sha256": "1f256ecad192880510e84ad60474eab7589218784b9a50bc7ceee34c2b91f1d5" + }, + "LICENSE.bdb.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.bdb.txt", + "sha256": "3bdff06e69991c94664f2ef5c5f8096f60b7dbec071756ea6cc26b445e06ec5b" + }, + "LICENSE.bzip2.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.bzip2.txt", + "sha256": "1f38bbc7caacafd65169276d759c0d88c991b753b643ce35d0e45ea1971dd441" + }, + "LICENSE.cpython.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.cpython.txt", + "sha256": "86e61415828a8b5b06ec8d024e6f086ce155a8b85fd0c419c0ba4dc004e74fdd" + }, + "LICENSE.expat.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.expat.txt", + "sha256": "122f2c27000472a201d337b9b31f7eb2b52d091b02857061a8880371612d9534" + }, + "LICENSE.libX11.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libX11.txt", + "sha256": "2daec087a88e7c9b8082557cdeebad5bbb8155a4137472f0b22e269cd99d0c1e" + }, + "LICENSE.libXau.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libXau.txt", + "sha256": "56abe29bb1d9806a9e04fa9f80fed2c0f18027594df3f098148d814aef6bddfa" + }, + "LICENSE.libedit.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libedit.txt", + "sha256": "29cea33c32bbc9785142386377915612a2fa786482c46843383384aded2e09b1" + }, + "LICENSE.libffi.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libffi.txt", + "sha256": "deaf3a42effb551a5b140fa9afefed183a27f1341c6d1bf430d106a5e6931fc0" + }, + "LICENSE.liblzma.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.liblzma.txt", + "sha256": "9a4062de0a2c388a98cf35a35d348b62fa97c838a71c3c28ee1a2d7d0a565b02" + }, + "LICENSE.libuuid.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libuuid.txt", + "sha256": "122ee1f7e258f2c3c0e538a75c037684f420454bf3850ddc74ce750bbf5fe86b" + }, + "LICENSE.libxcb.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libxcb.txt", + "sha256": "c5ffbfeaa501071ceeb97b7de2c0d703fdaa35de01c0fb6cbac1c28453a3e9fd" + }, + "LICENSE.mpdecimal.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.mpdecimal.txt", + "sha256": "669512af7219f58be03a398766d7c9da11a3b3df9d3f05cb74c5ceca25c8da3b" + }, + "LICENSE.ncurses.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.ncurses.txt", + "sha256": "87a4c4442337b8968ef956031c406b74f9cb7149b7ba87311bdaba534816201c" + }, + "LICENSE.openssl-1.1.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.openssl-1.1.txt", + "sha256": "9c04cce50c4989d5601dd8b07f6ab922c40388b66ac736c9007cb1ed9d9dd560" + }, + "LICENSE.openssl-3.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.openssl-3.txt", + "sha256": "7d5450cb2d142651b8afa315b5f238efc805dad827d91ba367d8516bc9d49e7a" + }, + "LICENSE.sqlite.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.sqlite.txt", + "sha256": "38bef3d28b24f145ea293bd3b6eb4b20396982abc8303128fb493986ea5bc719" + }, + "LICENSE.tcl.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.tcl.txt", + "sha256": "c0a69a2bfd757361ec7e6143973b103c90409316b49e9c88db26ad6388e79f16" + }, + "LICENSE.tix.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.tix.txt", + "sha256": "3ac5cdd0bef6c43ce34c6a7ced452081d9e5a0bf94082b9f9147d23ec9e214f5" + }, + "LICENSE.zlib.txt": { + "url": "https://raw.githubusercontent.com/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.zlib.txt", + "sha256": "818922b2620f12801a12bf78e399644a30990e66824abd8ca8ec24d451d6f92c" + } + } +} diff --git a/apps/maple-agent/scripts/python-runtime.json b/apps/maple-agent/scripts/python-runtime.json new file mode 100644 index 000000000..b7fd50cbd --- /dev/null +++ b/apps/maple-agent/scripts/python-runtime.json @@ -0,0 +1,27 @@ +{ + "implementation": "cpython", + "version": "3.13.15", + "release": "20260901", + "targets": { + "aarch64-apple-darwin": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-aarch64-apple-darwin-install_only_stripped.tar.gz", + "sha256": "d3904bd6a072246e07aa0bdadee9a14e80521e42a943c0848059feb16a2816dc", + "executable": "bin/python3.13" + }, + "x86_64-apple-darwin": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-apple-darwin-install_only_stripped.tar.gz", + "sha256": "f712a9143c8a5d248438ec7921a0b48d548bca4f1337d33c690d28c2d0504137", + "executable": "bin/python3.13" + }, + "x86_64-unknown-linux-gnu": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", + "sha256": "8a689a077337bea6d1c4bc0b7df1d52fcaa28f5f67e50df8bf417c1e3f9d8874", + "executable": "bin/python3.13" + }, + "x86_64-pc-windows-msvc": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-pc-windows-msvc-install_only_stripped.tar.gz", + "sha256": "63d263ab0162f34a241a56dc5b283c22d6e131f5516117e6a921350c69ba7d4f", + "executable": "python.exe" + } + } +} diff --git a/apps/maple-agent/scripts/test-python-worker.py b/apps/maple-agent/scripts/test-python-worker.py new file mode 100644 index 000000000..ab1f37b47 --- /dev/null +++ b/apps/maple-agent/scripts/test-python-worker.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Run focused worker tests with the explicitly prepared package interpreter. + +This command only consumes resources. Run just python-prepare before invoking it. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + + +REPO = Path(__file__).resolve().parent.parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, default=Path(os.environ.get( + "MAPLE_CODE_MODE_RUNTIME_MANIFEST", REPO / "target/debug/runtime/python/runtime.json" + ))) + args = parser.parse_args() + try: + manifest_path = args.manifest.resolve(strict=True) + manifest = json.loads(manifest_path.read_text()) + if manifest["implementation"] != "cpython" or manifest["version"] != "3.13.15": + raise ValueError("Tests require the declared CPython 3.13.15 fixture") + interpreter = (manifest_path.parent / manifest["executable"]).resolve(strict=True) + worker = (manifest_path.parent / manifest["worker"]).resolve(strict=True) + source = REPO / "crates/maple-code-mode/python/worker.py" + if hashlib.sha256(worker.read_bytes()).digest() != hashlib.sha256(source.read_bytes()).digest(): + raise ValueError("Prepared worker differs from source; run just python-prepare") + subprocess.run([ + str(interpreter), "-I", "-B", "-u", + str(source.with_name("test_worker.py")), "--python", str(interpreter), + ], check=True) + except (OSError, ValueError, KeyError) as error: + parser.exit(1, f"Python worker tests require valid prepared resources: {error}. Run just python-prepare.\n") + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/tests/test_package_archive.py b/apps/maple-agent/scripts/tests/test_package_archive.py new file mode 100644 index 000000000..8e6d252a5 --- /dev/null +++ b/apps/maple-agent/scripts/tests/test_package_archive.py @@ -0,0 +1,46 @@ +import hashlib +import importlib.util +import json +from pathlib import Path +import tarfile +import tempfile +import unittest +import zipfile + + +SCRIPT = Path(__file__).resolve().parents[1] / "package-archive.py" +spec = importlib.util.spec_from_file_location("package_archive", SCRIPT) +archive = importlib.util.module_from_spec(spec) +spec.loader.exec_module(archive) + + +class PackageTests(unittest.TestCase): + def test_archive_includes_binary_runtime_worker_licenses_and_checksum(self): + for windows in (False, True): + with self.subTest(windows=windows), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + binary = root / ("maple-gpui.exe" if windows else "maple-gpui") + binary.write_bytes(b"maple executable") + runtime = root / "python" + runtime.mkdir() + executable = "python.exe" if windows else "bin/python3.13" + (runtime / executable).parent.mkdir(exist_ok=True) + (runtime / executable).write_bytes(b"python executable") + (runtime / "worker.py").write_text("worker") + (runtime / "LICENSE.txt").write_text("python license") + (runtime / "runtime.json").write_text(json.dumps({"distribution": "pbs-fixture", "executable": executable, "worker": "worker.py"})) + result = archive.package(binary, runtime, "Maple test 日本語", root / "dist") + if windows: + with zipfile.ZipFile(result) as package: + names = package.namelist() + else: + with tarfile.open(result) as package: + names = package.getnames() + for expected in [binary.name, "LICENSE", "runtime/python/runtime.json", "runtime/python/worker.py", "runtime/python/LICENSE.txt", f"runtime/python/{executable}"]: + self.assertIn(f"Maple test 日本語/{expected}", names) + checksum = hashlib.sha256(result.read_bytes()).hexdigest() + self.assertEqual(result.with_name(result.name + ".sha256").read_text(), f"{checksum} {result.name}\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/maple-agent/scripts/tests/test_prepare_python.py b/apps/maple-agent/scripts/tests/test_prepare_python.py new file mode 100644 index 000000000..eaf659e4b --- /dev/null +++ b/apps/maple-agent/scripts/tests/test_prepare_python.py @@ -0,0 +1,114 @@ +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).resolve().parents[1] / "prepare-python.py" +spec = importlib.util.spec_from_file_location("prepare_python", SCRIPT) +prepare = importlib.util.module_from_spec(spec) +spec.loader.exec_module(prepare) + + +class PreparationTests(unittest.TestCase): + def archive(self, root, entries): + path = root / "fixture.tar.gz" + with tarfile.open(path, "w:gz") as archive: + for name, value, link in entries: + member = tarfile.TarInfo(name) + if link: + member.type = tarfile.SYMTYPE + member.linkname = value + archive.addfile(member) + else: + value = value.encode() + member.size = len(value) + archive.addfile(member, io.BytesIO(value)) + return path + + def test_archive_rejects_traversal_and_external_links(self): + for entry in [ + ("python/../../escape", "bad", False), + ("/absolute", "bad", False), + ("python/bin/link", "../../../escape", True), + ("python/bin/link", "/absolute", True), + ("python/C:escape", "bad", False), + ]: + with self.subTest(entry=entry), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = self.archive(root, [entry]) + with self.assertRaises(ValueError): + prepare.safe_extract(archive, root / "stage") + + def test_archive_preserves_internal_link_and_strips_one_root(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = self.archive(root, [ + ("python/bin/python3.13", "binary", False), + ("python/bin/python3", "python3.13", True), + ]) + staged = prepare.safe_extract(archive, root / "stage") + self.assertEqual((staged / "bin/python3").read_text(), "binary") + self.assertFalse((staged / "python").exists()) + + def test_digest_mismatch_never_extracts_or_replaces_good_staging(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + cache = root / "cache" + asset = {"sha256": "0" * 64, "url": "https://example.invalid/archive"} + with patch.object(prepare.urllib.request, "urlopen", return_value=io.BytesIO(b"wrong bytes")): + with self.assertRaisesRegex(ValueError, "SHA-256"): + prepare.download(asset, cache, False) + self.assertEqual(list(cache.iterdir()), []) + + def test_verified_cache_and_stage_reused_corruption_repaired_offline(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = self.archive(root, [ + ("python/bin/python3.13", "binary", False), + ("python/LICENSE.txt", "license", False), + ("python/lib/module.py", "module", False), + ]) + archive_hash = hashlib.sha256(archive.read_bytes()).hexdigest() + cache = root / "cache" + cache.mkdir() + archive.rename(cache / f"{archive_hash}.tar.gz") + worker = root / "worker.py" + worker.write_text("worker") + pins = root / "pins.json" + pins.write_text(json.dumps({"implementation": "cpython", "version": "3.13.15", "release": "20260901", "targets": {"test": {"sha256": archive_hash, "executable": "bin/python3.13", "url": "unused"}}})) + destination = root / "runtime" + with patch.object(prepare, "PINS", pins), patch.object(prepare.urllib.request, "urlopen", side_effect=AssertionError("network forbidden")): + prepare.prepare_pbs("test", destination, cache, worker, True) + stamp = (destination / "runtime.json").stat().st_mtime_ns + prepare.prepare_pbs("test", destination, cache, worker, True) + self.assertEqual((destination / "runtime.json").stat().st_mtime_ns, stamp) + (destination / "lib/module.py").write_text("corrupt") + prepare.prepare_pbs("test", destination, cache, worker, True) + self.assertEqual((destination / "lib/module.py").read_text(), "module") + worker.write_text("updated worker") + prepare.prepare_pbs("test", destination, cache, worker, True) + self.assertEqual((destination / "worker.py").read_text(), "updated worker") + + def test_failed_publication_restores_previous_tree(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + destination = root / "runtime" + destination.mkdir() + (destination / "valid").write_text("retained") + with self.assertRaises(FileNotFoundError): + prepare.replace_directory(root / "missing", destination) + self.assertEqual((destination / "valid").read_text(), "retained") + + def test_nix_requires_explicit_manifest(self): + with self.assertRaisesRegex(ValueError, "explicit"): + prepare.verify_nix(None) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_agent_change_detection.py b/scripts/ci/test_agent_change_detection.py index 51f306ded..4c5d71790 100644 --- a/scripts/ci/test_agent_change_detection.py +++ b/scripts/ci/test_agent_change_detection.py @@ -22,6 +22,12 @@ def test_agent_runtime_build_and_asset_inputs_select_only_agent(self): "apps/maple-agent/rust-toolchain.toml", "apps/maple-agent/justfile", "apps/maple-agent/scripts/macos-debug-app.sh", + "apps/maple-agent/crates/maple-code-mode/python/worker.py", + "apps/maple-agent/scripts/python-runtime.json", + "apps/maple-agent/scripts/python-licenses/LICENSE", + "apps/maple-agent/scripts/prepare-python.py", + "apps/maple-agent/scripts/package-archive.py", + "apps/maple-agent/.gitattributes", "apps/maple-agent/new-build-input", ): with self.subTest(path=path): diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 82ab1085e..ee44d79d6 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -75,12 +75,46 @@ def test_agent_cache_and_artifacts_do_not_share_research_publication(self): self.assertTrue(step["with"]["path"].startswith("apps/maple-agent/target/")) self.assertFalse(any((ROOT / "apps/maple-agent/.github/workflows").glob("*.yml"))) - def test_failed_or_missing_selection_cannot_skip_the_desktop_matrix(self): - condition = workflow("agent-ci.yml")["jobs"]["desktop"]["if"] - self.assertIn("always() && !cancelled()", condition) - self.assertIn("needs.changes.result != 'success'", condition) - self.assertIn("needs.changes.outputs.agent != 'false'", condition) - self.assertNotIn("head.repo", condition) + def test_failed_or_missing_selection_cannot_skip_agent_validation(self): + jobs = workflow("agent-ci.yml")["jobs"] + for name in ("desktop", "nix-python"): + with self.subTest(job=name): + self.assertEqual(jobs[name]["needs"], "changes") + condition = jobs[name]["if"] + self.assertIn("always() && !cancelled()", condition) + self.assertIn("needs.changes.result != 'success'", condition) + self.assertIn("needs.changes.outputs.agent != 'false'", condition) + self.assertNotIn("head.repo", condition) + + def test_python_packaging_retains_portable_and_nix_validation(self): + config = workflow("agent-ci.yml") + self.assertEqual(config["defaults"]["run"]["working-directory"], "apps/maple-agent") + desktop = config["jobs"]["desktop"] + self.assertIn({"os": "macos-15-intel", "focused": True}, + desktop["strategy"]["matrix"]["include"]) + steps = desktop["steps"] + commands = "\n".join(step.get("run", "") for step in steps) + for command in ("just ci", "just python-test", "scripts/test-python-worker.py", + "-m unittest discover -s scripts/tests", + "cargo test -p maple-code-mode --locked", + "scripts/macos-debug-app.sh", "just code-mode-smoke", + "scripts/check-python-package.py --smoke target/debug/code-mode-smoke.exe", + "scripts/check-python-package.py --smoke target/debug/code-mode-smoke"): + self.assertIn(command, commands) + archive = next(step for step in steps if step.get("name") == "Stage complete Linux CI archive") + self.assertIn("scripts/prepare-python.py", archive["run"]) + self.assertIn("--runtime target/release/runtime/python", archive["run"]) + self.assertIn("--output-dir target/ci-dist", archive["run"]) + upload = next(step for step in steps if "upload-artifact@" in step.get("uses", "")) + self.assertEqual(upload["with"]["path"], "apps/maple-agent/target/ci-dist/*") + nix = config["jobs"]["nix-python"] + self.assertEqual(nix["runs-on"], "ubuntu-24.04-arm") + commands = "\n".join(step.get("run", "") for step in nix["steps"]) + for command in ("nix build --no-update-lock-file .#default", + "export MAPLE_CODE_MODE_RUNTIME_MANIFEST=", "just python-test", + "cargo test -p maple-code-mode --locked", "just code-mode-smoke", + "nix path-info --recursive ./result", "interpreter.parents[1]"): + self.assertIn(command, commands) def test_namespaced_agent_releases_do_not_enter_research_jobs(self): release = workflow("release.yml")