@topy-ai/maggie 0.7.44 → 0.7.46

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 (39) hide show
  1. package/README-zh-TW.md +29 -6
  2. package/README.md +86 -12
  3. package/bin/maggie.js +207 -58
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +76 -0
  5. package/bundled-contracts/google-integrations/external-write-readback-v1.schema.json +36 -0
  6. package/bundled-contracts/maggie-deployment/deployer-delegation-v1.schema.json +20 -0
  7. package/bundled-contracts/maggie-deployment/release-profile-v1.schema.json +34 -0
  8. package/bundled-contracts/maggie-design/browser-capability-v1.schema.json +33 -0
  9. package/bundled-contracts/maggie-feedback/evidence-bundle-v1.schema.json +38 -0
  10. package/bundled-references/browser-inspection.md +17 -0
  11. package/bundled-references/google-integrations-runbook.md +8 -1
  12. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +7 -7
  13. package/bundled-skills/maggie-clone/SKILL.md +21 -14
  14. package/bundled-skills/maggie-clone-to-template/SKILL.md +11 -11
  15. package/bundled-skills/maggie-clone-to-template/references/workflow.md +2 -2
  16. package/bundled-skills/maggie-content-localization/SKILL.md +10 -10
  17. package/bundled-skills/maggie-deployment/SKILL.md +57 -23
  18. package/bundled-skills/maggie-deployment/references/vps.md +4 -4
  19. package/bundled-skills/maggie-design/SKILL.md +12 -12
  20. package/bundled-skills/maggie-feedback/SKILL.md +23 -0
  21. package/bundled-skills/maggie-marketplace/SKILL.md +12 -12
  22. package/bundled-skills/maggie-ops/SKILL.md +28 -6
  23. package/bundled-skills/maggie-project-context/SKILL.md +1 -1
  24. package/bundled-skills/maggie-seo-geo/SKILL.md +6 -3
  25. package/bundled-skills/maggie-service-booking/SKILL.md +24 -24
  26. package/bundled-skills/maggie-social-share/SKILL.md +2 -2
  27. package/bundled-skills/maggie-template/SKILL.md +5 -5
  28. package/bundled-tools/clis/maggie_analytics.py +8 -0
  29. package/bundled-tools/clis/maggie_browser_audit.py +13 -2
  30. package/bundled-tools/clis/maggie_deployment.py +167 -6
  31. package/bundled-tools/clis/maggie_feedback.py +115 -1
  32. package/bundled-tools/clis/maggie_ops.py +33 -0
  33. package/bundled-tools/clis/maggie_release.py +124 -19
  34. package/bundled-tools/runtime/browser_capability.py +143 -0
  35. package/bundled-tools/runtime/external_write.py +122 -0
  36. package/bundled-tools/runtime/google_capabilities.py +37 -5
  37. package/package.json +1 -1
  38. package/references/browser-inspection.md +17 -0
  39. package/references/google-integrations-runbook.md +8 -1
@@ -20,6 +20,21 @@ from urllib.parse import urljoin, urlsplit
20
20
 
21
21
 
22
22
  ROOT = Path(__file__).resolve().parent
23
+ PROFILE_SCHEMA = "maggie-release-profile.v1"
24
+ PROFILE_CAPABILITIES = {
25
+ "deployment",
26
+ "scenario-qa",
27
+ "social-card-audit",
28
+ "migration",
29
+ "maggiedash-health",
30
+ "schedule",
31
+ "analytics-contract",
32
+ "analytics-release-gate",
33
+ "service-facts",
34
+ "maggiedash-schema",
35
+ "editorial-review",
36
+ "durable-site-evidence",
37
+ }
23
38
 
24
39
 
25
40
  def safe_tail(value: str, limit: int = 1200) -> str:
@@ -52,6 +67,68 @@ def run_gate(name: str, command: list[str], cwd: Path) -> dict:
52
67
  }
53
68
 
54
69
 
