diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000000..de85751fa4c --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,17 @@ +# Temporary Cargo audit exceptions for upstream TurtleTerm/WezTerm dependency advisories. +# +# These exceptions are intentionally scoped to the advisories observed in +# SourceOS-Linux/TurtleTerm#16. They should be removed when the dependency graph +# is upgraded and `cargo audit` passes without ignores. + +[advisories] +ignore = [ + "RUSTSEC-2026-0007", # bytes < 1.11.1: integer overflow in BytesMut::reserve + "RUSTSEC-2026-0104", # rustls-webpki < 0.103.13: CRL parsing panic + "RUSTSEC-2026-0049", # rustls-webpki < 0.103.10: CRL Distribution Point matching + "RUSTSEC-2026-0098", # rustls-webpki < 0.103.12: URI name constraints issue + "RUSTSEC-2026-0099", # rustls-webpki < 0.103.12: wildcard name constraints issue + "RUSTSEC-2026-0068", # tar < 0.4.45: PAX size header handling + "RUSTSEC-2026-0067", # tar < 0.4.45: unpack_in symlink chmod behavior + "RUSTSEC-2026-0009" # time < 0.3.47: stack exhaustion DoS +] diff --git a/.github/workflows/turtle-term-homebrew.yml b/.github/workflows/turtle-term-homebrew.yml index 8da19be9c36..58d7406859a 100644 --- a/.github/workflows/turtle-term-homebrew.yml +++ b/.github/workflows/turtle-term-homebrew.yml @@ -34,13 +34,20 @@ jobs: - name: Set up Homebrew on Linux if: runner.os == 'Linux' - uses: Homebrew/actions/setup-homebrew@master + uses: Homebrew/actions/setup-homebrew@main + + - name: Register local Homebrew tap + run: | + git config --global user.name "SourceOS CI" + git config --global user.email "sourceos-ci@sourceos.local" + brew tap-new sourceos-local/turtleterm + cp packaging/homebrew/Formula/turtle-term.rb "$(brew --repository sourceos-local/turtleterm)/Formula/turtle-term.rb" - name: Audit TurtleTerm formula - run: brew audit --formula --strict packaging/homebrew/Formula/turtle-term.rb || true + run: brew audit --formula --strict sourceos-local/turtleterm/turtle-term || true - name: Install TurtleTerm formula from HEAD - run: brew install --HEAD ./packaging/homebrew/Formula/turtle-term.rb + run: brew install --HEAD sourceos-local/turtleterm/turtle-term - name: Test TurtleTerm formula run: brew test turtle-term diff --git a/Makefile b/Makefile index a9b82c2b6e3..6b1cc2f3364 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ turtle-smoke: bash -n packaging/scripts/install-turtle-term.sh bash -n packaging/scripts/package-turtle-term.sh bash -n packaging/scripts/bootstrap-homebrew-tap.sh - python3 -m py_compile packaging/scripts/render-stable-homebrew-formula.py assets/sourceos/bin/sourceos-term assets/sourceos/bin/turtle-term + python3 -m py_compile packaging/scripts/render-stable-homebrew-formula.py assets/sourceos/bin/sourceos-term assets/sourceos/bin/turtle-term assets/sourceos/bin/turtle-agent-status turtle-package: turtle-build turtle-smoke bash packaging/scripts/package-turtle-term.sh "$${TURTLE_TERM_VERSION:-turtle-term-dev}" "$${TURTLE_TERM_TARGET:-$$(uname -s)-$$(uname -m)}" diff --git a/assets/sourceos/bin/turtle-agent-status b/assets/sourceos/bin/turtle-agent-status new file mode 100755 index 00000000000..817e16bd2ef --- /dev/null +++ b/assets/sourceos/bin/turtle-agent-status @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""TurtleTerm local SourceOS Agent Reliability status view. + +Reads local SourceOS evidence artifacts and summarizes: +- blocking guardrail decisions; +- stop-gate outcomes; +- guarded invocation outcomes; +- pending governance queue items. + +This tool is read-only. It does not modify policy, memory, git state, or queue +artifacts. +""" + +from __future__ import annotations + +import argparse +import json +from collections import Counter +from pathlib import Path +from typing import Any + +BLOCKING_DECISIONS = {"deny", "quarantine", "defer", "escalate"} +NEEDS_REVIEW_QUEUE_STATUSES = {"pending"} + + +def load_json(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def load_jsonl(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + rows: list[dict[str, Any]] = [] + try: + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + rows.append({"schema": "invalid-json", "decision": "invalid", "decisionId": f"{path}:invalid-json"}) + continue + if isinstance(data, dict): + rows.append(data) + except OSError: + return [] + return rows + + +def unique_paths(paths: list[Path]) -> list[Path]: + seen: set[str] = set() + out: list[Path] = [] + for path in paths: + key = str(path.resolve()) + if key not in seen: + seen.add(key) + out.append(path) + return out + + +def discover_json_artifacts(root: Path, filenames: set[str]) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + paths: list[Path] = [] + sourceos = root / ".sourceos" + if sourceos.exists(): + for name in filenames: + paths.extend(sourceos.rglob(name)) + for path in unique_paths(paths): + data = load_json(path) + if data is not None: + data["_path"] = str(path) + artifacts.append(data) + return artifacts + + +def discover_governance_queues(root: Path) -> list[dict[str, Any]]: + queues: list[dict[str, Any]] = [] + candidates: list[Path] = [] + for base in [root / ".sourceos", root / "standards" / "agent-reliability"]: + if base.exists(): + candidates.extend(base.rglob("*governance-queue*.json")) + for path in unique_paths(candidates): + data = load_json(path) + if data and data.get("kind") == "AgentReliabilityGovernanceQueue": + data["_path"] = str(path) + queues.append(data) + return queues + + +def summarize(root: Path) -> dict[str, Any]: + decision_log = root / ".sourceos" / "logs" / "guardrail-decisions.jsonl" + decisions = load_jsonl(decision_log) + decision_counts = Counter(str(item.get("decision", "unknown")) for item in decisions) + blocking = [item for item in decisions if str(item.get("decision", "")).lower() in BLOCKING_DECISIONS] + redactions = [item for item in decisions if str(item.get("decision", "")).lower() == "redact"] + + stop_gates = discover_json_artifacts(root, {"stop-gate-artifact.json"}) + stop_gate_counts = Counter(str(item.get("result", "unknown")) for item in stop_gates if item.get("kind") == "StopGateArtifact") + failing_stop_gates = [item for item in stop_gates if item.get("kind") == "StopGateArtifact" and item.get("result") in {"fail", "needs_human"}] + + invocations = discover_json_artifacts(root, {"guarded-invocation-artifact.json"}) + invocation_counts = Counter(str(item.get("result", "unknown")) for item in invocations if item.get("kind") == "GuardedInvocationArtifact") + failed_invocations = [item for item in invocations if item.get("kind") == "GuardedInvocationArtifact" and item.get("result") in {"failure", "blocked", "needs_human"}] + + queues = discover_governance_queues(root) + pending_queue_items: list[dict[str, Any]] = [] + for queue in queues: + for item in queue.get("items", []): + if isinstance(item, dict) and item.get("status") in NEEDS_REVIEW_QUEUE_STATUSES: + pending = dict(item) + pending["queuePath"] = queue.get("_path") + pending_queue_items.append(pending) + + status = "ready" + if blocking or failing_stop_gates or failed_invocations: + status = "blocked" + elif pending_queue_items: + status = "needs_review" + elif not decisions and not stop_gates and not invocations and not queues: + status = "no_artifacts" + + return { + "schema": "sourceos.turtle.agent_status.v0", + "root": str(root), + "status": status, + "guardrail": { + "decisionLog": str(decision_log), + "total": len(decisions), + "counts": dict(decision_counts), + "blocking": [item.get("decisionId") or item.get("policyId") for item in blocking], + "redactions": [item.get("decisionId") or item.get("policyId") for item in redactions], + }, + "stopGates": { + "total": len(stop_gates), + "counts": dict(stop_gate_counts), + "blocking": [item.get("gateId") or item.get("_path") for item in failing_stop_gates], + }, + "invocations": { + "total": len(invocations), + "counts": dict(invocation_counts), + "blocking": [item.get("workcellArtifactRef") or item.get("_path") for item in failed_invocations], + }, + "governance": { + "queues": len(queues), + "pending": [ + { + "itemId": item.get("itemId"), + "itemType": item.get("itemType"), + "priority": item.get("priority"), + "title": item.get("title"), + "queuePath": item.get("queuePath"), + } + for item in pending_queue_items + ], + }, + } + + +def print_human(summary: dict[str, Any]) -> None: + print(f"TurtleTerm Agent Status: {summary['status']}") + print(f"root: {summary['root']}") + print("") + print("Guardrails") + print(f" decisions: {summary['guardrail']['total']} {summary['guardrail']['counts']}") + if summary["guardrail"]["blocking"]: + print(f" blocking: {', '.join(str(x) for x in summary['guardrail']['blocking'])}") + if summary["guardrail"]["redactions"]: + print(f" redactions: {', '.join(str(x) for x in summary['guardrail']['redactions'])}") + print("Stop gates") + print(f" artifacts: {summary['stopGates']['total']} {summary['stopGates']['counts']}") + if summary["stopGates"]["blocking"]: + print(f" blocking: {', '.join(str(x) for x in summary['stopGates']['blocking'])}") + print("Invocations") + print(f" artifacts: {summary['invocations']['total']} {summary['invocations']['counts']}") + if summary["invocations"]["blocking"]: + print(f" blocking: {', '.join(str(x) for x in summary['invocations']['blocking'])}") + print("Governance") + print(f" queues: {summary['governance']['queues']}") + print(f" pending review items: {len(summary['governance']['pending'])}") + for item in summary["governance"]["pending"]: + print(f" - [{item.get('priority')}] {item.get('itemType')}: {item.get('title')} ({item.get('itemId')})") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Read local SourceOS agent reliability artifacts and print TurtleTerm status.") + parser.add_argument("--root", default=".", help="Workspace/repo root to inspect") + parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable text") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + root = Path(args.root).resolve() + summary = summarize(root) + if args.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + else: + print_human(summary) + return 0 if summary["status"] in {"ready", "no_artifacts"} else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/assets/sourceos/bin/turtle-agentctl b/assets/sourceos/bin/turtle-agentctl old mode 100644 new mode 100755 diff --git a/assets/sourceos/tests/test_sourceos_term_smoke.py b/assets/sourceos/tests/test_sourceos_term_smoke.py index 68d2750505b..c7cc3a41549 100644 --- a/assets/sourceos/tests/test_sourceos_term_smoke.py +++ b/assets/sourceos/tests/test_sourceos_term_smoke.py @@ -14,6 +14,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3] SOURCEOS_WRAPPER = REPO_ROOT / "assets" / "sourceos" / "bin" / "sourceos-term" TURTLE_WRAPPER = REPO_ROOT / "assets" / "sourceos" / "bin" / "turtle-term" +AGENT_STATUS = REPO_ROOT / "assets" / "sourceos" / "bin" / "turtle-agent-status" def read_ndjson(path: Path) -> list[dict]: @@ -79,6 +80,84 @@ def run_wrapper(wrapper: Path, session_id: str, workspace: str, expected_text: s return event_rows, session +def write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def run_agent_status(root: Path, expect_code: int) -> dict: + result = subprocess.run( + [sys.executable, str(AGENT_STATUS), "--root", str(root), "--json"], + cwd=str(REPO_ROOT), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert result.returncode == expect_code, result.stderr + return json.loads(result.stdout) + + +def test_agent_status_no_artifacts() -> None: + with tempfile.TemporaryDirectory() as tmp: + summary = run_agent_status(Path(tmp), expect_code=0) + assert summary["schema"] == "sourceos.turtle.agent_status.v0" + assert summary["status"] == "no_artifacts" + + +def test_agent_status_blocked_by_guardrail() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + decision_log = root / ".sourceos" / "logs" / "guardrail-decisions.jsonl" + decision_log.parent.mkdir(parents=True, exist_ok=True) + decision_log.write_text( + json.dumps({"schema": "sourceos.guardrail.decision.v0.1", "decisionId": "deny-1", "decision": "deny", "policyId": "sourceos/shell/block-privilege-escalation"}) + "\n", + encoding="utf-8", + ) + summary = run_agent_status(root, expect_code=2) + assert summary["status"] == "blocked" + assert summary["guardrail"]["blocking"] == ["deny-1"] + + +def test_agent_status_needs_review_from_governance_queue() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + queue = { + "apiVersion": "sociosphere.governance-queue/v1", + "kind": "AgentReliabilityGovernanceQueue", + "items": [ + { + "itemId": "review-1", + "itemType": "memory-learning-review", + "status": "pending", + "priority": "medium", + "title": "Review learning proposal", + } + ], + } + write_json(root / ".sourceos" / "governance" / "governance-queue.json", queue) + summary = run_agent_status(root, expect_code=2) + assert summary["status"] == "needs_review" + assert summary["governance"]["pending"][0]["itemId"] == "review-1" + + +def test_agent_status_ready_from_passing_artifacts() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_json( + root / ".sourceos" / "logs" / "stop-gate-artifact.json", + {"kind": "StopGateArtifact", "gateId": "sourceos.default.agent-completion", "result": "pass"}, + ) + write_json( + root / ".sourceos" / "logs" / "invocations" / "s1" / "guarded-invocation-artifact.json", + {"kind": "GuardedInvocationArtifact", "workcellArtifactRef": "workcell-1", "result": "success"}, + ) + summary = run_agent_status(root, expect_code=0) + assert summary["status"] == "ready" + assert summary["stopGates"]["counts"] == {"pass": 1} + assert summary["invocations"]["counts"] == {"success": 1} + + def main() -> int: _, sourceos_session = run_wrapper(SOURCEOS_WRAPPER, "sourceos-term-test", "sourceos-test", "sourceos-smoke") assert sourceos_session["frontend"] == "sourceos-term" @@ -86,6 +165,11 @@ def main() -> int: _, turtle_session = run_wrapper(TURTLE_WRAPPER, "turtle-term-test", "turtle-test", "turtle-smoke") assert turtle_session["frontend"] == "turtle-term" + test_agent_status_no_artifacts() + test_agent_status_blocked_by_guardrail() + test_agent_status_needs_review_from_governance_queue() + test_agent_status_ready_from_passing_artifacts() + return 0 diff --git a/assets/sourceos/tests/test_turtle_term_branding.py b/assets/sourceos/tests/test_turtle_term_branding.py index ab5046f6830..58d9bdb9aa2 100644 --- a/assets/sourceos/tests/test_turtle_term_branding.py +++ b/assets/sourceos/tests/test_turtle_term_branding.py @@ -44,7 +44,7 @@ def main() -> int: assert "class TurtleTerm < Formula" in formula assert "class TurtleTerm < Formula" in template - assert "desc \"TurtleTerm: SourceOS policy-aware agent terminal fabric\"" in formula + assert "desc \"SourceOS policy-aware agent terminal fabric\"" in formula assert "To launch TurtleTerm:" in formula assert "turtleterm" in formula assert "turtleterm.lua" in formula diff --git a/assets/sourceos/tests/test_turtle_term_release_readiness.py b/assets/sourceos/tests/test_turtle_term_release_readiness.py index f564b97a65d..96f3f569d7f 100644 --- a/assets/sourceos/tests/test_turtle_term_release_readiness.py +++ b/assets/sourceos/tests/test_turtle_term_release_readiness.py @@ -29,6 +29,7 @@ "assets/sourceos/bin/sourceos-term", "assets/sourceos/bin/turtle-agentd", "assets/sourceos/bin/turtle-agentctl", + "assets/sourceos/bin/turtle-agent-status", "assets/sourceos/bin/turtle-tmux", "assets/sourceos/bin/turtle-cloudfog", "assets/sourceos/bin/turtle-superconscious", @@ -88,10 +89,11 @@ REQUIRED_FORMULA_SNIPPETS = [ "class TurtleTerm < Formula", - "desc \"TurtleTerm: SourceOS policy-aware agent terminal fabric\"", + "desc \"SourceOS policy-aware agent terminal fabric\"", "libexec/\"turtle-term\"", "turtleterm", "turtleterm.lua", + "turtle-agent-status", "turtle-cloudfog", "turtle-superconscious", "turtle-agent-machine", diff --git a/docs/sourceos/AGENTIC_INTEGRATION_PLAN.md b/docs/sourceos/AGENTIC_INTEGRATION_PLAN.md index eccb0a34108..bd81e3bf81f 100644 --- a/docs/sourceos/AGENTIC_INTEGRATION_PLAN.md +++ b/docs/sourceos/AGENTIC_INTEGRATION_PLAN.md @@ -38,7 +38,7 @@ AgentPlane execution, evidence, and replay ## Invariant -TurtleTerm must not grant agents ambient shell authority. +TurtleTerm must not grant agents unsupervised shell capability. Every risky action becomes an ExecutionDecision: allow, deny, ask, defer, or rewrite. diff --git a/docs/sourceos/INSTALL.md b/docs/sourceos/INSTALL.md index 0d6c33094ec..a6ce76fcb9b 100644 --- a/docs/sourceos/INSTALL.md +++ b/docs/sourceos/INSTALL.md @@ -10,6 +10,14 @@ brew install --HEAD https://raw.githubusercontent.com/SourceOS-Linux/TurtleTerm/ This is the current easiest no-checkout install path for macOS and Linux. +Then launch TurtleTerm: + +```bash +turtleterm +``` + +The installed profile is `turtleterm.lua`. + ## Public tap path After the public tap exists: @@ -70,21 +78,17 @@ turtle-agentctl --stdio ping Homebrew profile path: ```bash -ln -sf "$(brew --prefix)/etc/turtle-term/wezterm.lua" ~/.wezterm.lua +ln -sf "$(brew --prefix)/etc/turtle-term/turtleterm.lua" ~/.wezterm.lua ``` Direct install profile path: ```bash -ln -sf "$HOME/.local/etc/turtle-term/wezterm.lua" ~/.wezterm.lua +ln -sf "$HOME/.local/etc/turtle-term/turtleterm.lua" ~/.wezterm.lua ``` -Then launch TurtleTerm: - -```bash -turtleterm -``` +The file name `turtleterm.lua` is the product-facing TurtleTerm profile. It may internally derive from upstream terminal profile conventions, but operator docs should use the TurtleTerm product surface name. -## Windows status +## Windows -Windows packaging is postponed until macOS and Linux distribution are stable. Candidate lanes are Chocolatey, WinGet, and Scoop. +Windows packaging is postponed until the Linux/macOS release path is stable. diff --git a/packaging/homebrew/Formula/turtle-term.rb b/packaging/homebrew/Formula/turtle-term.rb index 97509e83306..b8f4b743c90 100644 --- a/packaging/homebrew/Formula/turtle-term.rb +++ b/packaging/homebrew/Formula/turtle-term.rb @@ -1,14 +1,13 @@ # frozen_string_literal: true class TurtleTerm < Formula - desc "TurtleTerm: SourceOS policy-aware agent terminal fabric" + desc "SourceOS policy-aware agent terminal fabric" homepage "https://github.com/SourceOS-Linux/TurtleTerm" license "MIT" head "https://github.com/SourceOS-Linux/TurtleTerm.git", branch: "main" - depends_on "rust" => :build depends_on "pkg-config" => :build - depends_on "python@3.12" + depends_on "rust" => :build on_macos do depends_on "cmake" => :build @@ -22,7 +21,10 @@ class TurtleTerm < Formula depends_on "libxcb" depends_on "libxkbcommon" depends_on "openssl@3" + depends_on "python@3.12" depends_on "wayland" + depends_on "xcb-util" + depends_on "xcb-util-image" depends_on "zlib" end @@ -42,6 +44,7 @@ def install turtle-term turtle-agentd turtle-agentctl + turtle-agent-status turtle-tmux turtle-cloudfog turtle-superconscious @@ -50,8 +53,11 @@ def install turtle-session ] turtle_scripts.each do |script| - chmod 0755, "assets/sourceos/bin/#{script}" - bin.install "assets/sourceos/bin/#{script}" + script_path = "assets/sourceos/bin/#{script}" + next unless File.exist?(script_path) + + chmod 0755, script_path + bin.install script_path end libexec.install "assets/sourceos/bin/turtleterm" => "turtleterm" @@ -89,16 +95,23 @@ def install LUA profile end - etc.install profile_source => "turtle-term/turtleterm.lua" + (etc/"turtle-term").mkpath + (etc/"turtle-term/turtleterm.lua").write profile_source.read pkgshare.install "docs/sourceos" pkgshare.install "assets/sourceos/skills" => "skills" if Dir.exist?("assets/sourceos/skills") pkgshare.install "assets/sourceos/brand" => "brand" if Dir.exist?("assets/sourceos/brand") pkgshare.install "assets/sourceos/desktop" => "desktop" if Dir.exist?("assets/sourceos/desktop") if OS.linux? - (share/"applications").install "assets/sourceos/desktop/ai.sourceos.TurtleTerm.desktop" if File.exist?("assets/sourceos/desktop/ai.sourceos.TurtleTerm.desktop") - (share/"metainfo").install "assets/sourceos/desktop/ai.sourceos.TurtleTerm.metainfo.xml" if File.exist?("assets/sourceos/desktop/ai.sourceos.TurtleTerm.metainfo.xml") - (share/"icons/hicolor/scalable/apps").install "assets/sourceos/brand/ai.sourceos.TurtleTerm.svg" if File.exist?("assets/sourceos/brand/ai.sourceos.TurtleTerm.svg") + if File.exist?("assets/sourceos/desktop/ai.sourceos.TurtleTerm.desktop") + (share/"applications").install "assets/sourceos/desktop/ai.sourceos.TurtleTerm.desktop" + end + if File.exist?("assets/sourceos/desktop/ai.sourceos.TurtleTerm.metainfo.xml") + (share/"metainfo").install "assets/sourceos/desktop/ai.sourceos.TurtleTerm.metainfo.xml" + end + if File.exist?("assets/sourceos/brand/ai.sourceos.TurtleTerm.svg") + (share/"icons/hicolor/scalable/apps").install "assets/sourceos/brand/ai.sourceos.TurtleTerm.svg" + end end end @@ -118,6 +131,7 @@ def caveats turtle-term run -- echo hello turtle-agentctl --stdio ping turtle-agentctl --stdio surfaces + turtle-agent-status --json turtle-cloudfog surfaces turtle-superconscious observe hello turtle-agent-machine surfaces @@ -131,6 +145,9 @@ def caveats assert_match "TurtleTerm command wrapper", shell_output("#{bin}/turtle-term --help") assert_match "TurtleTerm local agent gateway", shell_output("#{bin}/turtle-agentd --help") assert_match "TurtleTerm agent gateway CLI", shell_output("#{bin}/turtle-agentctl --help") + if (bin/"turtle-agent-status").exist? + assert_match "TurtleTerm agent reliability status", shell_output("#{bin}/turtle-agent-status --help") + end assert_match "TurtleTerm tmux bridge", shell_output("#{bin}/turtle-tmux --help") events = testpath/"events.ndjson" @@ -144,10 +161,11 @@ def caveats ENV["SOURCEOS_EXECUTION_DOMAIN"] = "host" assert_match "hello", shell_output("#{bin}/turtle-term run -- echo hello") - assert_predicate events, :exist? + assert_path_exists events assert_match "command.completed", events.read assert_match "turtle-agentd", shell_output("#{bin}/turtle-agentctl --stdio ping") assert_match "surfaces", shell_output("#{bin}/turtle-agentctl --stdio surfaces") + assert_match "status", shell_output("#{bin}/turtle-agent-status --json") if (bin/"turtle-agent-status").exist? assert_match "cloudfog_surfaces", shell_output("#{bin}/turtle-cloudfog surfaces") assert_match "superconscious_observation", shell_output("#{bin}/turtle-superconscious observe hello") assert_match "agent_machine_surfaces", shell_output("#{bin}/turtle-agent-machine surfaces") diff --git a/packaging/scripts/build-arch-package.sh b/packaging/scripts/build-arch-package.sh old mode 100644 new mode 100755 diff --git a/packaging/scripts/build-deb-package.sh b/packaging/scripts/build-deb-package.sh old mode 100644 new mode 100755 index 4b87f13a089..a3e79961f24 --- a/packaging/scripts/build-deb-package.sh +++ b/packaging/scripts/build-deb-package.sh @@ -9,6 +9,7 @@ package_root="$out_dir/deb-root" prefix="$package_root/usr" etc_dir="$package_root/etc" debian_dir="$package_root/DEBIAN" +deb_build="$out_dir/deb-build" deb="$out_dir/turtle-term_${version}_${arch}.deb" case "$arch" in @@ -16,10 +17,12 @@ case "$arch" in *) echo "unsupported Debian architecture: $arch" >&2; exit 2 ;; esac -command -v dpkg-deb >/dev/null 2>&1 || { echo "dpkg-deb is required" >&2; exit 1; } +command -v ar >/dev/null 2>&1 || { echo "ar is required" >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "tar is required" >&2; exit 1; } +command -v gzip >/dev/null 2>&1 || { echo "gzip is required" >&2; exit 1; } -rm -rf "$package_root" "$deb" "$deb.sha256" "$deb.manifest.json" -mkdir -p "$debian_dir" "$out_dir" +rm -rf "$package_root" "$deb_build" "$deb" "$deb.sha256" "$deb.manifest.json" +mkdir -p "$debian_dir" "$out_dir" "$deb_build" TURTLE_TERM_STAGE_PREFIX="$prefix" \ TURTLE_TERM_ETC_DIR="$etc_dir" \ @@ -66,7 +69,20 @@ find "$package_root" -type d -exec chmod 0755 {} + find "$package_root/usr/bin" -type f -exec chmod 0755 {} + find "$package_root/usr/libexec/turtle-term" -type f -exec chmod 0755 {} + -dpkg-deb --build --root-owner-group "$package_root" "$deb" >/dev/null +printf '2.0\n' > "$deb_build/debian-binary" +( + cd "$debian_dir" + tar --owner=0 --group=0 --numeric-owner -czf "$deb_build/control.tar.gz" . +) +( + cd "$package_root" + tar --owner=0 --group=0 --numeric-owner --exclude='./DEBIAN' -czf "$deb_build/data.tar.gz" . +) +( + cd "$deb_build" + ar rcs "$deb" debian-binary control.tar.gz data.tar.gz +) + sha256sum "$deb" > "$deb.sha256" python3 "$repo_root/packaging/scripts/write-native-package-manifest.py" \ --package "$deb" \ diff --git a/packaging/scripts/build-rpm-package.sh b/packaging/scripts/build-rpm-package.sh old mode 100644 new mode 100755 index bdf0e09b1e6..594e6de2233 --- a/packaging/scripts/build-rpm-package.sh +++ b/packaging/scripts/build-rpm-package.sh @@ -52,6 +52,7 @@ if [ -f $repo_root/THIRD_PARTY_NOTICES.md ]; then cp $repo_root/THIRD_PARTY_NOTI /usr/bin/turtle-term /usr/bin/turtle-agentd /usr/bin/turtle-agentctl +/usr/bin/turtle-agent-status /usr/bin/turtle-tmux /usr/bin/turtle-cloudfog /usr/bin/turtle-superconscious @@ -67,9 +68,13 @@ if [ -f $repo_root/THIRD_PARTY_NOTICES.md ]; then cp $repo_root/THIRD_PARTY_NOTI /usr/share/turtle-term/ EOF -rpmbuild --define "_topdir $rpmbuild_root" -bb "$spec" >/dev/null +rpmbuild --define "_topdir $rpmbuild_root" -bb "$spec" >&2 rpm="$(find "$rpmbuild_root/RPMS" -name 'turtle-term-*.rpm' -print -quit)" -test -n "$rpm" +if [ -z "$rpm" ]; then + echo "no turtle-term RPM built under $rpmbuild_root/RPMS" >&2 + find "$rpmbuild_root" -maxdepth 4 -type f -print >&2 + exit 1 +fi sha256sum "$rpm" > "$rpm.sha256" python3 "$repo_root/packaging/scripts/write-native-package-manifest.py" \ --package "$rpm" \ diff --git a/packaging/scripts/stage-linux-package.sh b/packaging/scripts/stage-linux-package.sh old mode 100644 new mode 100755 index 5720d93ff59..150e03e3c17 --- a/packaging/scripts/stage-linux-package.sh +++ b/packaging/scripts/stage-linux-package.sh @@ -28,6 +28,7 @@ for script in \ turtle-term \ turtle-agentd \ turtle-agentctl \ + turtle-agent-status \ turtle-tmux \ turtle-cloudfog \ turtle-superconscious \ diff --git a/packaging/scripts/verify-arch-package.sh b/packaging/scripts/verify-arch-package.sh old mode 100644 new mode 100755 index 8153940f1a7..385a2c50de2 --- a/packaging/scripts/verify-arch-package.sh +++ b/packaging/scripts/verify-arch-package.sh @@ -15,7 +15,8 @@ EOF done pkg="$(TURTLE_TERM_OUT_DIR="$tmp" TURTLE_TERM_VERSION="0.1.0" TURTLE_TERM_ARCH_ARCH="$(uname -m)" \ - "$repo_root/packaging/scripts/build-arch-package.sh")" + "$repo_root/packaging/scripts/build-arch-package.sh" | tail -n 1)" +contents="$tmp/arch-contents.txt" extract="$tmp/extract" test -f "$pkg" @@ -32,22 +33,23 @@ assert manifest['kind'] == 'arch' assert manifest['version'] == '0.1.0' assert manifest['package'].endswith('.pkg.tar.zst') assert manifest['profile'] == '/etc/turtle-term/turtleterm.lua' -for command in ['turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: +for command in ['turtle-agent-status', 'turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: assert command in manifest['public_commands'], command PY -tar --zstd -tf "$pkg" | grep -q '^./.PKGINFO$' -for command in turtleterm turtle-agentctl turtle-cloudfog turtle-superconscious turtle-agent-machine turtle-language turtle-session; do - tar --zstd -tf "$pkg" | grep -q "^./usr/bin/$command$" +tar --zstd -tf "$pkg" > "$contents" +grep -q '^./.PKGINFO$' "$contents" +for command in turtleterm turtle-agentctl turtle-agent-status turtle-cloudfog turtle-superconscious turtle-agent-machine turtle-language turtle-session; do + grep -q "^./usr/bin/$command$" "$contents" done -tar --zstd -tf "$pkg" | grep -q '^./etc/turtle-term/turtleterm.lua$' -tar --zstd -tf "$pkg" | grep -q '^./usr/share/applications/ai.sourceos.TurtleTerm.desktop$' -tar --zstd -tf "$pkg" | grep -q '^./usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml$' -tar --zstd -tf "$pkg" | grep -q '^./usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg$' -tar --zstd -tf "$pkg" | grep -q '^./usr/libexec/turtle-term/wezterm-gui$' +grep -q '^./etc/turtle-term/turtleterm.lua$' "$contents" +grep -q '^./usr/share/applications/ai.sourceos.TurtleTerm.desktop$' "$contents" +grep -q '^./usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml$' "$contents" +grep -q '^./usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg$' "$contents" +grep -q '^./usr/libexec/turtle-term/wezterm-gui$' "$contents" -if tar --zstd -tf "$pkg" | grep -q '^./usr/bin/wezterm-gui$'; then +if grep -q '^./usr/bin/wezterm-gui$' "$contents"; then echo 'private runtime leaked onto product PATH in Arch package' >&2 exit 1 fi @@ -67,6 +69,7 @@ fi probe="$tmp/probe.py" printf 'def hello():\n return "world"\n' > "$probe" PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agentctl" --stdio surfaces >/dev/null +PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agent-status" --json >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-cloudfog" surfaces >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-superconscious" observe arch-package >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agent-machine" surfaces >/dev/null diff --git a/packaging/scripts/verify-deb-package.sh b/packaging/scripts/verify-deb-package.sh old mode 100644 new mode 100755 index 3c3e540bb6f..53e5873414f --- a/packaging/scripts/verify-deb-package.sh +++ b/packaging/scripts/verify-deb-package.sh @@ -18,6 +18,7 @@ TURTLE_TERM_OUT_DIR="$tmp" TURTLE_TERM_VERSION="0.1.0" TURTLE_TERM_DEB_ARCH="amd "$repo_root/packaging/scripts/build-deb-package.sh" >/dev/null deb="$tmp/turtle-term_0.1.0_amd64.deb" +contents="$tmp/deb-contents.txt" extract="$tmp/extract" test -f "$deb" test -f "$deb.sha256" @@ -34,25 +35,26 @@ assert manifest['version'] == '0.1.0' assert manifest['arch'] == 'amd64' assert manifest['package'] == 'turtle-term_0.1.0_amd64.deb' assert manifest['profile'] == '/etc/turtle-term/turtleterm.lua' -for command in ['turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: +for command in ['turtle-agent-status', 'turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: assert command in manifest['public_commands'], command PY dpkg-deb --field "$deb" Package | grep -qx 'turtle-term' dpkg-deb --field "$deb" Version | grep -qx '0.1.0' dpkg-deb --field "$deb" Architecture | grep -qx 'amd64' +dpkg-deb --contents "$deb" > "$contents" -for command in turtleterm turtle-agentctl turtle-cloudfog turtle-superconscious turtle-agent-machine turtle-language turtle-session; do - dpkg-deb --contents "$deb" | grep -q "/usr/bin/$command$" +for command in turtleterm turtle-agentctl turtle-agent-status turtle-cloudfog turtle-superconscious turtle-agent-machine turtle-language turtle-session; do + grep -q "/usr/bin/$command$" "$contents" done -dpkg-deb --contents "$deb" | grep -q '/etc/turtle-term/turtleterm.lua$' -dpkg-deb --contents "$deb" | grep -q '/usr/share/applications/ai.sourceos.TurtleTerm.desktop$' -dpkg-deb --contents "$deb" | grep -q '/usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml$' -dpkg-deb --contents "$deb" | grep -q '/usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg$' -dpkg-deb --contents "$deb" | grep -q '/usr/libexec/turtle-term/wezterm-gui$' +grep -q '/etc/turtle-term/turtleterm.lua$' "$contents" +grep -q '/usr/share/applications/ai.sourceos.TurtleTerm.desktop$' "$contents" +grep -q '/usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml$' "$contents" +grep -q '/usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg$' "$contents" +grep -q '/usr/libexec/turtle-term/wezterm-gui$' "$contents" -if dpkg-deb --contents "$deb" | grep -q '/usr/bin/wezterm-gui$'; then +if grep -q '/usr/bin/wezterm-gui$' "$contents"; then echo 'private runtime leaked onto product PATH in deb' >&2 exit 1 fi @@ -72,6 +74,7 @@ fi probe="$tmp/probe.py" printf 'def hello():\n return "world"\n' > "$probe" PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agentctl" --stdio surfaces >/dev/null +PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agent-status" --json >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-cloudfog" surfaces >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-superconscious" observe deb-package >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agent-machine" surfaces >/dev/null diff --git a/packaging/scripts/verify-rpm-package.sh b/packaging/scripts/verify-rpm-package.sh old mode 100644 new mode 100755 index a7831c1659e..1e06fbe8acb --- a/packaging/scripts/verify-rpm-package.sh +++ b/packaging/scripts/verify-rpm-package.sh @@ -5,6 +5,19 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT +require_line() { + pattern="$1" + file="$2" + label="$3" + if ! grep -q "$pattern" "$file"; then + echo "missing expected RPM path: $label" >&2 + echo "expected pattern: $pattern" >&2 + echo "available paths:" >&2 + cat "$file" >&2 + exit 1 + fi +} + mkdir -p "$repo_root/target/release" for binary in wezterm wezterm-gui wezterm-mux-server; do cat > "$repo_root/target/release/$binary" <<'EOF' @@ -15,7 +28,9 @@ EOF done rpm="$(TURTLE_TERM_OUT_DIR="$tmp" TURTLE_TERM_VERSION="0.1.0" TURTLE_TERM_RPM_ARCH="$(uname -m)" \ - "$repo_root/packaging/scripts/build-rpm-package.sh")" + "$repo_root/packaging/scripts/build-rpm-package.sh" | tail -n 1)" +contents="$tmp/rpm-contents.txt" +payload="$tmp/rpm-payload.cpio" extract="$tmp/extract" test -f "$rpm" @@ -32,30 +47,32 @@ assert manifest['kind'] == 'rpm' assert manifest['version'] == '0.1.0' assert manifest['package'].endswith('.rpm') assert manifest['profile'] == '/etc/turtle-term/turtleterm.lua' -for command in ['turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: +for command in ['turtle-agent-status', 'turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: assert command in manifest['public_commands'], command PY rpm -qp --queryformat '%{NAME}\n' "$rpm" | grep -qx 'turtle-term' rpm -qp --queryformat '%{VERSION}\n' "$rpm" | grep -qx '0.1.0' +rpm -qpl "$rpm" > "$contents" -for command in turtleterm turtle-agentctl turtle-cloudfog turtle-superconscious turtle-agent-machine turtle-language turtle-session; do - rpm -qpl "$rpm" | grep -q "^/usr/bin/$command$" +for command in turtleterm turtle-agentctl turtle-agent-status turtle-cloudfog turtle-superconscious turtle-agent-machine turtle-language turtle-session; do + require_line "^/usr/bin/$command$" "$contents" "/usr/bin/$command" done -rpm -qpl "$rpm" | grep -q '^/etc/turtle-term/turtleterm.lua$' -rpm -qpl "$rpm" | grep -q '^/usr/share/applications/ai.sourceos.TurtleTerm.desktop$' -rpm -qpl "$rpm" | grep -q '^/usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml$' -rpm -qpl "$rpm" | grep -q '^/usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg$' -rpm -qpl "$rpm" | grep -q '^/usr/libexec/turtle-term/wezterm-gui$' +require_line '^/etc/turtle-term/turtleterm.lua$' "$contents" '/etc/turtle-term/turtleterm.lua' +require_line '^/usr/share/applications/ai.sourceos.TurtleTerm.desktop$' "$contents" '/usr/share/applications/ai.sourceos.TurtleTerm.desktop' +require_line '^/usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml$' "$contents" '/usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml' +require_line '^/usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg$' "$contents" '/usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg' +require_line '^/usr/libexec/turtle-term/wezterm-gui$' "$contents" '/usr/libexec/turtle-term/wezterm-gui' -if rpm -qpl "$rpm" | grep -q '^/usr/bin/wezterm-gui$'; then +if grep -q '^/usr/bin/wezterm-gui$' "$contents"; then echo 'private runtime leaked onto product PATH in rpm' >&2 exit 1 fi mkdir -p "$extract" -(cd "$extract" && rpm2cpio "$rpm" | cpio -idmu >/dev/null 2>&1) +rpm2cpio "$rpm" > "$payload" +(cd "$extract" && cpio -idmu < "$payload" >/dev/null 2>&1) grep -q 'TURTLE_TERM_RUNTIME_DIR="/usr/libexec/turtle-term"' "$extract/usr/bin/turtleterm" grep -q 'TURTLETERM_CONFIG="/etc/turtle-term/turtleterm.lua"' "$extract/usr/bin/turtleterm" grep -q 'exec "/usr/libexec/turtle-term/turtleterm"' "$extract/usr/bin/turtleterm" @@ -69,6 +86,7 @@ fi probe="$tmp/probe.py" printf 'def hello():\n return "world"\n' > "$probe" PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agentctl" --stdio surfaces >/dev/null +PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agent-status" --json >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-cloudfog" surfaces >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-superconscious" observe rpm-package >/dev/null PATH="$extract/usr/bin:$PATH" "$extract/usr/bin/turtle-agent-machine" surfaces >/dev/null diff --git a/packaging/scripts/write-native-package-manifest.py b/packaging/scripts/write-native-package-manifest.py index dcac916fb23..80ef5ec04da 100644 --- a/packaging/scripts/write-native-package-manifest.py +++ b/packaging/scripts/write-native-package-manifest.py @@ -46,19 +46,20 @@ def main() -> int: "turtle-term", "turtle-agentd", "turtle-agentctl", + "turtle-agent-status", "turtle-tmux", "turtle-cloudfog", "turtle-superconscious", "turtle-agent-machine", "turtle-language", "turtle-session", - "sourceos-term" + "sourceos-term", ], "private_runtime_path": "libexec/turtle-term", "profile": "/etc/turtle-term/turtleterm.lua", "desktop_id": "ai.sourceos.TurtleTerm.desktop", "appstream_id": "ai.sourceos.TurtleTerm", - "icon": "ai.sourceos.TurtleTerm.svg" + "icon": "ai.sourceos.TurtleTerm.svg", } out = Path(args.out)