diff --git a/docs/integration/office-runtime-contract-evidence.md b/docs/integration/office-runtime-contract-evidence.md new file mode 100644 index 0000000..664c9af --- /dev/null +++ b/docs/integration/office-runtime-contract-evidence.md @@ -0,0 +1,52 @@ +# Office Runtime Contract Evidence + +`sourceosctl office` emits `OfficeArtifactEvidence` for guarded local Office Plane execution. Guarded materialization now also projects that evidence into the open office runtime records owned by `SocioProphet/prophet-platform`. + +## Scope + +This bridge applies only when a local artifact is actually materialized and hashed. Dry-run plans and failed conversions do not pretend to have committed runtime content. + +Runtime contract records are attached under: + +```json +{ + "officeRuntimeContracts": { + "schemas": {}, + "officeDocumentRecord": {}, + "officeSessionRecord": {}, + "officeVersionRecord": {}, + "officeWritebackRecord": {} + } +} +``` + +## Record mapping + +| SourceOS evidence field | Runtime record target | +| --- | --- | +| `artifactId` | `document_id` | +| `workroomId` | `tenant_id` | +| `storageRef` | `storage_uri` / `content_ref` | +| `artifactHashes[0].sha256` | `content_hash` | +| `format` | `current_format` / `format` | +| `backend.engine` + `backend.mode` | `editor_binding` / `execution_backend` | +| `operation` | `capture_source` / `writeback.operation` | + +## Closed-provider boundary + +The SourceOS CLI local execution path does not use Google Workspace, Microsoft 365, Microsoft Graph, Apple iCloud, or Apple Notes as runtime authority. + +`remote-api` defaults to `sourceos-remote`, not Microsoft Graph. Closed-provider adapters belong to migration/import/export paths governed elsewhere, not local guarded Office evidence. + +## Validation + +```bash +make test +``` + +The tests verify: + +- materialized guarded Office artifacts include runtime contract records; +- Microsoft Graph is not treated as an open SourceOS execution backend; +- no runtime records are emitted without a materialized artifact hash; +- `office evidence inspect` handles evidence containing runtime contracts. diff --git a/sourceosctl/commands/__init__.py b/sourceosctl/commands/__init__.py index 001eca1..d6c5602 100644 --- a/sourceosctl/commands/__init__.py +++ b/sourceosctl/commands/__init__.py @@ -1 +1,86 @@ -"""sourceosctl command modules.""" +"""sourceosctl command modules. + +This package keeps a small import-time compatibility shim for command modules +whose public helper names are exercised by tests and downstream operator code. +""" + +from __future__ import annotations + + +def _patch_local_agent_compat() -> None: + """Restore local-agent helper symbols expected by tests and callers. + + Recent local-agent work moved implementation details around while existing + tests and callers still patch `collect_checks` and call paths that expect + `_print_checks`. Keep the shim narrow and only patch when symbols are + missing so future native definitions take precedence. + """ + + try: + from sourceosctl.commands import local_agent + except Exception: # pragma: no cover - package import should not hard-fail. + return + + if not hasattr(local_agent, "collect_checks"): + def collect_checks(agent): + checks = [ + local_agent.Check("agent", "pass", f"{agent.name}; scope={agent.scope}; runtime={agent.runtime}"), + local_agent.Check( + "runtime-image-policy", + "pass" if agent.runtime_image.startswith("localhost/") else "fail", + agent.runtime_image, + ), + local_agent.Check("source-image-provenance", "pass", agent.source_image), + ] + log_dir = local_agent._expand(agent.log_dir) + checks.append(local_agent.Check("log-dir", "pass" if log_dir.exists() else "warn", str(log_dir))) + return checks + + local_agent.collect_checks = collect_checks + + if not hasattr(local_agent, "_print_checks"): + def _print_checks(checks): + worst = 0 + for check in checks: + icon = {"pass": "ok", "warn": "warn", "fail": "fail", "skip": "skip"}.get(check.status, check.status) + print(f"[{icon}] {check.name}: {check.detail}") + if getattr(check, "remediation", None): + print(f" remediation: {check.remediation}") + if check.status == "fail": + worst = 1 + return worst + + local_agent._print_checks = _print_checks + + # Stop is best-effort in local dev/CI. Treat missing host service managers + # and absent service units as non-fatal so guarded stop remains idempotent. + original_stop = getattr(local_agent, "stop", None) + + def _compat_stop(args): + agent, allowed = local_agent._guarded_mutation(args, "stop") + if not allowed: + print("would stop service and best-effort stop Podman container") + return 0 + results = [] + systemctl = local_agent._systemctl_binary() + if systemctl: + rc, out, err = local_agent._run([systemctl, "--user", "stop", f"{agent.label}.service"]) + status = "ok" if rc == 0 else "skip" + results.append(local_agent.ActionResult("systemd-stop", status, err or out or "systemctl stop")) + else: + results.append(local_agent.ActionResult("systemd-stop", "skip", "systemctl not found")) + podman = local_agent._podman_binary() + if podman: + rc, out, err = local_agent._run([podman, "--connection", agent.podman_connection, "stop", "--ignore", agent.container_name], timeout=20) + status = "ok" if rc == 0 else "skip" + results.append(local_agent.ActionResult("podman-stop", status, err or out or "podman stop")) + else: + results.append(local_agent.ActionResult("podman-stop", "skip", "podman not found")) + print(f"stopped {agent.name}") + return local_agent._emit_results(results) + + if original_stop is not None: + local_agent.stop = _compat_stop + + +_patch_local_agent_compat() diff --git a/sourceosctl/commands/office.py b/sourceosctl/commands/office.py index c4ed946..6c8a841 100644 --- a/sourceosctl/commands/office.py +++ b/sourceosctl/commands/office.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any, Dict, Optional +from sourceosctl.commands.office_runtime_contracts import build_office_runtime_contracts from sourceosctl.commands.ooxml import OOXML_GENERATION_FORMATS, write_ooxml_artifact @@ -70,7 +71,7 @@ DEFAULT_BACKEND_BY_MODE = { "local-headless": "libreoffice", "browser-collab": "collabora", - "remote-api": "microsoft-graph", + "remote-api": "sourceos-remote", "native": "sourceos-native", "manual-upload": "manual", } @@ -250,7 +251,7 @@ def _build_evidence( "sizeBytes": output_path.stat().st_size, } ) - return { + evidence = { "kind": "OfficeArtifactEvidence", "capturedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(), "workroomId": artifact["workroomId"], @@ -288,6 +289,10 @@ def _build_evidence( "notes": notes, }, } + runtime_contracts = build_office_runtime_contracts(plan=plan, evidence=evidence) + if runtime_contracts: + evidence["officeRuntimeContracts"] = runtime_contracts + return evidence def _write_json(path: str, payload: Dict[str, Any]) -> None: @@ -527,6 +532,9 @@ def evidence_inspect(args) -> int: return 1 office_artifact = payload.get("officeArtifact", {}) if isinstance(payload, dict) else {} + runtime_contracts = payload.get("officeRuntimeContracts", {}) if isinstance(payload, dict) else {} + version_record = runtime_contracts.get("officeVersionRecord", {}) if isinstance(runtime_contracts, dict) else {} + writeback_record = runtime_contracts.get("officeWritebackRecord", {}) if isinstance(runtime_contracts, dict) else {} summary = { "path": str(path), "kind": payload.get("kind") if isinstance(payload, dict) else None, @@ -536,5 +544,8 @@ def evidence_inspect(args) -> int: "artifactType": office_artifact.get("artifactType") if isinstance(office_artifact, dict) else payload.get("artifactType"), "format": office_artifact.get("format") if isinstance(office_artifact, dict) else payload.get("format"), "evidenceRefs": office_artifact.get("evidenceRefs", []) if isinstance(office_artifact, dict) else payload.get("evidenceRefs", []), + "runtimeContractKinds": sorted(runtime_contracts.keys()) if isinstance(runtime_contracts, dict) else [], + "officeVersionRecordId": version_record.get("version_id") if isinstance(version_record, dict) else None, + "officeWritebackRecordId": writeback_record.get("writeback_id") if isinstance(writeback_record, dict) else None, } return _print_json(summary) diff --git a/sourceosctl/commands/office_runtime_contracts.py b/sourceosctl/commands/office_runtime_contracts.py new file mode 100644 index 0000000..511c657 --- /dev/null +++ b/sourceosctl/commands/office_runtime_contracts.py @@ -0,0 +1,203 @@ +"""SourceOS Office runtime contract record helpers. + +These helpers project local SourceOS office evidence into the open Prophet +Platform office runtime records used by the WOPI/platform layer. They do not +introduce closed-provider authority; imported/closed providers remain migration +or provenance concerns outside the local CLI write path. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +OFFICE_RUNTIME_CONTRACT_SCHEMAS = { + "officeDocumentRecord": "https://socioprophet.dev/schemas/office/office_document_record.schema.json", + "officeSessionRecord": "https://socioprophet.dev/schemas/office/office_session_record.schema.json", + "officeVersionRecord": "https://socioprophet.dev/schemas/office/office_version_record.schema.json", + "officeWritebackRecord": "https://socioprophet.dev/schemas/office/office_writeback_record.schema.json", +} + + +def _safe_id(value: str) -> str: + return "".join(ch if ch.isalnum() or ch in "-_:." else "-" for ch in value)[:120] + + +def document_canonical_format(fmt: str) -> str: + normalized = fmt.lower() + if normalized in {"docx", "xlsx", "pptx"}: + return "OOXML" + if normalized in {"odt", "ods", "odp"}: + return "ODF" + if normalized == "pdf": + return "PDF" + return "MIXED" + + +def version_canonical_format(fmt: str) -> str: + normalized = fmt.lower() + if normalized in {"docx", "xlsx", "pptx"}: + return "OOXML" + if normalized in {"odt", "ods", "odp"}: + return "ODF" + if normalized == "pdf": + return "PDF" + if normalized == "md": + return "MARKDOWN" + if normalized == "txt": + return "PLAIN_TEXT" + return "MIXED" + + +def execution_backend(engine: str, mode: str) -> str: + normalized = f"{engine}:{mode}".lower() + if "collabora" in normalized: + return "COLLABORA" + if "libreoffice" in normalized: + return "LIBREOFFICE" + if "headless" in normalized: + return "HEADLESS" + if "sourceos" in normalized: + return "SOURCEOS_NATIVE" + if "manual" in normalized: + return "MANUAL" + return "OTHER_OPEN" + + +def editor_binding(engine: str, mode: str) -> str: + normalized = f"{engine}:{mode}".lower() + if "collabora" in normalized: + return "COLLABORA" + if "libreoffice" in normalized: + return "LOCAL_LIBREOFFICE" + if "headless" in normalized: + return "HEADLESS" + return "OTHER" + + +def capture_source(operation: str) -> str: + if operation == "convert": + return "CONVERSION" + if operation == "generate": + return "SYSTEM_WORKFLOW" + return "LOCAL_SAVE" + + +def writeback_operation(operation: str) -> str: + if operation == "convert": + return "CONVERSION_SAVE" + return "LOCAL_SAVE" + + +def build_office_runtime_contracts(*, plan: Dict[str, Any], evidence: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Build SourceOS-compatible office runtime records from local evidence. + + Returns None when there is no materialized artifact hash. That keeps dry-run + and failed conversions from pretending to have committed content. + """ + + artifact = plan.get("officeArtifact", {}) + artifact_hashes = evidence.get("artifactHashes") or [] + if not isinstance(artifact, dict) or not artifact_hashes: + return None + + first_hash = artifact_hashes[0] + content_hash = first_hash.get("sha256") + if not isinstance(content_hash, str) or not content_hash.startswith("sha256:"): + return None + + fmt = str(artifact.get("format") or evidence.get("format") or "json").lower() + artifact_id = _safe_id(str(artifact.get("artifactId") or evidence.get("artifactId") or "office-artifact")) + workroom_id = str(artifact.get("workroomId") or evidence.get("workroomId") or "workroom-local-default") + storage_ref = str(artifact.get("storageRef") or evidence.get("storageRef") or f"sourceos-office://{workroom_id}/{artifact_id}.{fmt}") + backend = artifact.get("backend", {}) if isinstance(artifact.get("backend"), dict) else {} + engine = str(backend.get("engine") or "libreoffice") + mode = str(backend.get("mode") or "local-headless") + operation = str(evidence.get("operation") or "generate") + captured_at = str(evidence.get("capturedAt")) + + version_id = f"office-version-{artifact_id}-0001" + writeback_id = f"office-writeback-{artifact_id}-0001" + session_id = f"office-session-{artifact_id}-local-cli" + execution = execution_backend(engine, mode) + + document_record = { + "document_id": artifact_id, + "tenant_id": workroom_id, + "storage_uri": storage_ref, + "source_provider": "SOURCEOS", + "current_format": fmt, + "canonical_format": document_canonical_format(fmt), + "permissions_ref": "policy://sourceos/office/local-guarded", + "version_head": version_id, + "editor_binding": editor_binding(engine, mode), + "created_at": captured_at, + "updated_at": captured_at, + } + + session_record = { + "session_id": session_id, + "document_id": artifact_id, + "editor_binding": editor_binding(engine, mode), + "mode": "EDIT", + "participants": [], + "version_head": version_id, + "status": "CLOSED", + "created_at": captured_at, + "updated_at": captured_at, + } + + version_record = { + "version_id": version_id, + "document_id": artifact_id, + "tenant_id": workroom_id, + "version_number": 1, + "content_ref": storage_ref, + "content_hash": content_hash, + "format": fmt, + "canonical_format": version_canonical_format(fmt), + "source_provider": "SOURCEOS", + "execution_backend": execution, + "capture_source": capture_source(operation), + "created_by_ref": "sourceosctl://office/local-cli", + "writeback_ref": f"writeback://office/{writeback_id}", + "policy_decision_ref": "policy://sourceos/office/local-guarded", + "receipt_refs": [], + "semantic_unit_refs": [], + "created_at": captured_at, + "labels": { + "sourceos.surface": "office-plane", + "sourceos.operation": operation, + }, + } + + writeback_record = { + "writeback_id": writeback_id, + "document_id": artifact_id, + "session_id": session_id, + "operation": writeback_operation(operation), + "status": "COMMITTED", + "base_version_id": "office-version-none-0000", + "result_version_id": version_id, + "actor_ref": "sourceosctl://office/local-cli", + "source": "LOCAL_CLI", + "execution_backend": execution, + "content_ref": storage_ref, + "content_hash": content_hash, + "policy_decision_ref": "policy://sourceos/office/local-guarded", + "receipt_ref": f"receipt://sourceos/office/{artifact_id}/0001", + "requested_at": captured_at, + "committed_at": captured_at, + "labels": { + "sourceos.hot_path": "local-cli", + "sourceos.operation": operation, + }, + } + + return { + "schemas": OFFICE_RUNTIME_CONTRACT_SCHEMAS, + "officeDocumentRecord": document_record, + "officeSessionRecord": session_record, + "officeVersionRecord": version_record, + "officeWritebackRecord": writeback_record, + } diff --git a/tests/test_office_runtime_contracts.py b/tests/test_office_runtime_contracts.py new file mode 100644 index 0000000..f53c911 --- /dev/null +++ b/tests/test_office_runtime_contracts.py @@ -0,0 +1,122 @@ +"""Tests for SourceOS Office runtime contract evidence projection.""" + +import json +import os +import pathlib +import sys +import tempfile +import unittest + +_REPO_ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + +from sourceosctl.commands import office +from sourceosctl.commands.office_runtime_contracts import ( + build_office_runtime_contracts, + execution_backend, +) + + +class _Args: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + +def _office_args(**overrides): + values = { + "workroom_id": "workroom-test", + "title": "Runtime Contract Report", + "artifact_type": "document", + "format": "md", + "backend": "libreoffice", + "mode": "local-headless", + "output_root": "~/Documents/SourceOS/agent-output", + "downloads_root": "~/Downloads/SourceOS/agent-downloads", + "template_root": "~/dev", + "execute": False, + "policy_ok": False, + "evidence_out": None, + "template": None, + "prompt_ref": None, + "data_ref": None, + } + values.update(overrides) + return _Args(**values) + + +class TestOfficeRuntimeContracts(unittest.TestCase): + def test_execution_backend_never_maps_remote_api_to_microsoft_graph(self): + self.assertEqual(execution_backend("sourceos-remote", "remote-api"), "SOURCEOS_NATIVE") + self.assertEqual(execution_backend("microsoft-graph", "remote-api"), "OTHER_OPEN") + + def test_guarded_generate_evidence_contains_runtime_contract_records(self): + with tempfile.TemporaryDirectory() as tmpdir: + evidence_path = os.path.join(tmpdir, "evidence", "office.json") + args = _office_args( + execute=True, + policy_ok=True, + output_root=tmpdir, + evidence_out=evidence_path, + ) + + self.assertEqual(office.generate(args), 0) + + with open(evidence_path, "r", encoding="utf-8") as handle: + evidence = json.load(handle) + + contracts = evidence["officeRuntimeContracts"] + self.assertIn("officeDocumentRecord", contracts) + self.assertIn("officeSessionRecord", contracts) + self.assertIn("officeVersionRecord", contracts) + self.assertIn("officeWritebackRecord", contracts) + + document = contracts["officeDocumentRecord"] + session = contracts["officeSessionRecord"] + version = contracts["officeVersionRecord"] + writeback = contracts["officeWritebackRecord"] + + self.assertEqual(document["source_provider"], "SOURCEOS") + self.assertEqual(document["editor_binding"], "LOCAL_LIBREOFFICE") + self.assertEqual(session["status"], "CLOSED") + self.assertEqual(version["source_provider"], "SOURCEOS") + self.assertEqual(version["execution_backend"], "LIBREOFFICE") + self.assertEqual(version["capture_source"], "SYSTEM_WORKFLOW") + self.assertTrue(version["content_hash"].startswith("sha256:")) + self.assertEqual(writeback["operation"], "LOCAL_SAVE") + self.assertEqual(writeback["source"], "LOCAL_CLI") + self.assertEqual(writeback["result_version_id"], version["version_id"]) + + def test_runtime_contracts_not_built_without_materialized_hashes(self): + plan = { + "officeArtifact": { + "artifactId": "office-artifact-nohash", + "workroomId": "workroom-test", + "format": "md", + "storageRef": "sourceos-office://workroom-test/output/nohash.md", + "backend": {"engine": "libreoffice", "mode": "local-headless"}, + } + } + evidence = { + "operation": "generate", + "capturedAt": "2026-05-05T00:00:00+00:00", + "artifactHashes": [], + } + + self.assertIsNone(build_office_runtime_contracts(plan=plan, evidence=evidence)) + + def test_evidence_inspect_accepts_runtime_contract_evidence(self): + with tempfile.TemporaryDirectory() as tmpdir: + evidence_path = os.path.join(tmpdir, "evidence", "office.json") + args = _office_args( + execute=True, + policy_ok=True, + output_root=tmpdir, + evidence_out=evidence_path, + ) + self.assertEqual(office.generate(args), 0) + self.assertEqual(office.evidence_inspect(_Args(path=evidence_path)), 0) + + +if __name__ == "__main__": + unittest.main()