70
+ def load_release_profile(project: Path, requested: str | None) -> tuple[dict, dict]:
71
+ """Load an explicit project capability manifest without guessing scope."""
72
+ path = Path(requested).resolve() if requested else project / ".maggie" / "release-profile.json"
73
+ errors: list[str] = []
74
+ profile: dict = {}
75
+ try:
76
+ profile = json.loads(path.read_text(encoding="utf-8"))
77
+ except (OSError, json.JSONDecodeError) as exc:
78
+ errors.append(f"release profile {path}: {exc}")
79
+ if not isinstance(profile, dict):
80
+ errors.append("release profile must be a JSON object")
81
+ profile = {}
82
+ if profile.get("schemaVersion") != PROFILE_SCHEMA:
83
+ errors.append(f"schemaVersion must be {PROFILE_SCHEMA}")
84
+ name = profile.get("profile")
85
+ if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9-]{1,63}", name):
86
+ errors.append("profile must be a safe non-empty identifier")
87
+ capabilities = profile.get("capabilities")
88
+ if not isinstance(capabilities, list) or not capabilities:
89
+ errors.append("capabilities must be a non-empty array")
90
+ capabilities = []
91
+ elif len(set(capabilities)) != len(capabilities):
92
+ errors.append("capabilities must not contain duplicates")
93
+ unknown = sorted(set(capabilities) - PROFILE_CAPABILITIES) if isinstance(capabilities, list) else []
94
+ if unknown:
95
+ errors.append("unsupported capabilities: " + ", ".join(unknown))
96
+ if "deployment" not in capabilities:
97
+ errors.append("capabilities must include deployment for a release")
98
+ valid = not errors
99
+ return (
100
+ {
101
+ "path": str(path),
102
+ "profile": name if isinstance(name, str) else None,
103
+ "projectType": profile.get("projectType"),
104
+ "capabilities": capabilities if isinstance(capabilities, list) else [],
105
+ "valid": valid,
106
+ "errors": errors,
107
+ },
108
+ profile if valid else {},
109
+ )
110
+
111
+
112
+ def profile_gate(profile: dict) -> dict:
113
+ """Return the explicit profile validation as a normal release gate."""
114
+ return {
115
+ "name": "release-profile",
116
+ "passed": profile["valid"],
117
+ "exitCode": 0 if profile["valid"] else 1,
118
+ "result": {
119
+ "passed": profile["valid"],
120
+ "schemaVersion": PROFILE_SCHEMA if profile["valid"] else None,
121
+ "profile": profile.get("profile"),
122
+ "projectType": profile.get("projectType"),
123
+ "capabilities": profile.get("capabilities", []),
124
+ "path": profile.get("path"),
125
+ "errors": profile.get("errors", []),
126
+ "mutation": "not executed",
127
+ },
128
+ "stderr": "",
129
+ }
130
+
131
+
55
132
  def build_gate(project: Path) -> dict:
56
133
  """Run the project's declared typecheck and build commands."""
57
134
  package_path = project / "package.json"
@@ -84,19 +161,23 @@ def build_gate(project: Path) -> dict:
84
161
  }
85
162
 
86
163
 
87
- def build_gates(project: Path, environment: str, target: str, include_compatibility: bool) -> list[tuple[str, list[str]]]:
164
+ def build_gates(project: Path, environment: str, target: str, capabilities: set[str]) -> list[tuple[str, list[str]]]:
88
165
  python = sys.executable
89
- gates: list[tuple[str, list[str]]] = [
90
- ("deployment-preflight", [python, str(ROOT / "maggie_deployment.py"), str(project), "--target", target, "--environment", environment]),
91
- ("migration-preflight", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]),
92
- ("maggiedash-health", [python, str(ROOT / "maggie_health.py"), "--project", str(project), "--environment", environment]),
93
- ("schedule-manifest", [python, str(ROOT / "maggie_schedule.py"), str(project / ".maggie" / "schedule.json"), "--project", str(project)]),
94
- ("analytics-contract", [python, str(ROOT / "maggie_analytics.py"), "--project", str(project), "--environment", environment]),
95
- ("service-facts", [python, str(ROOT / "maggie_service_booking.py"), "fact-audit", "--project", str(project)]),
96
- ]
97
- if include_compatibility:
98
- gates.append(("maggiedash-schema", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]))
99
- return gates
166
+ deployment_command = [python, str(ROOT / "maggie_deployment.py"), str(project), "--target", target, "--environment", environment]
167
+ if target in {"vps", "vps-with-cloudflare-dns", "detected"}:
168
+ deployment_command.append("--require-deployer")
169
+ commands = {
170
+ "deployment": ("deployment-preflight", deployment_command),
171
+ "migration": ("migration-preflight", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]),
172
+ "maggiedash-health": ("maggiedash-health", [python, str(ROOT / "maggie_health.py"), "--project", str(project), "--environment", environment]),
173
+ "schedule": ("schedule-manifest", [python, str(ROOT / "maggie_schedule.py"), str(project / ".maggie" / "schedule.json"), "--project", str(project)]),
174
+ "analytics-contract": ("analytics-contract", [python, str(ROOT / "maggie_analytics.py"), "--project", str(project), "--environment", environment]),
175
+ "service-facts": ("service-facts", [python, str(ROOT / "maggie_service_booking.py"), "fact-audit", "--project", str(project)]),
176
+ "maggiedash-schema": ("maggiedash-schema", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]),
177
+ }
178
+ return [commands[capability] for capability in (
179
+ "deployment", "migration", "maggiedash-health", "schedule", "analytics-contract", "service-facts", "maggiedash-schema"
180
+ ) if capability in capabilities]
100
181
 
