@namewta/speculo 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +1 -1
  2. package/dist/src/kernel.d.ts +23 -2
  3. package/dist/src/kernel.js +9 -0
  4. package/dist/src/kernel.js.map +1 -1
  5. package/dist/src/ops-resources.d.ts +3 -0
  6. package/dist/src/ops-resources.js +188 -0
  7. package/dist/src/ops-resources.js.map +1 -0
  8. package/dist/src/refresh.js +20 -2
  9. package/dist/src/refresh.js.map +1 -1
  10. package/dist/src/structured.js +7 -0
  11. package/dist/src/structured.js.map +1 -1
  12. package/dist/src/workflows.js +1 -1
  13. package/dist/src/workflows.js.map +1 -1
  14. package/package.json +1 -1
  15. package/template/.speculo/README.md +3 -3
  16. package/template/.speculo/kernel/README.md +3 -0
  17. package/template/.speculo/kernel/checkpoint.schema.json +138 -4
  18. package/template/commands/archive-and-consolidate.md +5 -1
  19. package/template/commands/status.md +2 -2
  20. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +34 -0
  21. package/template/workflows/ops/H-host-manage/H-host-manage.md +30 -0
  22. package/template/workflows/ops/I-initialize/I-initialize.md +28 -0
  23. package/template/workflows/ops/INDEX.md +15 -17
  24. package/template/workflows/ops/README.md +59 -93
  25. package/template/workflows/ops/_state/status.json +15 -3
  26. package/template/workflows/ops/common/CAPABILITIES.md +23 -0
  27. package/template/workflows/ops/common/USAGE.md +112 -0
  28. package/template/workflows/ops/common/examples/README.md +7 -0
  29. package/template/workflows/ops/common/examples/binding.example.json +12 -0
  30. package/template/workflows/ops/common/examples/compose-app.example.json +56 -0
  31. package/template/workflows/ops/common/examples/credential.example.json +9 -0
  32. package/template/workflows/ops/common/examples/environment-request.example.json +7 -0
  33. package/template/workflows/ops/common/examples/register.example.json +44 -0
  34. package/template/workflows/ops/common/examples/shared-allocation.example.json +37 -0
  35. package/template/workflows/ops/common/rules/activation-and-memory.md +5 -20
  36. package/template/workflows/ops/common/rules/persistence-and-secrets.md +27 -0
  37. package/template/workflows/ops/common/rules/recovery.md +15 -0
  38. package/template/workflows/ops/common/rules/shared-services.md +13 -0
  39. package/template/workflows/ops/common/schemas/allocation.schema.json +92 -0
  40. package/template/workflows/ops/common/schemas/approval.schema.json +41 -21
  41. package/template/workflows/ops/common/schemas/binding.schema.json +73 -0
  42. package/template/workflows/ops/common/schemas/deployment.schema.json +244 -0
  43. package/template/workflows/ops/common/schemas/host.schema.json +88 -0
  44. package/template/workflows/ops/common/schemas/plan.schema.json +1573 -0
  45. package/template/workflows/ops/common/schemas/project.schema.json +55 -11
  46. package/template/workflows/ops/common/schemas/spec.schema.json +1032 -0
  47. package/template/workflows/ops/common/schemas/status.schema.json +758 -17
  48. package/template/workflows/ops/common/service-profiles/custom.md +5 -0
  49. package/template/workflows/ops/common/service-profiles/docker-engine.md +11 -0
  50. package/template/workflows/ops/common/service-profiles/minio.md +7 -0
  51. package/template/workflows/ops/common/service-profiles/mysql.md +7 -0
  52. package/template/workflows/ops/common/service-profiles/redis.md +7 -0
  53. package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +13 -0
  54. package/template/workflows/ops/common/templates/EXECUTION-PLAN.md +15 -0
  55. package/template/workflows/ops/common/templates/HOST-README.md +11 -0
  56. package/template/workflows/ops/common/templates/PROJECT-README.md +15 -0
  57. package/template/workflows/ops/common/tests/test_ops.py +392 -0
  58. package/template/workflows/ops/common/tools/bootstrap.ps1 +18 -0
  59. package/template/workflows/ops/common/tools/bootstrap.sh +27 -0
  60. package/template/workflows/ops/common/tools/demo-local.py +64 -0
  61. package/template/workflows/ops/common/tools/ops.py +7 -0
  62. package/template/workflows/ops/common/tools/opslib/__init__.py +2 -0
  63. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  64. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  65. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  66. package/template/workflows/ops/common/tools/opslib/agent.py +510 -0
  67. package/template/workflows/ops/common/tools/opslib/cli.py +172 -0
  68. package/template/workflows/ops/common/tools/opslib/core.py +199 -0
  69. package/template/workflows/ops/common/tools/opslib/docs.py +199 -0
  70. package/template/workflows/ops/common/tools/opslib/execution.py +248 -0
  71. package/template/workflows/ops/common/tools/opslib/host_recipes.py +67 -0
  72. package/template/workflows/ops/common/tools/opslib/model.py +199 -0
  73. package/template/workflows/ops/common/tools/opslib/native_windows.py +32 -0
  74. package/template/workflows/ops/common/tools/opslib/planner.py +497 -0
  75. package/template/workflows/ops/common/tools/opslib/services.py +47 -0
  76. package/template/workflows/ops/common/tools/opslib/sources.py +27 -0
  77. package/template/workflows/ops/common/tools/opslib/transport.py +55 -0
  78. package/template/workflows/ops/common/tools/validate-ops.mjs +32 -1014
  79. package/template/workflows/ops/manifest.json +60 -1
  80. package/template/workflows/ops/runtime-contract.json +1 -6
  81. package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +0 -73
  82. package/template/workflows/ops/A-archive-and-learn/promotion-plan-template.md +0 -41
  83. package/template/workflows/ops/A-archive-and-learn/retrospective-template.md +0 -53
  84. package/template/workflows/ops/E-execute-and-stabilize/E-execute-and-stabilize.md +0 -82
  85. package/template/workflows/ops/E-execute-and-stabilize/attempt-summary-template.md +0 -44
  86. package/template/workflows/ops/E-execute-and-stabilize/diagnosis-template.md +0 -24
  87. package/template/workflows/ops/E-execute-and-stabilize/handoff-template.md +0 -32
  88. package/template/workflows/ops/E-execute-and-stabilize/rollback-template.md +0 -26
  89. package/template/workflows/ops/E-execute-and-stabilize/verification-state-template.json +0 -30
  90. package/template/workflows/ops/E-execute-and-stabilize/verification-template.md +0 -55
  91. package/template/workflows/ops/H-computer-hygiene/H-computer-hygiene.md +0 -105
  92. package/template/workflows/ops/H-computer-hygiene/examples/README.md +0 -9
  93. package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.environment.md +0 -45
  94. package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.json +0 -970
  95. package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.md +0 -216
  96. package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.environment.md +0 -45
  97. package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.json +0 -970
  98. package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.md +0 -216
  99. package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.environment.md +0 -45
  100. package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.json +0 -969
  101. package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.md +0 -216
  102. package/template/workflows/ops/H-computer-hygiene/references/00-sources.md +0 -58
  103. package/template/workflows/ops/H-computer-hygiene/references/01-safety.md +0 -52
  104. package/template/workflows/ops/H-computer-hygiene/references/10-windows.md +0 -47
  105. package/template/workflows/ops/H-computer-hygiene/references/11-macos.md +0 -46
  106. package/template/workflows/ops/H-computer-hygiene/references/12-linux.md +0 -13
  107. package/template/workflows/ops/H-computer-hygiene/references/20-toolchains.md +0 -83
  108. package/template/workflows/ops/H-computer-hygiene/references/21-path-migration.md +0 -64
  109. package/template/workflows/ops/H-computer-hygiene/references/22-repositories.md +0 -35
  110. package/template/workflows/ops/H-computer-hygiene/references/30-software-removal.md +0 -41
  111. package/template/workflows/ops/H-computer-hygiene/references/40-report-spec.md +0 -54
  112. package/template/workflows/ops/H-computer-hygiene/references/50-speculo-integration.md +0 -26
  113. package/template/workflows/ops/H-computer-hygiene/references/source-register.json +0 -406
  114. package/template/workflows/ops/H-computer-hygiene/rules/catalog.json +0 -191
  115. package/template/workflows/ops/H-computer-hygiene/scripts/hygiene.py +0 -1348
  116. package/template/workflows/ops/H-computer-hygiene/scripts/run.ps1 +0 -7
  117. package/template/workflows/ops/H-computer-hygiene/scripts/run.sh +0 -5
  118. package/template/workflows/ops/H-computer-hygiene/templates/config.example.json +0 -13
  119. package/template/workflows/ops/H-computer-hygiene/templates/maven-settings.fragment.xml +0 -6
  120. package/template/workflows/ops/H-computer-hygiene/templates/native-evidence.example.json +0 -19
  121. package/template/workflows/ops/H-computer-hygiene/tests/cli_smoke.py +0 -65
  122. package/template/workflows/ops/H-computer-hygiene/tests/make_examples.py +0 -114
  123. package/template/workflows/ops/H-computer-hygiene/tests/test_hygiene.py +0 -486
  124. package/template/workflows/ops/I-intake-and-assess/I-intake-and-assess.md +0 -73
  125. package/template/workflows/ops/I-intake-and-assess/change-status-template.json +0 -26
  126. package/template/workflows/ops/I-intake-and-assess/collector-catalog.md +0 -28
  127. package/template/workflows/ops/I-intake-and-assess/deployment-dossier-template.md +0 -62
  128. package/template/workflows/ops/I-intake-and-assess/global-change-status-template.json +0 -26
  129. package/template/workflows/ops/I-intake-and-assess/project-detection.md +0 -38
  130. package/template/workflows/ops/I-intake-and-assess/request-template.md +0 -44
  131. package/template/workflows/ops/I-intake-and-assess/system-report-template.md +0 -34
  132. package/template/workflows/ops/I-intake-and-assess/target-profile-template.json +0 -24
  133. package/template/workflows/ops/P-plan-and-approve/P-plan-and-approve.md +0 -72
  134. package/template/workflows/ops/P-plan-and-approve/plan-review-template.md +0 -78
  135. package/template/workflows/ops/_state/archive/.gitkeep +0 -1
  136. package/template/workflows/ops/_state/changes/.gitkeep +0 -1
  137. package/template/workflows/ops/common/rules/artifact-contract.md +0 -38
  138. package/template/workflows/ops/common/rules/closure-and-learning.md +0 -27
  139. package/template/workflows/ops/common/rules/evidence-and-redaction.md +0 -22
  140. package/template/workflows/ops/common/rules/execution-loop.md +0 -27
  141. package/template/workflows/ops/common/rules/path-and-scope-contract.md +0 -21
  142. package/template/workflows/ops/common/rules/plan-and-approval.md +0 -25
  143. package/template/workflows/ops/common/rules/project-and-change-scope.md +0 -20
  144. package/template/workflows/ops/common/rules/target-profile-and-release-gates.md +0 -44
  145. package/template/workflows/ops/common/schemas/attempt.schema.json +0 -42
  146. package/template/workflows/ops/common/schemas/change-status.schema.json +0 -43
  147. package/template/workflows/ops/common/schemas/deployment-model.schema.json +0 -26
  148. package/template/workflows/ops/common/schemas/implementation-plan.schema.json +0 -221
  149. package/template/workflows/ops/common/schemas/inventory-snapshot.schema.json +0 -31
  150. package/template/workflows/ops/common/schemas/journal-event.schema.json +0 -22
  151. package/template/workflows/ops/common/schemas/promotion-approval.schema.json +0 -20
  152. package/template/workflows/ops/common/schemas/promotion-manifest.schema.json +0 -22
  153. package/template/workflows/ops/common/schemas/target-profile.schema.json +0 -32
  154. package/template/workflows/ops/common/schemas/verification-state.schema.json +0 -30
  155. package/template/workflows/ops/common/tools/close-change.mjs +0 -177
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Real disposable local deployment. No network, system installation, or existing-root writes."""
3
+ from __future__ import annotations
4
+ import argparse,json,os,sys
5
+ from pathlib import Path
6
+ from opslib.cli import initialize
7
+ from opslib.core import write_json,OpsError,read_json
8
+ from opslib.model import register,put_credential,load
9
+ from opslib.transport import probe_local
10
+ from opslib.planner import compile_plan
11
+ from opslib.execution import approval,apply
12
+
13
+ def run(output:Path)->dict:
14
+ output=output.absolute()
15
+ if output.exists() and any(output.iterdir()):raise OpsError("demo output must be a new/empty directory, never your real state or target root")
16
+ output.mkdir(parents=True,exist_ok=True)
17
+ state=output/'controller';target=output/'server'
18
+ initialize(state,'demo-controller')
19
+ inventory=probe_local()
20
+ platform=inventory['platform']
21
+ if platform not in ('linux','darwin','windows'):raise OpsError('unsupported platform')
22
+ register(state,{'hosts':[{'host_id':'demo-local','display_name':'Disposable local demo','platform':platform,'transport':'local','connection':{},'root':str(target),'identity':inventory['identity']}],
23
+ 'projects':[{'project_id':'app-a','display_name':'Demo APP A','kind':'app','service_type':None,'source':{'type':'local','location':str(output/'fixture-source'),'revision':'demo-v1'}}]})
24
+ password='DEMO-ONLY-$literal-quote\"-not-a-real-password'
25
+ put_credential(state,{'credential_id':'demo-auth','version':1,'purpose':'Disposable example only; never use in production','values':{'username':'demo-admin','password':password}})
26
+ program="""import os,json,pathlib
27
+ root=pathlib.Path(os.environ['OPS_DATA_ROOT'])/'app/storage'
28
+ root.mkdir(parents=True,exist_ok=True)
29
+ assert os.environ['APP_USERNAME']=='demo-admin'
30
+ assert os.environ['APP_PASSWORD'].startswith('DEMO-ONLY-')
31
+ p=root/'record.json'
32
+ p.write_text(json.dumps({'version':'demo-v1','count':1,'environment_injected':True}))
33
+ """
34
+ spec={'schema_version':1,'worker':'D','operation':'deploy','reason':'Explicit disposable local demo; only this new output tree is written. No network or system install.',
35
+ 'rollback_note':'All files belong to the demo output. Keep evidence; no user data or shared service is touched.',
36
+ 'deployments':[{'deployment_id':'app-a-demo','project_id':'app-a','host_id':'demo-local','environment':'demo','instance':'main','layout':'flat','method':'native','version':'demo-v1',
37
+ 'files':[{'path':'artifact/main.py','content':program}], 'native':{'supervisor':'oneshot','argv':[sys.executable,'{{artifact}}/main.py']},
38
+ 'env':{'app.env':{'APP_USERNAME':'{{credential:demo-auth@1:username}}','APP_PASSWORD':'{{credential:demo-auth@1:password}}'}},'credential_refs':['demo-auth@1'],
39
+ 'health':[{'type':'file','path':'data/app/storage/record.json'}],
40
+ 'backup':'The finite process exits before backup. Demo has no shared dependencies. Actual backup must be a separately approved action.',
41
+ 'recovery':'Keep data/app/storage. Rerunning a different version requires a new approved plan.'}]}
42
+ write_json(output/'demo-spec.json',spec)
43
+ plan=compile_plan(state,output/'demo-spec.json')
44
+ # This script is an explicit opt-in demo limited to a fresh user-designated directory, not production approval automation.
45
+ approval(state,plan['run_id'],plan['plan_digest'],'demo-user','I authorize only this fresh disposable demo directory and the exact generated fixture plan.')
46
+ result=apply(state,plan['run_id'])
47
+ if result['status']!='completed':raise OpsError('demo did not fully complete: '+json.dumps(result))
48
+ local=state/'hosts/demo-local/deployments/app-a-demo'
49
+ readme=(target/'app-a/README.md').read_text()
50
+ assert password not in readme and 'demo-v1' in readme and str(target/'app-a/data/app/storage') in readme
51
+ assert password in (local/'README.md').read_text()
52
+ assert password in (target/'app-a/OPERATIONS.md').read_text()
53
+ assert read_json(local/'docs-receipt.json')['status']=='both-sides-verified'
54
+ assert read_json(target/'app-a/data/app/storage/record.json')['environment_injected']
55
+ report={**result,'output':str(output),'target_readme':str(target/'app-a/README.md'),'controller_record':str(local/'README.md'),
56
+ 'checks':{'runtime_data_in_project':True,'native_env_injection':True,'server_readme_no_password':True,'controller_plaintext_exact':True,'server_operations_plaintext_exact':True,'both_sides_verified':True},
57
+ 'scope':'real local filesystem and subprocess fixture; not a Docker/SSH/Windows-service production acceptance test'}
58
+ write_json(output/'DEMO-RESULT.json',report)
59
+ return report
60
+
61
+ if __name__=='__main__':
62
+ parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',required=True)
63
+ try:print(json.dumps(run(Path(parser.parse_args().output)),ensure_ascii=False,indent=2))
64
+ except (OpsError,OSError,AssertionError) as exc:print(str(exc),file=sys.stderr);sys.exit(2)
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """OPS entry point: Python >=3.10, no third-party packages."""
3
+ import sys
4
+ if sys.version_info < (3,10):
5
+ raise SystemExit("OPS requires Python >=3.10; run bootstrap.sh or bootstrap.ps1 --probe first.")
6
+ from opslib.cli import main
7
+ if __name__=="__main__":raise SystemExit(main())
@@ -0,0 +1,2 @@
1
+ """OPS 2.2 resource-centric runtime."""
2
+ __version__="2.2.0"
@@ -0,0 +1,510 @@
1
+ """Ephemeral local/SSH target agent. No third-party modules or target installation.
2
+ Receives the trusted code and a JSON request on stdin; never executes repository text implicitly.
3
+ """
4
+ from __future__ import annotations
5
+ import base64, csv, datetime, hashlib, io, json, os, pathlib, platform, re, shutil, socket
6
+ import stat, subprocess, sys, tempfile, time, urllib.request, urllib.error, urllib.parse, signal
7
+
8
+ class Failure(Exception): pass
9
+
10
+ def stamp(): return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
11
+ def packed(v): return json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
12
+ def sha(v): return hashlib.sha256(v if isinstance(v, bytes) else packed(v)).hexdigest()
13
+ def check_id(v):
14
+ if not isinstance(v, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", v): raise Failure("invalid resource id")
15
+
16
+ def no_links(path):
17
+ path = pathlib.Path(path).absolute()
18
+ for q in [*reversed(path.parents), path]:
19
+ try: s = q.lstat()
20
+ except FileNotFoundError: continue
21
+ if stat.S_ISLNK(s.st_mode) or getattr(s, "st_file_attributes", 0) & 0x400: raise Failure("symlink/reparse point: " + str(q))
22
+
23
+ def secure_file(path):
24
+ if os.name != "nt": os.chmod(path, 0o600); return
25
+ p = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True, text=True, check=True)
26
+ sid = next(csv.reader([p.stdout.strip()]))[1]
27
+ subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"*{sid}:F", "*S-1-5-18:F"],
28
+ capture_output=True, check=True)
29
+
30
+ def mkdir(path, mode=0o750):
31
+ no_links(path)
32
+ missing=[]; p=pathlib.Path(path)
33
+ while not p.exists(): missing.append(p); p=p.parent
34
+ for p in reversed(missing): p.mkdir(mode=mode)
35
+
36
+ def atomic(path, data, mode=0o600):
37
+ no_links(path); mkdir(path.parent)
38
+ fd, name = tempfile.mkstemp(prefix=".ops-", dir=path.parent)
39
+ tmp = pathlib.Path(name)
40
+ try:
41
+ if hasattr(os, "fchmod"): os.fchmod(fd, mode)
42
+ with os.fdopen(fd, "wb") as f: f.write(data); f.flush(); os.fsync(f.fileno())
43
+ if os.name == "nt": secure_file(tmp)
44
+ os.replace(tmp, path)
45
+ if os.name != "nt": os.chmod(path, mode)
46
+ if hasattr(os, "O_DIRECTORY"):
47
+ d=os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
48
+ try: os.fsync(d)
49
+ finally: os.close(d)
50
+ finally: tmp.unlink(missing_ok=True)
51
+
52
+ def wj(p, v): atomic(p, json.dumps(v, ensure_ascii=False, indent=2).encode()+b"\n")
53
+ def rj(p): no_links(p); return json.loads(p.read_text(encoding="utf-8"))
54
+ def fingerprint():
55
+ stable = ""
56
+ for p in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
57
+ try: stable=pathlib.Path(p).read_text().strip(); break
58
+ except OSError: pass
59
+ if os.name == "nt":
60
+ try:
61
+ import winreg
62
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as k:
63
+ stable=winreg.QueryValueEx(k,"MachineGuid")[0]
64
+ except OSError: pass
65
+ if platform.system() == "Darwin" and not stable:
66
+ p=subprocess.run(["ioreg","-rd1","-c","IOPlatformExpertDevice"],capture_output=True,text=True,timeout=5)
67
+ m=re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"',p.stdout)
68
+ if m: stable=m.group(1)
69
+ if not stable: raise Failure("stable machine identity unavailable; explicit identity adapter required")
70
+ return sha({"machine":stable,"platform":platform.system()})
71
+
72
+ def file_state(path, limit=16*1024*1024):
73
+ path=pathlib.Path(path); no_links(path)
74
+ if not path.exists(): return {"kind":"absent"}
75
+ s=path.stat()
76
+ if path.is_file():
77
+ if s.st_size>limit: raise Failure("snapshot exceeds bounded file size: " + str(path))
78
+ result={"kind":"file","sha256":sha(path.read_bytes()),"size":s.st_size,"mode":stat.S_IMODE(s.st_mode)}
79
+ if path.name in (".ops-project.json",".ops-host.json"):result["owner"]=rj(path)
80
+ return result
81
+ if path.is_dir():
82
+ entries=sorted(p.name for p in path.iterdir())
83
+ if len(entries)>10000: raise Failure("directory snapshot exceeds 10000 entries")
84
+ return {"kind":"directory","entries_digest":sha(entries),"entry_count":len(entries)}
85
+ raise Failure("unsupported file type: "+str(path))
86
+
87
+ def tree_state(path):
88
+ root=pathlib.Path(path);no_links(root)
89
+ if not root.exists():raise Failure("quarantine item no longer exists")
90
+ rows=[];total=0
91
+ for p in ([root] if root.is_file() else [root,*root.rglob("*")]):
92
+ no_links(p)
93
+ if len(rows)>=10000:raise Failure("purge manifest exceeds 10000 entries; split a reviewed cleanup")
94
+ info=file_state(p)
95
+ if info["kind"]=="file":total+=info["size"]
96
+ if total>256*1024*1024:raise Failure("purge manifest exceeds 256 MiB; split or use a separately reviewed cleanup adapter")
97
+ rows.append({"path":str(p.relative_to(root)),"state":info})
98
+ return {"kind":"tree","manifest_sha256":sha(sorted(rows,key=lambda x:x["path"])),"logical_bytes":total,"entries":len(rows)}
99
+
100
+ def inventory(req):
101
+ tools={}
102
+ for name,args in (("python3",["--version"]),("python",["--version"]),("uv",["--version"]),
103
+ ("java",["-version"]),("node",["--version"]),("npm",["--version"]),
104
+ ("volta",["--version"]),("docker",["--version"]),("git",["--version"]),("ssh",["-V"])):
105
+ path=shutil.which(name)
106
+ item={"path":path,"status":"missing"}
107
+ if path:
108
+ try:
109
+ p=subprocess.run([path,*args],capture_output=True,text=True,timeout=8)
110
+ item.update(status="observed" if p.returncode==0 else "failed",version=(p.stdout+p.stderr).strip()[:2000])
111
+ except (OSError,subprocess.TimeoutExpired): item["status"]="unavailable"
112
+ tools[name]=item
113
+ docker_control=None
114
+ if req.get("include_docker"):
115
+ try:
116
+ context=subprocess.run(["docker","context","show"],capture_output=True,text=True,check=True,timeout=20).stdout.strip()
117
+ endpoint=json.loads(subprocess.run(["docker","context","inspect",context,"--format","{{json .Endpoints.docker.Host}}"],capture_output=True,text=True,check=True,timeout=20).stdout.strip())
118
+ info=json.loads(subprocess.run(["docker","--context",context,"info","--format","{{json .}}"],capture_output=True,text=True,check=True,timeout=30).stdout.strip())
119
+ compose=subprocess.run(["docker","--context",context,"compose","version","--short"],capture_output=True,text=True,check=True,timeout=20).stdout.strip()
120
+ docker_control={"status":"observed","context":context,"endpoint":endpoint,"id":info["ID"],"data_root":info["DockerRootDir"],"os_type":info.get("OSType"),"compose_version":compose}
121
+ except (OSError,ValueError,KeyError,subprocess.SubprocessError):docker_control={"status":"unavailable"}
122
+ diagnostics={"issues":[],"memory":{},"disks":{}}
123
+ if pathlib.Path("/proc/meminfo").exists():
124
+ mem={}
125
+ for line in pathlib.Path("/proc/meminfo").read_text().splitlines():
126
+ k,v=line.split(":",1);mem[k]=int(v.strip().split()[0])*1024
127
+ diagnostics["memory"]={k:mem.get(k) for k in ("MemTotal","MemAvailable","Cached","SwapTotal","SwapFree")}
128
+ if mem.get("MemTotal",0) and mem.get("MemAvailable",0)/mem["MemTotal"]<0.05:diagnostics["issues"].append("low-memory-available: diagnose pressure before any cleanup")
129
+ for candidate in req.get("disk_roots",[str(pathlib.Path.home())]):
130
+ p=pathlib.Path(candidate)
131
+ while not p.exists() and p!=p.parent:p=p.parent
132
+ usage=shutil.disk_usage(p);diagnostics["disks"][candidate]={"observed_path":str(p),"total":usage.total,"free":usage.free}
133
+ if usage.free/max(usage.total,1)<0.1:diagnostics["issues"].append("low-disk-free:"+candidate)
134
+ return {"identity":fingerprint(),"observed_at":stamp(),"diagnostics":diagnostics,"docker_control":docker_control,"platform":platform.system().lower(),
135
+ "architecture":platform.machine(),"hostname":socket.gethostname(),"account":os.environ.get("USERNAME",os.environ.get("USER","unknown")),
136
+ "uid":os.geteuid() if hasattr(os,"geteuid") else None,"python":sys.executable,
137
+ "tools":tools,"defaults":{"JAVA_HOME":os.environ.get("JAVA_HOME"),"PATH":os.environ.get("PATH"),
138
+ "SDKMAN_DIR":os.environ.get("SDKMAN_DIR"),"VOLTA_HOME":os.environ.get("VOLTA_HOME")},
139
+ "snapshots":{p:(tree_state(p) if p in req.get("deep_paths",[]) else file_state(p)) for p in req.get("paths",[])}}
140
+
141
+ def under(path, root, allow_root=False):
142
+ p=pathlib.Path(path); r=pathlib.Path(root)
143
+ if not p.is_absolute() or ".." in p.parts: raise Failure("nonabsolute/traversing path")
144
+ try: p.relative_to(r)
145
+ except ValueError: raise Failure("path outside approved root: " + str(p))
146
+ if p==r and not allow_root: raise Failure("operation may not target entire host root")
147
+ no_links(p); return p
148
+
149
+ def checked_path(path, req, *, allow_root=False):
150
+ if path in req.get("external_files",[]):
151
+ if path not in ("/etc/docker/daemon.json",) and not re.fullmatch(r"/etc/systemd/system/ops-[a-z0-9-]+\.service",path):
152
+ raise Failure("unrecognized external control file")
153
+ p=pathlib.Path(path); no_links(p); return p
154
+ return under(path,req["root"],allow_root)
155
+
156
+ def strip_secrets(text, values):
157
+ for v in sorted(set(values),key=len,reverse=True):
158
+ if v: text=text.replace(v,"[REDACTED]")
159
+ return re.sub(r"(?i)(password|passwd|token|secret|access_key)(\s*[=:]\s*)[^\s,;]+",r"\1\2[REDACTED]",text)
160
+
161
+ def command(argv, *, cwd, env=None, stdin=None, timeout=300, secrets=None, success_codes=None):
162
+ if not isinstance(argv,list) or not argv or not all(isinstance(x,str) and "\x00" not in x for x in argv): raise Failure("argv must be a nonempty string array")
163
+ values=secrets or []
164
+ # Temporary files bound output memory. They live under the approved run directory, not OS /tmp.
165
+ run_tmp=pathlib.Path(cwd)/".ops-command-tmp"
166
+ mkdir(run_tmp,0o700)
167
+ out=tempfile.TemporaryFile(dir=run_tmp); err=tempfile.TemporaryFile(dir=run_tmp)
168
+ try:
169
+ p=subprocess.Popen(argv,cwd=cwd,env={**os.environ,**(env or {}),"TMPDIR":str(run_tmp),"TMP":str(run_tmp),"TEMP":str(run_tmp)},
170
+ stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,stdout=out,stderr=err)
171
+ try: p.communicate(input=stdin.encode() if stdin is not None else None,timeout=timeout)
172
+ except subprocess.TimeoutExpired:
173
+ p.kill(); p.wait(); raise Failure("command timed out; side effects may have occurred, inspect before replanning")
174
+ out.seek(0); err.seek(0)
175
+ raw_out=out.read(2*1024*1024); raw_err=err.read(2*1024*1024)
176
+ result={"exit_code":p.returncode,"stdout":strip_secrets(raw_out.decode("utf-8","replace"),values),
177
+ "stderr":strip_secrets(raw_err.decode("utf-8","replace"),values),"output_sha256":sha(raw_out+raw_err)}
178
+ if p.returncode not in (success_codes or [0]):
179
+ raise Failure("command failed with exit="+str(p.returncode)+"; output_sha256="+result["output_sha256"])
180
+ return result
181
+ finally:
182
+ out.close();err.close()
183
+ try:run_tmp.rmdir()
184
+ except OSError:pass
185
+
186
+ def docker_base(op):
187
+ cmd=[op.get("docker","docker")]
188
+ if op.get("context"): cmd += ["--context",op["context"]]
189
+ return cmd
190
+
191
+ def compose_base(op):
192
+ return docker_base(op)+["compose","--project-name",op["compose_name"],"--project-directory",op["project_root"],
193
+ "--file",str(pathlib.Path(op["project_root"])/"compose/compose.yaml")]
194
+
195
+ def compose_up(op,req):
196
+ root=checked_path(op["project_root"],req); base=compose_base(op)
197
+ docker=docker_base(op)
198
+ info=command(docker+["info","--format","{{json .}}"],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))
199
+ daemon=json.loads(info["stdout"].strip())
200
+ if daemon["ID"]!=op["expected_docker_id"]:raise Failure("Docker daemon identity changed since approval")
201
+ actual=daemon["DockerRootDir"]
202
+ if req.get("strict_docker_root",True):
203
+ under(actual,req["root"])
204
+ if pathlib.Path(actual)!=pathlib.Path(req["root"])/"_runtime/docker": raise Failure("Docker data-root must be registered host_root/_runtime/docker; existing engine migration needs a separate approved host plan")
205
+ command(base+["config","--quiet"],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))
206
+ model=json.loads((root/"compose/compose.yaml").read_text(encoding="utf-8"))
207
+ if any("build" in s for s in model["services"].values()):
208
+ command(base+["build","--pull=false"],cwd=str(root),timeout=op.get("timeout",1200),secrets=req.get("secrets",[]))
209
+ command(base+["pull","--ignore-buildable"],cwd=str(root),timeout=op.get("timeout",1200),secrets=req.get("secrets",[]))
210
+ for name,service in model["services"].items():
211
+ image=service.get("image",op["compose_name"]+"-"+name)
212
+ conf=command(docker+["image","inspect",image,"--format","{{json .Config.Volumes}}"],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))
213
+ declared=json.loads(conf["stdout"].strip()) or {}
214
+ mapped={v["target"] for v in service.get("volumes",[])} | set(service.get("tmpfs",[]))
215
+ if set(declared)-mapped: raise Failure("image declares unmapped VOLUME(s), refusing anonymous persistence: "+str(sorted(set(declared)-mapped)))
216
+ command(base+["up","--detach","--remove-orphans","--wait","--wait-timeout",str(op.get("wait_timeout",120))],cwd=str(root),timeout=op.get("timeout",1200),secrets=req.get("secrets",[]))
217
+ ids=command(base+["ps","--all","--quiet"],cwd=str(root),timeout=30)["stdout"].split()
218
+ if not ids: raise Failure("Compose returned no containers")
219
+ for cid in ids:
220
+ item=json.loads(command(docker+["inspect",cid],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))["stdout"])[0]
221
+ for mount in item.get("Mounts",[]):
222
+ if mount["Type"]=="volume": raise Failure("anonymous/named persistence detected after start; stop and reconcile")
223
+ if mount["Type"]=="bind": under(mount["Source"],str(root))
224
+ return {"containers":ids,"docker_data_root":actual,"compose_name":op["compose_name"],"verified_at":stamp()}
225
+
226
+ def allocation(op,req):
227
+ provider=checked_path(op["provider_root"],req)
228
+ aid=op["allocation_id"];check_id(aid)
229
+ marker=provider/"allocations"/(aid+".json")
230
+ if op.get("compose_name"):
231
+ info=json.loads(command(docker_base(op)+["info","--format","{{json .}}"],cwd=str(provider),timeout=30)["stdout"])
232
+ if info["ID"]!=op["expected_docker_id"]:raise Failure("provider Docker daemon identity drift")
233
+ ownership={"allocation_id":aid,"resource":op["resource"],"app_username":op["app_username"],
234
+ "owner_project_id":op["owner_project_id"],"environment":op["environment"],"credential_ref":op["credential_ref"]}
235
+ if marker.exists():
236
+ if rj(marker)!=ownership:raise Failure("allocation ownership conflict")
237
+ return {"allocation_id":aid,"status":"already-owned-no-password-reset"}
238
+ if op["kind"]=="mysql-allocation":
239
+ base=docker_base(op)+["compose","--project-name",op["compose_name"],"--project-directory",str(provider),"--file",str(provider/"compose/compose.yaml"),
240
+ "exec","-T","-e","MYSQL_PWD",op["compose_service"],"mysql","--batch","--skip-column-names","--user",op["admin_username"]]
241
+ env={"MYSQL_PWD":op["admin_password"]}
242
+ def sql(q):return command(base,cwd=str(provider),env=env,stdin=q,timeout=120,secrets=req.get("secrets",[]))["stdout"].strip()
243
+ db=op["resource"];user=op["app_username"]
244
+ exists=sql("SELECT (SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='"+db+"')+(SELECT COUNT(*) FROM mysql.user WHERE user='"+user+"');\n")
245
+ if exists!="0":raise Failure("database/user already exists without allocation marker; verified adoption required")
246
+ # A server-side hex literal plus QUOTE avoids SQL/argv injection by arbitrary passwords.
247
+ hx=op["app_password"].encode().hex()
248
+ q="CREATE DATABASE `"+db+"`;\nSET @p=CONVERT(0x"+hx+" USING utf8mb4);\nSET @s=CONCAT('CREATE USER ''"+user+"''@''%'' IDENTIFIED BY ',QUOTE(@p));\nPREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;\nGRANT "+",".join(op["privileges"])+" ON `"+db+"`.* TO '"+user+"'@'%';\n"
249
+ sql(q)
250
+ app=[*base[:-1],user,"--database",db] # Replace the admin username, preserving --user.
251
+ command(app,cwd=str(provider),env={"MYSQL_PWD":op["app_password"]},stdin="SELECT DATABASE();\n",timeout=30,secrets=req.get("secrets",[]))
252
+ elif op["kind"]=="redis-allocation":
253
+ base=docker_base(op)+["compose","--project-name",op["compose_name"],"--project-directory",str(provider),"--file",str(provider/"compose/compose.yaml"),
254
+ "exec","-T","-e","REDISCLI_AUTH",op["compose_service"],"redis-cli","--user",op["admin_username"],"--raw"]
255
+ env={"REDISCLI_AUTH":op["admin_password"]}
256
+ old=command(base+["ACL","GETUSER",op["app_username"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"].strip()
257
+ if old:raise Failure("Redis user exists without allocation marker; verified adoption required")
258
+ args=["ACL","SETUSER",op["app_username"],"reset","on",">"+op["app_password"],"~"+op["prefix"]+":*","resetchannels","-@all","+@read","+@write","-@dangerous","+ping"]
259
+ resp="*"+str(len(args))+"\r\n"+"".join("$"+str(len(x.encode()))+"\r\n"+x+"\r\n" for x in args)
260
+ reply=command(base+["--pipe"],cwd=str(provider),env=env,stdin=resp,secrets=req.get("secrets",[]))["stdout"]
261
+ if "errors: 0" not in reply:raise Failure("Redis ACL pipe did not confirm zero errors")
262
+ saved=command(base+["ACL","SAVE"],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"].strip()
263
+ if saved!="OK":raise Failure("Redis ACL persistence was not confirmed")
264
+ app=[*base];app[app.index("--user")+1]=op["app_username"]
265
+ pong=command(app+["PING"],cwd=str(provider),env={"REDISCLI_AUTH":op["app_password"]},secrets=req.get("secrets",[]))["stdout"].strip()
266
+ if pong!="PONG":raise Failure("new Redis ACL could not authenticate")
267
+ elif op["kind"]=="minio-allocation":
268
+ import urllib.parse
269
+ u=urllib.parse.urlsplit(op["endpoint"])
270
+ if u.scheme not in ("http","https") or not u.hostname or u.username or u.password:raise Failure("invalid MinIO endpoint")
271
+ auth=urllib.parse.quote(op["admin_username"],safe="")+":"+urllib.parse.quote(op["admin_password"],safe="")+"@"
272
+ url=urllib.parse.urlunsplit((u.scheme,auth+u.netloc,u.path,u.query,u.fragment))
273
+ env={"MC_HOST_ops":url};mc=[op["client_path"],"--config-dir",str(provider/"run/mc"),"--json"]
274
+ mkdir(provider/"run/mc",0o700)
275
+ # Listing is bounded to metadata; unknown existing bucket/user is not silently adopted.
276
+ buckets=command(mc+["ls","ops"],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"]
277
+ if any(json.loads(line).get("key","").rstrip("/")==op["resource"] for line in buckets.splitlines() if line.strip()):raise Failure("MinIO bucket exists without allocation marker")
278
+ users=command(mc+["admin","user","list","ops"],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"]
279
+ if op["app_username"] in users:raise Failure("MinIO user exists without allocation marker")
280
+ command(mc+["mb","ops/"+op["resource"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
281
+ if not op.get("secret_argv_acknowledged"):raise Failure("MinIO secret argv exposure not approved")
282
+ command(mc+["admin","user","add","ops",op["app_username"],op["app_password"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
283
+ policy={"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::"+op["resource"]]},
284
+ {"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::"+op["resource"]+"/*"]}]}
285
+ policy_path=provider/"config/minio/policies"/(aid+".json");wj(policy_path,policy)
286
+ command(mc+["admin","policy","create","ops",aid,str(policy_path)],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
287
+ command(mc+["admin","policy","attach","ops",aid,"--user",op["app_username"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
288
+ appauth=urllib.parse.quote(op["app_username"],safe="")+":"+urllib.parse.quote(op["app_password"],safe="")+"@"
289
+ appurl=urllib.parse.urlunsplit((u.scheme,appauth+u.netloc,u.path,u.query,u.fragment))
290
+ command(mc+["ls","ops/"+op["resource"]],cwd=str(provider),env={"MC_HOST_ops":appurl},secrets=req.get("secrets",[]))
291
+ else:raise Failure("unknown allocation adapter")
292
+ wj(marker,ownership)
293
+ return {"allocation_id":aid,"status":"provisioned-and-authenticated","ownership_path":str(marker)}
294
+
295
+ def execute(op,req):
296
+ kind=op["kind"]; secrets=req.get("secrets",[])
297
+ if kind.endswith("-allocation"): return allocation(op,req)
298
+ if kind=="grant-runtime":
299
+ if os.name=="nt":raise Failure("POSIX runtime grant cannot be used on Windows")
300
+ import pwd
301
+ account=pwd.getpwnam(op["account"]);root=checked_path(op["project_root"],req)
302
+ hostroot=pathlib.Path(req["root"]);os.chmod(hostroot,0o755)
303
+ for p in [root,*[x for x in root.parents if x!=hostroot and hostroot in x.parents]]:
304
+ no_links(p);os.chown(p,-1,account.pw_gid);os.chmod(p,0o750)
305
+ for rel in op["directories"]:
306
+ base=under(str(root/rel),str(root));mkdir(base)
307
+ count=0
308
+ for p in [base,*base.rglob("*")]:
309
+ count+=1
310
+ if count>10000:raise Failure("runtime permission grant exceeds scan bound")
311
+ no_links(p);os.chown(p,-1,account.pw_gid)
312
+ # Data ownership is application-specific; code/config remain deployment-owner controlled.
313
+ if rel.split("/")[0] in ("data","logs","run"):os.chown(p,account.pw_uid,account.pw_gid)
314
+ os.chmod(p,0o750 if p.is_dir() or p.stat().st_mode&0o111 else 0o640)
315
+ return {"account":op["account"],"project_root":str(root),"directories":op["directories"]}
316
+ if kind=="mkdir":
317
+ p=checked_path(op["path"],req); mkdir(p,int(op.get("mode",488)))
318
+ if os.name!="nt" and "mode" in op:os.chmod(p,op["mode"])
319
+ if os.name!="nt" and ("uid" in op or "gid" in op): os.chown(p,op.get("uid",-1),op.get("gid",-1))
320
+ return {"path":str(p),"state":file_state(p)}
321
+ if kind=="write":
322
+ p=checked_path(op["path"],req); data=base64.b64decode(op["content_b64"],validate=True)
323
+ current=file_state(p); expected=op["expected"]
324
+ if p.name==".ops-project.json" and current["kind"]=="file" and rj(p)!=json.loads(data):raise Failure("cannot overwrite another deployment ownership marker")
325
+ if current["kind"]=="file" and current["sha256"]==sha(data):
326
+ if os.name=="nt":secure_file(p)
327
+ else:os.chmod(p,op.get("mode",0o600))
328
+ return {"path":str(p),"sha256":sha(data),"unchanged":True,"state":file_state(p)}
329
+ if current!=expected: raise Failure("file drift since plan: "+str(p))
330
+ if current["kind"]=="file":
331
+ backup=pathlib.Path(req["root"])/"_host/runs"/req["run_id"]/"before"/(sha(str(p).encode())+".bin")
332
+ if not backup.exists(): atomic(backup,p.read_bytes())
333
+ atomic(p,data,op.get("mode",384))
334
+ return {"path":str(p),"sha256":sha(data),"state":file_state(p)}
335
+ if kind in ("command","verify-command"):
336
+ cwd=checked_path(op["cwd"],req,allow_root=True)
337
+ r=command(op["argv"],cwd=str(cwd),env=op.get("env"),stdin=op.get("stdin"),timeout=op.get("timeout",300),secrets=secrets)
338
+ if "expect_stdout" in op and r["stdout"].strip()!=op["expect_stdout"].strip(): raise Failure("verification stdout mismatch")
339
+ if "stdout_pattern" in op and not re.search(op["stdout_pattern"],r["stdout"]): raise Failure("verification pattern mismatch")
340
+ return r
341
+ if kind=="compose-up":return compose_up(op,req)
342
+ if kind=="compose-stop":
343
+ root=checked_path(op["project_root"],req)
344
+ # Never 'down --volumes'; preserve data and cross-project networks.
345
+ return command(compose_base(op)+["stop"],cwd=str(root),timeout=120,secrets=secrets)
346
+ if kind=="health":
347
+ deadline=time.monotonic()+op.get("timeout",60);last=""
348
+ while time.monotonic()<deadline:
349
+ try:
350
+ if op["type"]=="tcp":
351
+ with socket.create_connection((op["hostname"],op["port"]),timeout=3): pass
352
+ elif op["type"]=="http":
353
+ u=urllib.parse.urlsplit(op["url"])
354
+ if u.scheme not in ("http","https") or u.username or u.password: raise Failure("invalid health URL")
355
+ with urllib.request.urlopen(op["url"],timeout=4) as r:
356
+ if r.status!=op.get("status",200):raise Failure("unexpected HTTP status")
357
+ if op.get("contains") and op["contains"] not in r.read(1048576).decode("utf8","replace"):raise Failure("health body mismatch")
358
+ else:raise Failure("unsupported health type")
359
+ return {"healthy":True,"at":stamp()}
360
+ except (OSError,urllib.error.URLError,Failure) as e:last=str(e);time.sleep(1)
361
+ raise Failure("health timeout: "+last)
362
+ if kind=="assert-file":
363
+ p=checked_path(op["path"],req);s=file_state(p)
364
+ if s["kind"]!="file":raise Failure("expected persistent file missing")
365
+ if op.get("sha256") and s["sha256"]!=op["sha256"]:raise Failure("persistent file hash mismatch")
366
+ return s
367
+ if kind=="purge-quarantine":
368
+ src=checked_path(op["path"],req);qroot=pathlib.Path(req["root"])/"_host/quarantine"
369
+ try:parts=src.relative_to(qroot).parts
370
+ except ValueError:raise Failure("purge outside quarantine")
371
+ if len(parts)!=2:raise Failure("purge must name one run/item, not a quarantine root")
372
+ original_receipts=pathlib.Path(req["root"])/"_host/runs"/parts[0]/"receipts"
373
+ owned=any(rj(p).get("result",{}).get("quarantine_path")==str(src) and rj(p).get("status")=="succeeded" for p in original_receipts.glob("*.json") if not p.name.endswith(".started.json"))
374
+ if not owned:raise Failure("no successful isolation receipt owns this quarantine item")
375
+ observed=tree_state(src)
376
+ if observed!=op["expected"]:raise Failure("quarantine content changed after planning")
377
+ before=shutil.disk_usage(src).free
378
+ if src.is_dir():shutil.rmtree(src)
379
+ else:src.unlink()
380
+ after=shutil.disk_usage(qroot).free
381
+ return {"deleted_quarantine":str(src),"logical_bytes":observed["logical_bytes"],"observed_free_space_delta":after-before,"irreversible":True}
382
+ if kind=="quarantine":
383
+ src=checked_path(op["path"],req);rel=src.relative_to(pathlib.Path(req["root"]))
384
+ if not (str(rel).replace("\\","/").startswith("_host/cache/") or "logs" in rel.parts):
385
+ raise Failure("cleanup only supports registered cache/log targets; data/env/backups/releases are protected")
386
+ if any(p in ("data","env","backups","releases") for p in rel.parts):raise Failure("protected path")
387
+ if file_state(src)!=op["expected"]:raise Failure("cleanup candidate drift")
388
+ dst=pathlib.Path(req["root"])/"_host/quarantine"/req["run_id"]/op["item_id"]
389
+ mkdir(dst.parent)
390
+ if dst.exists():raise Failure("quarantine destination exists")
391
+ os.rename(src,dst)
392
+ return {"quarantine_path":str(dst),"released_bytes":0,"note":"same-volume isolation is not disk reclamation"}
393
+ if kind=="defaults":
394
+ current=inventory({})
395
+ for name,expected in op["expected"].items():
396
+ actual=current["tools"].get(name)
397
+ if not actual or actual.get("version")!=expected.get("version"):
398
+ raise Failure("default runtime not restored: "+name)
399
+ return {"defaults_verified":list(op["expected"]),"at":stamp()}
400
+ raise Failure("unsupported operation kind: "+kind)
401
+
402
+ def benchmark_mirrors(req):
403
+ import statistics
404
+ candidates=req.get("candidates",[])
405
+ if not 1<=len(candidates)<=6:raise Failure("mirror test requires 1..6 explicitly approved candidates")
406
+ rounds=req.get("rounds",3)
407
+ if rounds not in (1,2,3):raise Failure("mirror rounds must be 1..3")
408
+ limit=262144;results=[]
409
+ class SameOrigin(urllib.request.HTTPRedirectHandler):
410
+ def redirect_request(self,request,fp,code,msg,headers,newurl):
411
+ old=urllib.parse.urlsplit(request.full_url);new=urllib.parse.urlsplit(newurl)
412
+ if (old.scheme,old.netloc)!=(new.scheme,new.netloc):raise Failure("cross-origin mirror redirect rejected")
413
+ return super().redirect_request(request,fp,code,msg,headers,newurl)
414
+ opener=urllib.request.build_opener(SameOrigin())
415
+ for item in candidates:
416
+ if set(item)!={"id","ecosystem","url","expected_sha256","trust","approved"}:raise Failure("mirror candidate contract mismatch")
417
+ check_id(item["id"])
418
+ url=urllib.parse.urlsplit(item["url"])
419
+ if url.scheme!="https" or not url.hostname or url.username or url.password:raise Failure("mirror samples require credential-free HTTPS with certificate verification")
420
+ if not item["approved"] or item["trust"] not in ("official","intranet","approved-third-party"):raise Failure("unapproved mirror candidate")
421
+ if not re.fullmatch(r"[a-f0-9]{64}",item["expected_sha256"]):raise Failure("mirror sample needs a preverified SHA-256")
422
+ times=[];error=None
423
+ for _ in range(rounds):
424
+ try:
425
+ started=time.monotonic()
426
+ with opener.open(urllib.request.Request(item["url"],headers={"User-Agent":"Speculo-OPS/2.2 mirror-probe"}),timeout=5) as r:
427
+ data=r.read(limit+1)
428
+ elapsed=time.monotonic()-started
429
+ if len(data)>limit:raise Failure("sample exceeds 256 KiB bound")
430
+ if sha(data)!=item["expected_sha256"]:raise Failure("sample integrity mismatch")
431
+ times.append(elapsed)
432
+ except (OSError,Failure,urllib.error.URLError) as e:error=str(e);break
433
+ results.append({"id":item["id"],"ecosystem":item["ecosystem"],"url":item["url"],"verified":len(times)==rounds,"median_seconds":statistics.median(times) if times else None,"error":error})
434
+ best={}
435
+ for row in results:
436
+ if row["verified"] and (row["ecosystem"] not in best or row["median_seconds"]<best[row["ecosystem"]]["median_seconds"]):best[row["ecosystem"]]=row
437
+ return {"identity":fingerprint(),"observed_at":stamp(),"candidates":results,"best_by_ecosystem":best,
438
+ "configuration_changed":False,"note":"Candidate trust and sample digest are supplied by the administrator. Measured latency is not a guarantee of future download speed. A separate approved plan changes configuration."}
439
+
440
+ def main(req):
441
+ identity=fingerprint()
442
+ if req.get("identity") and identity!=req["identity"]:raise Failure("target identity drift")
443
+ action=req["action"]
444
+ if action=="probe":return inventory(req)
445
+ if action=="mirror-probe":return benchmark_mirrors(req)
446
+ if action=="snapshot":return {"identity":identity,"paths":{p:file_state(p) for p in req.get("paths",[])},"at":stamp()}
447
+ root=pathlib.Path(req["root"])
448
+ if not root.is_absolute() or len(root.parts)<(2 if os.name=="nt" else 3) or ".." in root.parts:raise Failure("unsafe host root")
449
+ no_links(root)
450
+ for key in ("host_id","run_id","controller_id"):check_id(req[key])
451
+ owner={k:req[k] for k in ("host_id","run_id","controller_id","plan_digest")}
452
+ marker=root/".ops-host.json"
453
+ lockdir=root/"_host/execution.lock"
454
+ if action=="lock":
455
+ if root.exists() and not marker.exists() and any(root.iterdir()) and not req.get("adopt_root"):
456
+ raise Failure("nonempty unowned host root; explicit adoption plan required")
457
+ mkdir(root,0o755)
458
+ if os.name!="nt" and root.stat().st_mode&0o022:raise Failure("host root is group/world writable; secure it in an explicit host preparation step before deployment")
459
+ if os.name=="nt":
460
+ who=subprocess.run(["whoami","/user","/fo","csv","/nh"],capture_output=True,text=True,check=True)
461
+ sid=next(csv.reader([who.stdout.strip()]))[1]
462
+ subprocess.run(["icacls",str(root),"/inheritance:r","/grant:r",f"*{sid}:(OI)(CI)F","*S-1-5-18:(OI)(CI)F"],capture_output=True,check=True)
463
+ if marker.exists():
464
+ if rj(marker)!={"host_id":req["host_id"],"identity":identity}:raise Failure("host root ownership conflict")
465
+ else:wj(marker,{"host_id":req["host_id"],"identity":identity})
466
+ mkdir(lockdir.parent)
467
+ try:lockdir.mkdir(mode=0o700);wj(lockdir/"owner.json",owner)
468
+ except FileExistsError:
469
+ if not (lockdir/"owner.json").exists() or rj(lockdir/"owner.json")!=owner:raise Failure("target lock held by another operation")
470
+ return {"locked":True,"owner":owner}
471
+ if not marker.exists() or rj(marker)!={"host_id":req["host_id"],"identity":identity}:raise Failure("target root marker missing/conflicting")
472
+ if action=="receipts":
473
+ folder=root/"_host/runs"/req["run_id"]/"receipts"
474
+ return {"receipts":{p.stem:rj(p) for p in sorted(folder.glob("*.json"))} if folder.exists() else {}}
475
+ if not lockdir.exists() or rj(lockdir/"owner.json")!=owner:raise Failure("target lock not owned")
476
+ if action=="unlock":
477
+ (lockdir/"owner.json").unlink();lockdir.rmdir();return {"unlocked":True}
478
+ if action!="step":raise Failure("unknown agent action")
479
+ op=req["operation"];check_id(op["step_id"])
480
+ receipts=root/"_host/runs"/req["run_id"]/"receipts";mkdir(receipts,0o700)
481
+ receipt=receipts/(op["step_id"]+".json");started=receipts/(op["step_id"]+".started.json")
482
+ op_digest=req["operation_digest"]
483
+ if receipt.exists():
484
+ r=rj(receipt)
485
+ if r["operation_digest"]!=op_digest:raise Failure("operation digest mismatch")
486
+ return r
487
+ if started.exists():return {"status":"unknown","step_id":op["step_id"],"reason":"started without terminal receipt; do not replay blindly"}
488
+ call_lock=lockdir/"active-call"
489
+ try:call_lock.mkdir(mode=0o700)
490
+ except FileExistsError:raise Failure("another target call is executing or crashed; inspect before manual recovery")
491
+ try:
492
+ wj(started,{"operation_digest":op_digest,"at":stamp(),"pid":os.getpid()})
493
+ try:
494
+ result=execute(op,req)
495
+ if isinstance(result,dict): result={k:v for k,v in result.items() if k not in ("stdout","stderr")}
496
+ record={"status":"succeeded","step_id":op["step_id"],"operation_digest":op_digest,"at":stamp(),"result":result}
497
+ except Exception as e:
498
+ record={"status":"failed","step_id":op["step_id"],"operation_digest":op_digest,"at":stamp(),
499
+ "error":strip_secrets(str(e),req.get("secrets",[])),"side_effects_possible":op["kind"] not in ("health","assert-file","defaults","verify-command")}
500
+ wj(receipt,record);return record
501
+ finally:call_lock.rmdir()
502
+
503
+ if __name__=="__main__":
504
+ try:
505
+ req=globals().get("OPS_REQUEST")
506
+ if req is None:req=json.load(sys.stdin)
507
+ response={"ok":True,"result":main(req)}
508
+ except Exception as e:
509
+ response={"ok":False,"error":strip_secrets(str(e),(globals().get("OPS_REQUEST") or {}).get("secrets",[]))}
510
+ sys.stdout.write(json.dumps(response,ensure_ascii=False)+"\n")