diff --git a/docs/integration/portable-ai-kit.md b/docs/integration/portable-ai-kit.md new file mode 100644 index 0000000..50c86dd --- /dev/null +++ b/docs/integration/portable-ai-kit.md @@ -0,0 +1,184 @@ +# SourceOS Portable AI Kit + +SourceOS Portable AI Kit is the pocketable local-AI appliance mode for SourceOS. + +The product promise is deliberately simple: prepare a USB drive or portable SSD once, carry a governed local AI workstation, and run it on a supported host without sending prompts or chat state off-device by default. + +This workstream absorbs the useful product pattern from simple portable Ollama/AnythingLLM USB projects, but raises the bar to SourceOS requirements: signed manifests, explicit model provenance, policy gates, host-write auditability, secret-free evidence, and integration with Agent Machine, Local Model Door, model-router, AgentPlane, Policy Fabric, TurtleTerm, AgentTerm, and BearBrowser. + +## Position + +Portable AI Kit is not a new agent brain, model registry, or governance authority. + +It is the installable workstation-side product surface for: + +- portable-root preflight checks; +- portable model pack planning; +- USB/SSD layout materialization; +- runtime launch planning; +- zero-trace and host-write audit posture; +- local model route evidence; +- TurtleTerm/AgentTerm/BearBrowser launch handoff; +- Agent Machine runtime receipts. + +## User-facing command surface + +Initial commands: + +```text +sourceosctl portable-ai preflight [--benchmark] +sourceosctl portable-ai profiles +sourceosctl portable-ai prepare --profile laptop-safe --dry-run +sourceosctl portable-ai prepare --profile laptop-safe --execute --policy-ok +sourceosctl portable-ai start-plan --surface turtleterm +sourceosctl portable-ai inspect +sourceosctl portable-ai evidence inspect +``` + +All commands are read-only or dry-run by default. Materialization requires `--execute --policy-ok` and emits evidence. Runtime start remains a plan until Agent Machine activation gates are present. + +## Portable root layout + +```text +SOURCEOS_AI/ +├── manifests/ +│ ├── portable-ai-root.json +│ └── model-carry-pack.*.json +├── runtimes/ +│ ├── ollama/ +│ ├── llama-cpp/ +│ └── openai-compatible-local/ +├── models/ +│ ├── blobs/ +│ └── modelfiles/ +├── cache/ +│ ├── embeddings/ +│ ├── retrieval/ +│ └── prompt-prefix/ +├── state/ +│ ├── chat/ +│ ├── workrooms/ +│ └── routes/ +├── surfaces/ +│ ├── turtleterm/ +│ ├── agent-term/ +│ └── bearbrowser/ +├── evidence/ +│ ├── preflight/ +│ ├── materialization/ +│ ├── activation/ +│ └── wipe/ +└── tmp/ +``` + +The portable root must not contain inline credentials, private keys, cloud tokens, or user enrollment secrets. + +## Preflight requirements + +`preflight` must check: + +- target root exists or parent is writable; +- filesystem large-file support is suitable for GGUF/model blobs; +- free space meets selected model-pack class; +- mount is not read-only; +- optional read/write benchmark; +- CPU architecture; +- RAM class; +- local runtime availability; +- existing manifest validity; +- evidence directory writability; +- whether the target appears removable or explicitly user-approved. + +The command must return structured JSON so TurtleTerm, AgentTerm, BearBrowser, and the website can render the same decision. + +## Profiles + +Initial portable profiles: + +| Profile | Purpose | Minimum target | Default posture | +| --- | --- | --- | --- | +| `tiny-router` | local routing, triage, rewrite | 8 GB free | local-only, no tools | +| `laptop-safe` | general offline fallback and Office assist | 16 GB free | local-only, no prompt egress | +| `office-local` | Office Plane summarization and document assistance | 32 GB free | workroom-scoped | +| `code-local` | local coding assistant and repo triage | 32 GB free | repo-scoped | +| `field-kit` | portable SSD field/operator kit | 64 GB free | evidence-first | +| `byom-gguf` | bring-your-own GGUF import | varies | manifest + hash required | + +## Security posture + +Default policy: + +- prompt egress denied; +- model downloads require explicit operator action; +- model blobs require signed or pinned-hash manifests; +- host `$HOME` writes denied by default; +- host cache writes denied by default; +- runtime ports bind to loopback only unless policy grants otherwise; +- tool use denied by default; +- wipe receipts required for zero-trace mode; +- evidence stores hashes and refs, not prompt bodies. + +## Launch posture + +A launch plan should describe what would start, not start it implicitly: + +- runtime provider: Ollama-compatible, llama.cpp, MLX/oMLX compatibility, or OpenAI-compatible local server; +- model profile and local model reference; +- portable state root; +- localhost port binding; +- selected surface: TurtleTerm, AgentTerm, BearBrowser, or local web fallback; +- required Agent Machine activation decision; +- expected evidence outputs. + +## Capability parity target + +The first parity target is to match simple portable USB projects at the product surface while exceeding them on governance. + +| Capability | Required SourceOS answer | +| --- | --- | +| USB/SSD preflight | `portable-ai preflight` with JSON evidence | +| Model menu | signed `ModelCarryPack` profiles | +| Custom GGUF | BYOM import plan with hash/license/provenance checks | +| Local chat UI | TurtleTerm plus local web fallback; optional AnythingLLM adapter | +| Ollama support | runtime profile, not sole authority | +| Offline mode | prompt egress denied and model route local-only by default | +| Zero trace | portable state root plus host-write audit and wipe receipt | +| Multi-platform | Linux-first, macOS/Windows compatibility as adapters | +| Safe shutdown | Agent Machine activation/teardown receipts | +| User clarity | one prepare path, one inspect path, one start plan | + +## Acceptance criteria + +M1 is complete when: + +1. `sourceosctl portable-ai preflight` renders structured JSON without mutating the target. +2. `sourceosctl portable-ai profiles` lists built-in portable profiles. +3. `sourceosctl portable-ai prepare --dry-run` renders a portable-root materialization plan. +4. `sourceosctl portable-ai prepare --execute --policy-ok` creates only declared directories and writes evidence. +5. `sourceosctl portable-ai start-plan` renders a launch plan without starting daemons. +6. `sourceos-model-carry` owns the portable model-pack schema and examples. +7. `agent-machine` owns runtime activation and teardown evidence semantics. +8. README documents the one-command demo path. + +M2 is complete when: + +1. model-pack manifests include pinned hashes and license/provenance fields; +2. BYOM GGUF import validates file presence and hash before route eligibility; +3. TurtleTerm can consume `start-plan` output; +4. Agent Machine emits portable runtime receipts; +5. website/product docs present the portable kit as a first-class SourceOS capability. + +## Integration homes + +| Repo | Responsibility | +| --- | --- | +| `SourceOS-Linux/sourceos-devtools` | CLI, preflight, prepare, inspect, launch-plan surface | +| `SourceOS-Linux/sourceos-model-carry` | portable model-pack schemas, examples, provenance expectations | +| `SourceOS-Linux/agent-machine` | provider activation, teardown, cache/model residency receipts | +| `SourceOS-Linux/sourceos-spec` | promoted stable contracts | +| `SourceOS-Linux/TurtleTerm` | first-class terminal UI surface | +| `SourceOS-Linux/agent-term` | ChatOps/operator event surface | +| `SocioProphet/model-router` | governed local/hosted routing | +| `SocioProphet/policy-fabric` | side-effect and prompt-egress policy | +| `SocioProphet/agentplane` | run/evidence submission and replay | +| `SocioProphet/prophet-workspace` | workroom and artifact semantics | diff --git a/tests/test_portable_ai_cli.py b/tests/test_portable_ai_cli.py new file mode 100644 index 0000000..696acd2 --- /dev/null +++ b/tests/test_portable_ai_cli.py @@ -0,0 +1,258 @@ +"""Unit tests for SourceOS Portable AI Kit commands.""" + +import hashlib +import json +import os +import pathlib +import sys +import tempfile +import unittest +from unittest import mock + +_REPO_ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + +from sourceosctl.commands import portable_ai +from sourceosctl.commands.portable_ai_cli import main as portable_ai_main + + +class TestPortableAICommands(unittest.TestCase): + def test_profiles_direct(self): + self.assertEqual(portable_ai.profiles(mock.Mock()), 0) + + def test_profiles_cli(self): + self.assertEqual(portable_ai_main(["profiles"]), 0) + + def test_preflight_existing_tempdir(self): + with tempfile.TemporaryDirectory() as tmpdir: + self.assertEqual(portable_ai_main(["preflight", tmpdir]), 0) + + def test_preflight_records_mount_and_host_facts(self): + with tempfile.TemporaryDirectory() as tmpdir: + captured = {} + + def capture(payload): + captured.update(payload) + return 0 + + args = mock.Mock(target_root=tmpdir, benchmark=False) + with mock.patch("sourceosctl.commands.portable_ai._print_json", side_effect=capture): + self.assertEqual(portable_ai.preflight(args), 0) + + self.assertEqual(captured["type"], "PortablePreflightEvidence") + self.assertIn("mount", captured) + self.assertIn("host", captured) + self.assertIn("disk", captured) + self.assertIn("runtimePaths", captured) + self.assertFalse(captured["benchmarkRequested"]) + self.assertFalse(captured["benchmarkPerformed"]) + self.assertFalse(captured["mutatesTarget"]) + + def test_preflight_benchmark_removes_tempfile(self): + with tempfile.TemporaryDirectory() as tmpdir: + before = set(os.listdir(tmpdir)) + result = portable_ai._benchmark(pathlib.Path(tmpdir), size_mb=1) + after = set(os.listdir(tmpdir)) + self.assertTrue(result["requested"]) + self.assertTrue(result["performed"]) + self.assertTrue(result["tempFileRemoved"]) + self.assertEqual(before, after) + self.assertGreater(result["writeMBps"], 0) + self.assertGreater(result["readMBps"], 0) + + def test_prepare_dry_run_does_not_create_target(self): + with tempfile.TemporaryDirectory() as parent: + target = pathlib.Path(parent) / "SOURCEOS_AI" + self.assertEqual(portable_ai_main(["prepare", str(target), "--profile", "tiny-router"]), 0) + self.assertFalse(target.exists()) + + def test_prepare_execute_requires_policy_ok(self): + with tempfile.TemporaryDirectory() as parent: + target = pathlib.Path(parent) / "SOURCEOS_AI" + self.assertEqual( + portable_ai_main(["prepare", str(target), "--execute", "--profile", "tiny-router"]), + 2, + ) + self.assertFalse(target.exists()) + + def test_prepare_execute_creates_manifest_and_evidence(self): + with tempfile.TemporaryDirectory() as parent: + target = pathlib.Path(parent) / "SOURCEOS_AI" + evidence = pathlib.Path(parent) / "evidence.json" + self.assertEqual( + portable_ai_main([ + "prepare", + str(target), + "--profile", + "laptop-safe", + "--execute", + "--policy-ok", + "--evidence-out", + str(evidence), + ]), + 0, + ) + self.assertTrue((target / "manifests" / "portable-ai-root.json").exists()) + self.assertTrue((target / "evidence" / "materialization").exists()) + self.assertTrue(evidence.exists()) + payload = json.loads(evidence.read_text()) + self.assertEqual(payload["type"], "PortableMaterializationEvidence") + self.assertEqual(payload["profile"], "laptop-safe") + self.assertFalse(payload["downloadedModels"]) + self.assertFalse(payload["startedRuntime"]) + + def test_byom_verify_dry_run_hashes_local_file_only(self): + with tempfile.TemporaryDirectory() as parent: + target = pathlib.Path(parent) / "SOURCEOS_AI" + model = pathlib.Path(parent) / "demo.gguf" + model.write_bytes(b"sourceos-test-model") + expected_hash = hashlib.sha256(b"sourceos-test-model").hexdigest() + captured = {} + + def capture(payload): + captured.update(payload) + return 0 + + with mock.patch("sourceosctl.commands.portable_ai_byom._print_json", side_effect=capture): + self.assertEqual( + portable_ai_main([ + "byom", + "verify", + str(target), + str(model), + "--name", + "demo", + ]), + 0, + ) + + self.assertEqual(captured["type"], "BYOMImportPlan") + self.assertEqual(captured["sha256"], expected_hash) + self.assertFalse(captured["downloadedModel"]) + self.assertTrue(captured["wouldWriteManifest"]) + self.assertFalse((target / "manifests").exists()) + + def test_byom_execute_requires_policy_ok(self): + with tempfile.TemporaryDirectory() as parent: + target = pathlib.Path(parent) / "SOURCEOS_AI" + model = pathlib.Path(parent) / "demo.gguf" + model.write_bytes(b"sourceos-test-model") + self.assertEqual( + portable_ai_main([ + "byom", + "verify", + str(target), + str(model), + "--execute", + ]), + 0, + ) + self.assertFalse((target / "manifests").exists()) + + def test_byom_execute_writes_manifest_after_prepare(self): + with tempfile.TemporaryDirectory() as parent: + target = pathlib.Path(parent) / "SOURCEOS_AI" + model = pathlib.Path(parent) / "demo.gguf" + model.write_bytes(b"sourceos-test-model") + evidence = pathlib.Path(parent) / "byom-evidence.json" + + self.assertEqual( + portable_ai_main([ + "prepare", + str(target), + "--profile", + "byom-gguf", + "--execute", + "--policy-ok", + ]), + 0, + ) + self.assertEqual( + portable_ai_main([ + "byom", + "verify", + str(target), + str(model), + "--name", + "demo", + "--execute", + "--policy-ok", + "--evidence-out", + str(evidence), + ]), + 0, + ) + manifest = target / "manifests" / "model-carry-pack.byom-gguf.demo.json" + self.assertTrue(manifest.exists()) + self.assertTrue(evidence.exists()) + payload = json.loads(evidence.read_text()) + self.assertEqual(payload["type"], "BYOMImportEvidence") + self.assertEqual(payload["decision"], "verified") + self.assertTrue(payload["manifestWritten"]) + self.assertFalse(payload["downloadedModel"]) + + def test_start_plan_emits_runtime_env_and_command_without_starting(self): + with tempfile.TemporaryDirectory() as tmpdir: + captured = {} + + def capture(payload): + captured.update(payload) + return 0 + + with mock.patch("sourceosctl.commands.portable_ai_runtime._print_json", side_effect=capture): + self.assertEqual(portable_ai_main(["start-plan", tmpdir, "--surface", "turtleterm"]), 0) + + self.assertEqual(captured["type"], "PortableAIStartPlan") + self.assertEqual(captured["provider"], "ollama-compatible") + self.assertEqual(captured["bindAddress"], "127.0.0.1") + self.assertEqual(captured["port"], 11434) + self.assertIn("OLLAMA_MODELS", captured["runtimeEnv"]) + self.assertEqual(captured["runtimeCommand"], ["ollama", "serve"]) + self.assertFalse(captured["wouldStartRuntime"]) + self.assertFalse(captured["wouldDownloadModel"]) + self.assertTrue(captured["requiresAgentMachineActivation"]) + + def test_stop_plan_does_not_kill_processes(self): + with tempfile.TemporaryDirectory() as tmpdir: + captured = {} + + def capture(payload): + captured.update(payload) + return 0 + + with mock.patch("sourceosctl.commands.portable_ai_runtime._print_json", side_effect=capture): + self.assertEqual(portable_ai_main(["stop-plan", tmpdir]), 0) + + self.assertEqual(captured["type"], "PortableAIStopPlan") + self.assertFalse(captured["wouldStopRuntime"]) + self.assertFalse(captured["wouldKillProcesses"]) + self.assertTrue(captured["requiresOperatorConfirmation"]) + self.assertTrue(captured["requiresAgentMachineTeardown"]) + + def test_inspect(self): + with tempfile.TemporaryDirectory() as tmpdir: + self.assertEqual(portable_ai_main(["inspect", tmpdir]), 0) + + def test_evidence_inspect_valid(self): + payload = { + "type": "PortablePreflightEvidence", + "apiVersion": portable_ai.PORTABLE_LAYOUT_VERSION, + "targetRoot": "/tmp/SOURCEOS_AI", + "decision": "pass", + "promptEgressDefault": "deny", + "hostWritesDefault": "deny", + } + with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as handle: + json.dump(payload, handle) + tmp_path = handle.name + try: + self.assertEqual(portable_ai_main(["evidence", "inspect", tmp_path]), 0) + finally: + os.unlink(tmp_path) + + def test_evidence_inspect_missing(self): + self.assertEqual(portable_ai_main(["evidence", "inspect", "/nonexistent/portable-ai.json"]), 1) + + +if __name__ == "__main__": + unittest.main()