101
182
 
102
183
  def evidence_gate(project: Path, environment: str) -> dict:
@@ -371,6 +452,7 @@ def main() -> int:
371
452
  parser.add_argument("project", nargs="?", default=".")
372
453
  parser.add_argument("--environment", choices=("staging", "production"), default="staging")
373
454
  parser.add_argument("--target", choices=("vps", "vps-with-cloudflare-dns", "cloudflare", "detected"), default="vps-with-cloudflare-dns")
455
+ parser.add_argument("--profile", help="explicit release capability manifest; defaults to .maggie/release-profile.json")
374
456
  parser.add_argument("--output", help="release evidence JSON path")
375
457
  parser.add_argument("--skip-compatibility", action="store_true", help="skip the optional schema rehearsal; not recommended for release")
376
458
  parser.add_argument("--base-url", help="optional live/staging URL for homepage, admin redirect and 404 smoke")
@@ -385,14 +467,24 @@ def main() -> int:
385
467
  if not project.is_dir():
386
468
  parser.error(f"project directory does not exist: {project}")
387
469
 
388
- gates = []
470
+ profile, _profile_payload = load_release_profile(project, args.profile)
471
+ capabilities = set(profile.get("capabilities", [])) if profile["valid"] else set()
472
+ if args.skip_compatibility:
473
+ capabilities.discard("maggiedash-schema")
474
+ gates = [profile_gate(profile)]
389
475
  gates.append(changed_surface_gate(project))
390
- gates.append(qa_gate(project, args.environment, args.base_url))
476
+ if "scenario-qa" in capabilities:
477
+ gates.append(qa_gate(project, args.environment, args.base_url))
478
+ else:
479
+ gates.append({"name": "scenario-qa", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "scenario-qa is not declared by the release profile"}, "stderr": ""})
391
480
  gates.append(build_gate(project))
392
- gates.append(social_card_gate(project))
393
- for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
481
+ if "social-card-audit" in capabilities:
482
+ gates.append(social_card_gate(project))
483
+ else:
484
+ gates.append({"name": "social-card-audit", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "social-card-audit is not declared by the release profile"}, "stderr": ""})
485
+ for name, command in build_gates(project, args.environment, args.target, capabilities):
394
486
  gates.append(run_gate(name, command, project))
395
- if args.analytics_release_gate:
487
+ if args.analytics_release_gate or "analytics-release-gate" in capabilities:
396
488
  evidence = {
397
489
  "--contract": args.analytics_contract,
398
490
  "--render-report": args.analytics_render_report,
@@ -407,8 +499,14 @@ def main() -> int:
407
499
  for name, value in evidence.items():
408
500
  analytics_command.extend([name, value])
409
501
  gates.append(run_gate("analytics-release-gate", analytics_command, project))
410
- gates.append(editorial_gate(project))
411
- gates.append(evidence_gate(project, args.environment))
502
+ if "editorial-review" in capabilities:
503
+ gates.append(editorial_gate(project))
504
+ else:
505
+ gates.append({"name": "editorial-review", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "editorial-review is not declared by the release profile"}, "stderr": ""})
506
+ if "durable-site-evidence" in capabilities:
507
+ gates.append(evidence_gate(project, args.environment))
508
+ else:
509
+ gates.append({"name": "durable-site-evidence", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "durable-site-evidence is not declared by the release profile"}, "stderr": ""})
412
510
  if args.base_url:
413
511
  gates.append(runtime_smoke(args.base_url))
414
512
 
@@ -418,6 +516,13 @@ def main() -> int:
418
516
  "project": str(project),
419
517
  "environment": args.environment,
420
518
  "target": args.target,
