diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index f297487ca..c892ef853 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -36,6 +36,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. CI conservatively selects Agent for shared Rust SDK/proxy runtime changes even when its SDK is registry-pinned; passing that build does not validate unpublished SDK source. Component-only 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 8670cd13f..8b30cf6d5 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -3870,7 +3870,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", @@ -3966,7 +3966,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", @@ -3975,7 +3975,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", @@ -3990,7 +3990,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", @@ -4006,7 +4006,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", @@ -4019,7 +4019,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", @@ -4045,7 +4045,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", @@ -4069,7 +4069,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", @@ -5821,6 +5821,7 @@ dependencies = [ "icu_properties", "libc", "log", + "maple-code-mode", "maple-proxy", "maple-sdk", "once_cell", @@ -5849,6 +5850,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" diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index 9b3a7d882..6666237a2 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", "app"] [workspace.package] edition = "2024" diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 39bfa8c0e..b953c761b 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 fc69e3426..b0bf240b1 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 0851116ab..d03ef847f 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; @@ -122,9 +124,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", @@ -161,6 +164,7 @@ const MAPLE_GOOSE_PERMISSION_CONFIG: &str = r#"user: - delegate - read - shell + - python_code - edit - write - read_image @@ -368,6 +372,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)] @@ -630,11 +656,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 { @@ -798,6 +1003,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>>, @@ -891,6 +1098,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()), @@ -2020,7 +2228,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; @@ -2249,7 +2457,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 @@ -2544,6 +2759,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 @@ -2905,6 +3132,7 @@ impl AgentRuntimeHandle { installation_id, context: tool_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }); None } @@ -3004,6 +3232,7 @@ impl AgentRuntimeHandle { mode: &mode, primary_model_supports_vision: false, tool_context: &tool_context, + python_binding: None, allow_embedded_cua: !has_external_tool_context, }, ) @@ -3285,9 +3514,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()); @@ -3381,6 +3611,7 @@ impl AgentRuntimeHandle { mode: &mode, primary_model_supports_vision: false, tool_context: &tool_context, + python_binding: None, allow_embedded_cua: false, }, ) @@ -3875,6 +4106,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( @@ -3910,6 +4245,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)); } @@ -4241,6 +4588,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), @@ -4302,7 +4653,7 @@ impl AgentRuntimeHandle { } }; if let Some(installed) = removed_tool_context { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } Ok(()) @@ -5001,6 +5352,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 @@ -5068,6 +5427,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 { @@ -5084,6 +5446,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, }, ) @@ -7823,15 +8186,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(), @@ -7966,6 +8324,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. @@ -8319,6 +8678,7 @@ async fn finish_session_agent( mode, primary_model_supports_vision, tool_context, + python_binding, allow_embedded_cua, } = configuration; let PreparedSessionAgent { @@ -8356,7 +8716,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, @@ -8367,6 +8727,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( @@ -10696,6 +11059,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}")) @@ -14130,12 +14837,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 { @@ -16344,6 +17047,7 @@ mod tests { installation_id: 2, context: replacement.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, )]); @@ -16375,6 +17079,7 @@ mod tests { installation_id: 7, context: leased.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, )]); let access = AgentToolContextAccess { @@ -16524,6 +17229,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let lease = AgentToolContextLease { @@ -16620,6 +17326,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let mut pending = PendingAgentToolContextInstallation::new(context.clone()); @@ -16728,6 +17435,7 @@ mod tests { installation_id: untouched_installation_id, context: untouched_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); contexts.insert( @@ -16736,6 +17444,7 @@ mod tests { installation_id: modified_installation_id, context: modified_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); } @@ -16851,6 +17560,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..b17027e8b --- /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. The task root is available for explicit project imports. Never install into the shared runtime or app bundle. When pip is available (portable bundle; not guaranteed in Nix), invoke sys.executable with -I -B -m pip --isolated install --only-binary=:all: --target /absolute/external/dependency/directory, then explicitly add that directory to sys.path. Keep -B to avoid writing import caches into the runtime. Use a separate environment if pip or a compatible wheel is unavailable. Creating or activating a venv in a shell does not retarget this retained worker. Spawned process pools require functions from importable modules; functions defined only in cells can fail or hang a pool. 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. Synchronous code can continue producing external effects during the retirement grace period; 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 6d0aebdbc..6b3447894 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..f8440e644 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/python/test_worker.py @@ -0,0 +1,492 @@ +"""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 +from unittest import mock + + +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("def fail():\n raise ValueError('example')\nretained = 17\nfail()") + 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.assertIn('line 4, in ', error) + self.assertIn('line 2, in fail', error) + self.assertNotIn(str(WORKER), error) + self.assertEqual(result(self.worker.execute("retained")), "17") + syntax = self.worker.execute("def broken(") + error = next(item["traceback"] for item in syntax if item["type"] == "error") + self.assertIn('File "", line 1', error) + self.assertIn(" def broken(\n ^\n", error) + self.assertIn("SyntaxError: '(' was never closed\n", error) + self.assertNotIn(str(WORKER), error) + + def test_file_errors_report_both_paths_in_the_message(self): + missing = str(Path(self.worker.root, "missing-ø.xlsx")) + destination = str(Path(self.worker.root, "result.xlsx")) + self.worker.execute(f"missing = {missing!r}\ndestination = {destination!r}") + for code in ("open(missing)", "import os\nos.rename(missing, destination)"): + messages = self.worker.execute(code) + error = next(item["traceback"] for item in messages if item["type"] == "error") + message = error.splitlines()[-1] + self.assertIn("FileNotFoundError: [Errno 2]", message) + self.assertIn(repr(missing), message) + if "rename" in code: + self.assertIn(" -> " + repr(destination), message) + + def test_tracebacks_retain_dependency_frames(self): + messages = self.worker.execute("import json\njson.loads('not-json')") + error = next(item["traceback"] for item in messages if item["type"] == "error") + self.assertIn("", error) + self.assertIn("decoder.py", error) + self.assertIn("JSONDecodeError", error) + self.assertNotIn(str(WORKER), 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_output_coalescing_preserves_attribution_controls_and_frame_limit(self): + with mock.patch.object(threading.Thread, "start"): + transport = self.module._Transport(-1, 7, lambda: self.fail("broken transport")) + transport.output("stdout", "first", 1) + transport.output("stdout", " second", 1) + transport.output("stderr", "error", 1) + transport.output("stdout", "next cell", 2) + transport.control({"type": "done", "execution_id": 2}) + transport.output("stdout", "late", 2) + transport.output("stdout", "ø" * (self.module.MAX_OUTPUT_BYTES // 2), 2) + messages = [entry[0] for entry in transport.queue] + self.assertEqual([message["type"] for message in messages], + ["output", "output", "output", "done", "output", "output"]) + self.assertEqual([message.get("text") for message in messages[:5]], + ["first second", "error", "next cell", None, "late"]) + self.assertEqual([message.get("execution_id") for message in messages], + [1, 1, 2, 2, 2, 2]) + self.assertTrue(all(len(message.get("text", "").encode("utf-8")) <= + self.module.MAX_OUTPUT_BYTES for message in messages)) + + def test_coalesced_output_still_bounds_queue_and_reserves_controls(self): + with mock.patch.object(threading.Thread, "start"): + transport = self.module._Transport(-1, 7, lambda: self.fail("broken transport")) + for _ in range(40000): + transport.output("stdout", "12345678", 1) + self.assertLessEqual(transport.output_bytes, self.module.MAX_OUTPUT_QUEUE_BYTES) + retained = sum(len(entry[0]["text"]) for entry in transport.queue) + self.assertEqual(retained + transport.counts()["stdout"], 320000) + self.assertGreater(transport.counts()["stdout"], 0) + self.assertTrue(transport.control({"type": "done", "execution_id": 1})) + self.assertEqual(transport.queue[-1][0]["type"], "done") + + def test_exception_fields_are_bounded_without_custom_str(self): + class FileError(OSError): + def __str__(self): + raise AssertionError("exception __str__ must not run") + + error = FileError(2, "missing", "source.xlsx", None, "result.xlsx") + formatted = self.module._bounded_traceback(error) + self.assertIn("FileError: [Errno 2] missing: 'source.xlsx' -> 'result.xlsx'", formatted) + for error in ( + FileError(2, "missing", "ø" * 100000), + FileError(2, "ø" * 100000, "source.xlsx"), + SyntaxError("ø" * 100000, ("source.py", 1, 100000, "ø" * 100000)), + ): + formatted = self.module._bounded_traceback(error) + self.assertLessEqual(len(formatted.encode("utf-8")), self.module.MAX_VALUE_BYTES) + self.assertIn("...", formatted) + + def test_traceback_frame_and_chain_limits_remain_bounded(self): + previous = None + for index in range(6): + error = ValueError(f"cause-{index}") + error.__cause__ = previous + previous = error + formatted = self.module._bounded_traceback(error) + self.assertNotIn("cause-0", formatted) + self.assertNotIn("cause-1", formatted) + self.assertEqual(formatted.count("ValueError: cause-"), 4) + + def recurse(depth): + if depth: + return recurse(depth - 1) + raise ValueError("deep") + + try: + recurse(40) + except ValueError as error: + formatted = self.module._bounded_traceback(error) + self.assertEqual(formatted.count(' File "'), 32) + self.assertIn("additional frames omitted", formatted) + + 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..7fcd6b315 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/python/worker.py @@ -0,0 +1,934 @@ +"""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") + tb = item.__traceback__ + frames = 0 + shown_frames = 0 + while tb is not None and frames < 32 and out.remaining: + code = tb.tb_frame.f_code + frames += 1 + # Omit the worker's leading execution wrappers, but retain frames + # from the cell and its dependencies. Count skipped frames too. + if not shown_frames and code.co_filename == __file__: + tb = tb.tb_next + continue + if not shown_frames: + out.add("Traceback (most recent call last):\n") + shown_frames += 1 + 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 + if tb is not None: + out.add(" ... additional frames omitted ...\n") + if isinstance(item, SyntaxError): + filename = item.filename if type(item.filename) is str else "" + lineno = item.lineno + out.add(' File "' + filename[:512] + '", line ') + out.add(str(lineno) if type(lineno) is int and 0 < lineno <= MAX_U64 else "?") + out.add("\n") + if type(item.text) is str: + source = item.text[:512].rstrip("\r\n") + displayed = source.lstrip(" \t") + out.add(" " + displayed.expandtabs(4) + "\n") + offset = item.offset + if type(offset) is int and 0 < offset <= len(source) + 1: + column = offset - 1 - (len(source) - len(displayed)) + if 0 <= column <= len(displayed): + out.add(" " + " " * len(displayed[:column].expandtabs(4)) + "^\n") + out.add(type(item).__name__[:256] + ": ") + arguments = item.args + # Builtin exception __str__ implementations may eagerly format huge + # arguments. Read their useful fields within our cumulative budget + # instead; custom exception __str__ methods are never invoked here. + if ( + isinstance(item, OSError) + and type(item.errno) is int + and -MAX_U64 <= item.errno <= MAX_U64 + and type(item.strerror) is str + ): + out.add("[Errno " + str(item.errno) + "] ") + out.add(item.strerror) + for prefix, filename in ((": ", item.filename), (" -> ", item.filename2)): + if not out.remaining: + break + if filename is not None: + out.add(prefix) + out.add(_bounded_repr(filename, max(1, out.remaining))) + elif isinstance(item, SyntaxError) and type(item.msg) is str: + out.add(item.msg) + elif 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: + # Combine only adjacent queued writes with identical attribution. + # This keeps print loops from exhausting metadata before the byte + # budget, without crossing controls or growing a wire frame. + if not self.failed and self.queue: + previous, previous_cost, delivered = self.queue[-1] + if ( + previous["type"] == "output" + and previous["stream"] == stream + and previous["execution_id"] == execution_id + and previous_cost - 256 + size <= MAX_OUTPUT_BYTES + and self.output_bytes + size <= MAX_OUTPUT_QUEUE_BYTES + ): + previous["text"] += text + self.queue[-1] = (previous, previous_cost + size, delivered) + self.output_bytes += size + return + 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..09da48e86 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs @@ -0,0 +1,437 @@ +//! 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 tiny_output_burst_is_retained_below_the_byte_limit() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "tiny-output", root.path()); + let expected: String = (0..5_000).map(|i| format!("{i}\n")).collect(); + let output = cell(&task, "for i in range(5000):\n print(i)\n42").await; + runtime.shutdown("test complete").await.unwrap(); + assert_eq!(output.status, OutcomeStatus::Ok); + assert_eq!(output.value.as_deref(), Some("42")); + assert_eq!( + output.stdout.len() as u64 + output.dropped_stdout_bytes, + expected.len() as u64 + ); + assert_eq!(output.dropped_stderr_bytes, 0); + assert_eq!(output.dropped_stdout_bytes, 0); + assert_eq!(output.stdout, expected); +} + +#[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/docs/python-code-mode.md b/apps/maple-agent/docs/python-code-mode.md new file mode 100644 index 000000000..86ffa27b3 --- /dev/null +++ b/apps/maple-agent/docs/python-code-mode.md @@ -0,0 +1,178 @@ +# 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. The existing raw execution tools remain available for +separate environments. + +## Dependencies and process pools + +The portable Python Build Standalone bundle includes pip; the Nix runtime does +not guarantee pip. Check availability with `importlib.util.find_spec("pip")`. +Never install into the interpreter's shared directory or an app bundle: this +changes the packaged runtime and can invalidate its signature. When pip is +available, install compatible wheels into an explicit directory outside the +runtime, then add that directory to this interpreter's `sys.path`: + +```python +import importlib +import importlib.util +from pathlib import Path +import subprocess +import sys + +# Choose a fresh directory in the task project, outside the application/runtime. +deps = (Path.cwd() / ".maple-python-deps" / "excel-v1").resolve() +if importlib.util.find_spec("pip") is None: + raise RuntimeError("pip is unavailable; use a separate Python environment") +subprocess.run([ + sys.executable, "-I", "-B", "-m", "pip", "--isolated", + "--disable-pip-version-check", "install", "--no-cache-dir", + "--only-binary=:all:", "--target", str(deps), "openpyxl", +], check=True) +sys.path.insert(0, str(deps)) +importlib.invalidate_caches() +import openpyxl +``` + +Keep `-I -B` on the child interpreter: the worker's flags are not automatically +inherited by `subprocess`, and import bytecode caches can otherwise modify the +runtime even with `--target`. pip's `--isolated` ignores user configuration and +environment options. `--only-binary=:all:` fails if a compatible wheel is missing; +use a separate environment for packages that require a build. Use reviewed pinned +versions for repeatable work. Dependencies and saved files persist on disk after +Reset; imported Python objects do not. Reuse a dependency directory only for the +same interpreter version and platform. To change an already imported dependency, +install into a fresh directory and reset before importing it. Do not bootstrap +pip into the runtime if it is unavailable. + +Spawned process pools need picklable functions from importable `.py` modules. +Functions defined only in a scratchpad cell are not importable by spawned children +and can fail or hang a pool. Put the function in a module under the task root or +an explicitly added dependency directory, import it, and pass that imported +function to the pool. Alternatively run a separate script with its own environment +through the existing execution tools. Pool operations that block also pause this +worker's asyncio loop; use an async API or `await asyncio.to_thread(...)` when +background asyncio work must keep progressing. + +## 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. +Stop does not immediately interrupt synchronous Python or native calls: code can +continue producing filesystem or other effects during the retirement grace +period, before forced termination. Cleanup handlers may be skipped and buffered +output lost. Do not treat Stop as undoing writes or as an immediate effect fence. + +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. + +After preparation, the optional offline dependency check runs from the component's +Nix shell with `python3 -I -B scripts/check-python-dependencies.py --runtime +target/debug/runtime/python`. It copies the runtime, installs a synthetic wheel +outside it, exercises retained imports and importable-module process pools, and +checks that the packaged tree is unchanged. On macOS, use `--app` with an existing +signed app path instead of `--runtime` to verify its signature before and after +the same check. This does not download packages or build an app. + +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 b6ee3a536..89a375742 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.28.0" = "sha256-jiAlbvolcowWSmenp7TtnJu7rn6W6qe8bj3fgWdemN8="; - "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-dependencies.py b/apps/maple-agent/scripts/check-python-dependencies.py new file mode 100644 index 000000000..4ff689c2d --- /dev/null +++ b/apps/maple-agent/scripts/check-python-dependencies.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Check external wheel installation in a copied PBS runtime, entirely offline. + +Pass --runtime target/debug/runtime/python or --app /path/to/a/signed/Maple.app. +The app variant also verifies the copied macOS signature before and after use. +This consumes existing artifacts; it does not build or prepare Python. +""" + +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + + +REPO = Path(__file__).resolve().parent.parent + + +def snapshot(root): + result = {} + for path in sorted(root.rglob("*")): + metadata = path.lstat() + if path.is_symlink(): + content = ("link", os.readlink(path)) + elif path.is_file(): + with path.open("rb") as stream: + content = ("file", hashlib.file_digest(stream, "sha256").hexdigest()) + else: + content = ("directory",) + result[str(path.relative_to(root))] = (metadata.st_mode, content) + return result + + +def make_wheel(directory): + name = "maple_dependency_fixture-1.0.dist-info" + files = { + "maple_dependency_fixture.py": b"def double(value):\n return value * 2\n", + f"{name}/METADATA": ( + b"Metadata-Version: 2.1\nName: maple-dependency-fixture\nVersion: 1.0\n" + ), + f"{name}/WHEEL": ( + b"Wheel-Version: 1.0\nGenerator: maple-test\nRoot-Is-Purelib: true\n" + b"Tag: py3-none-any\n" + ), + } + records = [] + for path, content in files.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=") + records.append(f"{path},sha256={digest.decode()},{len(content)}\n") + files[f"{name}/RECORD"] = ("".join(records) + f"{name}/RECORD,,\n").encode() + wheel = directory / "maple_dependency_fixture-1.0-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for path, content in files.items(): + archive.writestr(path, content) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--runtime", type=Path) + source.add_argument("--app", type=Path) + args = parser.parse_args() + if args.app and sys.platform != "darwin": + parser.error("--app requires macOS codesign") + with tempfile.TemporaryDirectory(prefix="maple dependencies 日本語 ") as temporary: + root = Path(temporary) + copied = root / ("Maple Dependency Check.app" if args.app else "python") + shutil.copytree((args.app or args.runtime).resolve(strict=True), copied, symlinks=True) + runtime = copied / "Contents/Resources/python" if args.app else copied + manifest = json.loads((runtime / "runtime.json").read_text()) + if not manifest["distribution"].startswith("pbs-"): + raise ValueError("This pip check requires the portable PBS runtime") + if args.app: + subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(copied)], check=True) + before = snapshot(copied) + fixture = root / "wheels" + fixture.mkdir() + make_wheel(fixture) + task = root / "task" + task.mkdir() + deps = task / "external dependencies" + spec = importlib.util.spec_from_file_location( + "maple_dependency_worker_tests", REPO / "crates/maple-code-mode/python/test_worker.py" + ) + harness = importlib.util.module_from_spec(spec) + spec.loader.exec_module(harness) + harness.PYTHON = (runtime / manifest["executable"]).resolve(strict=True) + harness.WORKER = (runtime / manifest["worker"]).resolve(strict=True) + worker = harness.NativeWorker(task) + + def execute(code, expected): + messages = worker.execute(code) + if messages[-1].get("status") != "ok" or harness.result(messages) != expected: + raise AssertionError(messages) + + try: + execute(f""" +import importlib, pathlib, subprocess, sys +deps = pathlib.Path({str(deps)!r}) +installed = subprocess.run([ + sys.executable, '-I', '-B', '-m', 'pip', '--isolated', + '--disable-pip-version-check', 'install', '--no-cache-dir', + '--only-binary=:all:', '--target', str(deps), + '--no-index', '--find-links', {str(fixture)!r}, 'maple-dependency-fixture==1.0', +], capture_output=True, text=True, timeout=20) +assert installed.returncode == 0, (installed.stdout, installed.stderr) +sys.path.insert(0, str(deps)) +importlib.invalidate_caches() +import maple_dependency_fixture +assert pathlib.Path(maple_dependency_fixture.__file__).parent == deps +value = maple_dependency_fixture.double(21) +value +""", "42") + execute("value = maple_dependency_fixture.double(value)\nvalue", "84") + execute(""" +import multiprocessing +from concurrent.futures import ProcessPoolExecutor +spawn = multiprocessing.get_context('spawn') +with spawn.Pool(1) as pool: + pooled = pool.map_async(maple_dependency_fixture.double, [2, 3]).get(timeout=3) +with ProcessPoolExecutor(max_workers=1, mp_context=spawn) as executor: + executed = executor.submit(maple_dependency_fixture.double, 4).result(timeout=3) +(pooled, executed) +""", "([4, 6], 8)") + finally: + worker.shutdown() + after = snapshot(copied) + changed = [path for path in before.keys() | after.keys() if before.get(path) != after.get(path)] + if changed: + raise AssertionError(f"Packaged tree changed: {sorted(changed)}") + if args.app: + subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(copied)], check=True) + print("Offline external wheel install, retained import, and imported-module spawn pools passed") + print(f"Packaged tree unchanged: {len(before)} entries; signature checked: {bool(args.app)}") + + +if __name__ == "__main__": + main() 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")