519
+ "profile": {
520
+ "path": profile["path"],
521
+ "name": profile.get("profile"),
522
+ "projectType": profile.get("projectType"),
523
+ "capabilities": profile.get("capabilities", []),
524
+ "valid": profile["valid"],
525
+ },
421
526
  "mutation": "not executed",
422
527
  "passed": all(gate["passed"] for gate in gates),
423
528
  "gates": gates,
@@ -0,0 +1,143 @@
1
+ """Detect whether a configured browser adapter can run Maggie audits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+
12
+ SCHEMA_VERSION = "maggie-browser-capability.v1"
13
+ REQUIRED_COMMANDS = ("goto", "viewport", "screenshot", "js", "eval")
14
+ INTERACTION_COMMANDS = ("click", "fill", "type", "press", "wait", "is")
15
+ HELP_TIMEOUT_SECONDS = 5
16
+
17
+
18
+ def _safe_name(value: object) -> str:
19
+ text = Path(str(value or "")).name
20
+ return text if text and text not in {".", ".."} else "unknown"
21
+
22
+
23
+ def _path_fingerprint(value: str) -> str:
24
+ return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
25
+
26
+
27
+ def _guidance(status: str) -> list[str]:
28
+ if status == "missing":
29
+ return [
30
+ "Install or enable an authorized Chrome/Playwright/gstack browser adapter.",
31
+ "Pass its executable path explicitly with --browse <adapter-path>.",
32
+ "If using Playwright locally, install the browser runtime with: npx playwright install chromium.",
33
+ ]
34
+ return [
35
+ "Pass the executable for the intended browser adapter, not a desktop URL/file opener.",
36
+ "The adapter must expose --help and support goto, viewport, screenshot, js, and eval.",
37
+ "If using gstack, pass the absolute path to gstack browse/dist/browse.",
38
+ ]
39
+
40
+
41
+ def discover_browser_adapter(requested: str | Path) -> dict:
42
+ """Return a redacted, actionable capability report without opening a page."""
43
+ requested_text = str(requested or "").strip()
44
+ requested_name = _safe_name(requested_text)
45
+ report = {
46
+ "schemaVersion": SCHEMA_VERSION,
47
+ "adapter": {
48
+ "requested": requested_name,
49
+ "requestedPathFingerprint": _path_fingerprint(requested_text) if requested_text else None,
50
+ },
51
+ "status": "missing",
52
+ "checks": {
53
+ "resolved": False,
54
+ "regularFile": False,
55
+ "executable": False,
56
+ "help": False,
57
+ "requiredCommands": False,
58
+ "interactionCommands": False,
59
+ },
60
+ "requiredCommands": list(REQUIRED_COMMANDS),
61
+ "fallback": _guidance("missing"),
62
+ }
63
+ if not requested_text:
64
+ report["errorCode"] = "adapter-not-found"
65
+ report["message"] = "No browser adapter was provided."
66
+ return report
67
+
68
+ resolved = shutil.which(requested_text)
69
+ if resolved is None:
70
+ candidate = Path(requested_text).expanduser()
71
+ if candidate.exists():
72
+ resolved = str(candidate.resolve())
73
+ if not resolved:
74
+ report["errorCode"] = "adapter-not-found"
75
+ report["message"] = "The requested browser adapter could not be resolved."
76
+ return report
77
+
78
+ path = Path(resolved)
79
+ report["checks"]["resolved"] = True
80
+ report["adapter"]["name"] = _safe_name(path)
81
+ report["adapter"]["resolvedPathFingerprint"] = _path_fingerprint(str(path.resolve()))
82
+ if not path.is_file():
83
+ report["errorCode"] = "adapter-not-file"
84
+ report["message"] = "The resolved browser adapter is not a regular executable file."
85
+ report["fallback"] = _guidance("incompatible")
86
+ report["status"] = "incompatible"
87
+ return report
88
+ report["checks"]["regularFile"] = True
89
+ if not os.access(path, os.X_OK):
90
+ report["errorCode"] = "adapter-not-executable"
91
+ report["message"] = "The resolved browser adapter is not executable."
92
+ report["fallback"] = _guidance("incompatible")
93
+ report["status"] = "incompatible"
94
+ return report
95
+ report["checks"]["executable"] = True
96
+ try:
97
+ completed = subprocess.run(
98
+ [str(path), "--help"],
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=HELP_TIMEOUT_SECONDS,
102
+ check=False,
103
+ )
104
+ except (OSError, subprocess.TimeoutExpired):
105
+ completed = None
106
+ help_text = "" if completed is None else (completed.stdout + "\n" + completed.stderr).lower()
107
+ if completed is None or completed.returncode != 0 or not help_text:
108
+ report["errorCode"] = "adapter-help-failed"
109
+ report["message"] = "The browser adapter did not provide a usable --help response."
110
+ report["fallback"] = _guidance("incompatible")
111
+ report["status"] = "incompatible"
112
+ return report
113
+ report["checks"]["help"] = True
114
+ missing = [command for command in REQUIRED_COMMANDS if command not in help_text]
115
+ report["missingCommands"] = missing
116
+ if missing:
117
+ report["errorCode"] = "adapter-command-contract-mismatch"
118
+ report["message"] = "The browser adapter does not expose Maggie's required command contract."
119
+ report["fallback"] = _guidance("incompatible")
120
+ report["status"] = "incompatible"
121
+ return report
122
+ report["checks"]["requiredCommands"] = True
123
+ report["checks"]["interactionCommands"] = all(command in help_text for command in INTERACTION_COMMANDS)
124
+ if not report["checks"]["interactionCommands"]:
125
+ report["errorCode"] = "adapter-interaction-contract-mismatch"
126
+ report["message"] = "The browser adapter can audit pages but cannot replay the interaction contract."
127
+ report["fallback"] = _guidance("incompatible")
128
+ report["status"] = "incompatible"
129
+ return report
130
+ report["status"] = "ready"
131
+ report["message"] = "Browser adapter capability preflight passed."
132
+ report["fallback"] = []
133
+ return report
134
+
135
+
136
+ def require_browser_adapter(requested: str | Path) -> dict:
137
+ report = discover_browser_adapter(requested)
138
+ if report["status"] != "ready":
139
+ code = report.get("errorCode", "adapter-unavailable")
140
+ message = report.get("message", "Browser adapter is unavailable.")
141
+ guidance = " ".join(report.get("fallback", []))
142
+ raise ValueError(f"{code}: {message} {guidance}".strip())
143
+ return report
@@ -0,0 +1,122 @@
1
+ """Validate bounded, hash-only evidence for an external provider write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+ from urllib.parse import urlsplit
8
+
9
+
10
+ SCHEMA = "maggie-external-write-readback.v1"
11
+ PROVIDERS = {"ga4", "gtm", "gsc", "google-ads", "firebase"}
12
+ STATUSES = {"created", "updated", "no-op"}
13
+ FINGERPRINT = re.compile(r"^sha256:[0-9a-f]{64}$")
14
+
15
+
16
+ def _safe_text(value: Any, field: str, errors: list[str], pattern: str | None = None) -> str | None:
17
+ if not isinstance(value, str) or not value.strip() or len(value) > 200 or "@" in value or re.search(r"(?:token|secret|password|BEGIN PRIVATE)", value, re.I):
18
+ errors.append(f"{field} must be a bounded redacted identifier")
19
+ return None
20
+ if pattern and not re.fullmatch(pattern, value):
21
+ errors.append(f"{field} has an invalid format")
22
+ return None
23
+ return value
24
+
25
+
26
+ def validate(value: Any) -> dict[str, Any]:
27
+ errors: list[str] = []
28
+ if not isinstance(value, dict):
29
+ return {"schemaVersion": "maggie-external-write-readback-result.v1", "passed": False, "errors": ["evidence must be an object"], "mutation": "not executed"}
30
+ if value.get("schemaVersion") != SCHEMA:
31
+ errors.append(f"schemaVersion must be {SCHEMA}")
32
+ if value.get("provider") not in PROVIDERS:
33
+ errors.append("provider is unsupported")
34
+ _safe_text(value.get("generatedAt"), "generatedAt", errors)
35
+ resource = _safe_text(value.get("resource"), "resource", errors)
36
+ _safe_text(value.get("operation"), "operation", errors, r"[a-z][a-z0-9_.-]{1,63}")
37
+ key = _safe_text(value.get("idempotencyKey"), "idempotencyKey", errors, r"[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}")
38
+
39
+ retry = value.get("retry")
40
+ if not isinstance(retry, dict) or set(retry) != {"attempt", "maxAttempts", "sameKeyOnRetry"}:
41
+ errors.append("retry must contain attempt, maxAttempts, and sameKeyOnRetry")
42
+ retry = {}
43
+ else:
44
+ attempt, maximum = retry.get("attempt"), retry.get("maxAttempts")
45
+ if not isinstance(attempt, int) or not 1 <= attempt <= 3:
46
+ errors.append("retry.attempt must be between 1 and 3")
47
+ if not isinstance(maximum, int) or not 1 <= maximum <= 3 or (isinstance(attempt, int) and isinstance(maximum, int) and attempt > maximum):
48
+ errors.append("retry.maxAttempts must be between attempt and 3")
49
+ if retry.get("sameKeyOnRetry") is not True:
50
+ errors.append("retry.sameKeyOnRetry must be true")
51
+
52
+ mutation = value.get("mutation")
53
+ if not isinstance(mutation, dict) or set(mutation) != {"status", "statusCode", "explicitConfirmation"}:
54
+ errors.append("mutation must contain status, statusCode, and explicitConfirmation")
55
+ mutation = {}
56
+ else:
57
+ if mutation.get("status") not in STATUSES:
58
+ errors.append("mutation.status is unsupported")
59
+ if not isinstance(mutation.get("statusCode"), int) or not 200 <= mutation["statusCode"] <= 299:
60
+ errors.append("mutation.statusCode must be a successful HTTP status")
61
+ if mutation.get("explicitConfirmation") is not True:
62
+ errors.append("mutation requires explicit confirmation")
63
+ if value.get("mutationExecuted") is not True:
64
+ errors.append("mutationExecuted must be true")
65
+
66
+ readback = value.get("readback")
67
+ safe_checks: list[dict[str, Any]] = []
68
+ if not isinstance(readback, dict) or set(readback) != {"passed", "endpoint", "checks"}:
69
+ errors.append("readback must contain passed, endpoint, and checks")
70
+ readback = {}
71
+ else:
72
+ if readback.get("passed") is not True:
73
+ errors.append("readback.passed must be true")
74
+ endpoint = readback.get("endpoint")
75
+ parsed = urlsplit(endpoint) if isinstance(endpoint, str) else None
76
+ if not parsed or parsed.scheme != "https" or not parsed.netloc or parsed.query or parsed.fragment or parsed.username or parsed.password:
77
+ errors.append("readback.endpoint must be an HTTPS URL without credentials/query")
78
+ checks = readback.get("checks")
79
+ if not isinstance(checks, list) or not checks:
80
+ errors.append("readback.checks must be a non-empty array")
81
+ checks = []
82
+ for index, check in enumerate(checks):
83
+ prefix = f"readback.checks[{index}]"
84
+ if not isinstance(check, dict) or set(check) != {"name", "expectedFingerprint", "observedFingerprint", "passed"}:
85
+ errors.append(f"{prefix} must contain name and hash-only invariant fields")
86
+ continue
87
+ name = _safe_text(check.get("name"), f"{prefix}.name", errors)
88
+ expected = check.get("expectedFingerprint")
89
+ observed = check.get("observedFingerprint")
90
+ if not isinstance(expected, str) or not FINGERPRINT.fullmatch(expected):
91
+ errors.append(f"{prefix}.expectedFingerprint must be sha256")
92
+ if not isinstance(observed, str) or not FINGERPRINT.fullmatch(observed):
93
+ errors.append(f"{prefix}.observedFingerprint must be sha256")
94
+ if check.get("passed") is not True or expected != observed:
95
+ errors.append(f"{prefix} does not match its expected readback")
96
+ safe_checks.append({"name": name, "passed": check.get("passed") is True})
97
+
98
+ duplicates = value.get("duplicates")
99
+ orphaned = value.get("orphaned")
100
+ if not isinstance(duplicates, list) or duplicates:
101
+ errors.append("duplicates must be an empty array after reconciliation")
102
+ if not isinstance(orphaned, list) or orphaned:
103
+ errors.append("orphaned must be an empty array after cleanup")
104
+ cleanup = value.get("cleanup")
105
+ if not isinstance(cleanup, dict) or set(cleanup) != {"status", "checked"} or cleanup.get("status") not in {"not-needed", "passed"} or cleanup.get("checked") is not True:
106
+ errors.append("cleanup must be checked and not-needed or passed")
107
+
108
+ return {
109
+ "schemaVersion": "maggie-external-write-readback-result.v1",
110
+ "passed": not errors,
111
+ "errors": sorted(set(errors)),
112
+ "provider": value.get("provider"),
113
+ "resource": resource,
114
+ "operation": value.get("operation"),
115
+ "idempotencyKey": key,
116
+ "retry": {"attempt": retry.get("attempt"), "maxAttempts": retry.get("maxAttempts"), "sameKeyOnRetry": retry.get("sameKeyOnRetry") is True},
117
+ "readback": {"passed": readback.get("passed") is True, "checks": safe_checks} if isinstance(readback, dict) else {"passed": False, "checks": []},
118
+ "duplicates": [],
119
+ "orphaned": [],
120
+ "cleanup": cleanup if isinstance(cleanup, dict) else {},
121
+ "mutation": "not executed",
122
+ }
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from urllib.parse import urlsplit
8
8
 
9
9
 
10
- SCHEMA_VERSION = "maggie-google-capability-report.v1"
10
+ SCHEMA_VERSION = "maggie-google-capability-report.v2"
11
11
  CAPABILITY_NAMES = ("read", "report", "edit", "publish")
12
12
  CAPABILITY_STATES = {"verified", "not_tested", "not_available", "blocked"}
13
13
  PROVIDERS = {"gsc", "ga4", "gtm", "google-ads", "firebase"}
@@ -112,6 +112,34 @@ def _write_test(value: Any, field: str, errors: list[str]) -> bool:
112
112
  return valid and not unknown
113
113
 
114
114
 
115
+ def _verified_reference(value: Any, field: str, errors: list[str]) -> dict[str, Any]:
116
+ if not isinstance(value, dict) or set(value) != {"id", "verified"}:
117
+ errors.append(f"{field} must contain only id and verified")
118
+ return {"id": None, "verified": False}
119
+ identifier = value.get("id")
120
+ if not isinstance(identifier, str) or not identifier.strip() or len(identifier) > 200 or re.search(r"[@\s]", identifier):
121
+ errors.append(f"{field}.id must be a redacted non-email identifier")
122
+ if value.get("verified") is not True:
123
+ errors.append(f"{field}.verified must be true")
124
+ return {"id": identifier if isinstance(identifier, str) else None, "verified": value.get("verified") is True}
125
+
126
+
127
+ def _target_reference(value: Any, resource: str, field: str, errors: list[str]) -> dict[str, Any]:
128
+ if not isinstance(value, dict) or set(value) != {"id", "kind", "verified"}:
129
+ errors.append(f"{field} must contain only id, kind, and verified")
130
+ return {"id": None, "kind": None, "verified": False}
131
+ identifier = value.get("id")
132
+ if not isinstance(identifier, str) or not identifier.strip() or len(identifier) > 200 or re.search(r"[@\s]", identifier):
133
+ errors.append(f"{field}.id must be a redacted non-email identifier")
134
+ if identifier != resource:
135
+ errors.append(f"{field}.id must equal the selected resource")
136
+ if value.get("kind") not in {"property", "container", "customer", "project", "site", "account"}:
137
+ errors.append(f"{field}.kind is unsupported")
138
+ if value.get("verified") is not True:
139
+ errors.append(f"{field}.verified must be true")
140
+ return {"id": identifier if isinstance(identifier, str) else None, "kind": value.get("kind"), "verified": value.get("verified") is True}
141
+
142
+
115
143
  def _required_scope(provider: str, capability: str) -> str | None:
116
144
  if capability == "read" or capability == "report":
117
145
  return READ_SCOPES[provider]
@@ -124,9 +152,9 @@ def validate_report(report: Any) -> dict[str, Any]:
124
152
  """Return safe validation output; never returns arbitrary input fields."""
125
153
  errors: list[str] = []
126
154
  if not isinstance(report, dict):
127
- return {"schemaVersion": "maggie-google-capability-report-result.v1", "passed": False, "errors": ["report must be an object"], "providerResults": []}
155
+ return {"schemaVersion": "maggie-google-capability-report-result.v2", "passed": False, "errors": ["report must be an object"], "providerResults": []}
128
156
  if report.get("schemaVersion") != SCHEMA_VERSION:
129
- errors.append("schemaVersion must be maggie-google-capability-report.v1")
157
+ errors.append("schemaVersion must be maggie-google-capability-report.v2; v1 reports lack target guard evidence")
130
158
  if not _string(report.get("generatedAt"), "generatedAt", errors):
131
159
  pass
132
160
  if report.get("mutationsAllowed") is not False:
@@ -143,7 +171,7 @@ def validate_report(report: Any) -> dict[str, Any]:
143
171
  if not isinstance(row, dict):
144
172
  errors.append(f"{prefix} must be an object")
145
173
  continue
146
- required = {"provider", "resource", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
174
+ required = {"provider", "resource", "activeAccount", "target", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
147
175
  unknown = sorted(set(row) - required)
148
176
  if unknown:
149
177
  errors.append(f"{prefix} contains unsupported fields")
@@ -154,6 +182,8 @@ def validate_report(report: Any) -> dict[str, Any]:
154
182
  continue
155
183
  if not _string(resource, f"{prefix}.resource", errors, max_length=200):
156
184
  continue
185
+ active_account = _verified_reference(row.get("activeAccount"), f"{prefix}.activeAccount", errors)
186
+ target = _target_reference(row.get("target"), resource, f"{prefix}.target", errors)
157
187
  key = (provider, resource)
158
188
  if key in seen:
159
189
  errors.append(f"{prefix} duplicates provider/resource")
@@ -226,6 +256,8 @@ def validate_report(report: Any) -> dict[str, Any]:
226
256
  normalized.append({
227
257
  "provider": provider,
228
258
  "resource": resource,
259
+ "activeAccount": active_account,
260
+ "target": target,
229
261
  "authMode": row.get("authMode"),
230
262
  "scopes": sorted(scopes),
231
263
  "productRole": row.get("productRole"),
@@ -241,7 +273,7 @@ def validate_report(report: Any) -> dict[str, Any]:
241
273
  })
242
274
 
243
275
  return {
244
- "schemaVersion": "maggie-google-capability-report-result.v1",
276
+ "schemaVersion": "maggie-google-capability-report-result.v2",
245
277
  "passed": not errors,
246
278
  "errors": sorted(set(errors)),
247
279
  "providerResults": normalized,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.44",
3
+ "version": "0.7.46",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,23 @@ The browser capability must support:
14
14
  - slow scrolling, click, hover, keyboard focus, and back/forward navigation;
15
15
  - reading computed CSS, media sources, links, and visible accessibility labels.
16
16
 
17
+ Before navigation, Maggie runs a capability preflight against the configured
18
+ adapter. The report is `maggie-browser-capability.v1` and classifies the
19
+ adapter as `ready`, `missing`, or `incompatible`. This prevents a system
20
+ `browse`/`xdg-open` desktop opener from being mistaken for a browser adapter:
21
+
22
+ ```bash
23
+ maggie browser-audit https://example.com \
24
+ --browse "$HOME/.codex/skills/gstack/browse/dist/browse" \
25
+ --output .maggie/browser-audit --required body --check-browser
26
+ ```
27
+
28
+ The report contains only the adapter name, path fingerprints, capability
29
+ checks, and bounded installation/fallback guidance. If it is `missing` or
30
+ `incompatible`, install or enable an authorized Chrome/Playwright/gstack
31
+ adapter and pass its executable path. Maggie does not silently install a
32
+ browser runtime or use a desktop opener as a fallback.
33
+
17
34
  The minimum extraction result for a target is:
18
35
 
19
36
  ```text
@@ -111,8 +111,13 @@ maggie ops google-capabilities \
111
111
  ```
112
112
 
113
113
  The report contract is
114
- [`capability-report-v1.json`](../bundled-contracts/google-integrations/capability-report-v1.json).
114
+ [`capability-report-v2.schema.json`](../bundled-contracts/google-integrations/capability-report-v2.schema.json).
115
115
  The CLI writes a normalized result and never copies arbitrary input fields.
116
+ Every provider row must also identify the redacted active account and selected
117
+ target resource, both with `verified: true`; the target ID must equal the
118
+ selected `resource`. A report that only proves account discovery, or comes from
119
+ the wrong browser account/property, is rejected before a write workflow can use
120
+ it.
116
121
 
117
122
  ## Capability matrix
118
123
 
@@ -121,6 +126,8 @@ Every provider/resource row must expose this shape:
121
126
  | Field | Meaning |
122
127
  |---|---|
123
128
  | `provider` / `resource` | The Google product and scoped resource being checked |
129
+ | `activeAccount` | Redacted account/principal reference confirmed by the current auth session |
130
+ | `target` | Resource ID and kind confirmed in the current account; must match `resource` |
124
131
  | `authMode` | `desktop-oauth`, `service-account-impersonation`, or `none` |
125
132
  | `scopes` | Exact allowlisted OAuth scopes, never token values |
126
133
  | `productRole` | Role granted in the product, distinct from Cloud IAM |