@topy-ai/maggie 0.7.49 → 0.7.51
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.
- package/README-zh-TW.md +3 -3
- package/README.md +58 -8
- package/bin/maggie.js +33 -14
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
- package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
- package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
- package/bundled-skills/catalog.json +8 -0
- package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
- package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
- package/bundled-skills/maggie-deployment/SKILL.md +13 -0
- package/bundled-skills/maggie-design/SKILL.md +55 -21
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
- package/bundled-skills/maggie-feedback/SKILL.md +20 -3
- package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +122 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
- package/bundled-tools/clis/maggie_app_collect.py +236 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
- package/bundled-tools/clis/maggie_bridge_host.py +212 -0
- package/bundled-tools/clis/maggie_design.py +190 -29
- package/bundled-tools/clis/maggie_feedback.py +89 -37
- package/bundled-tools/clis/maggie_mobile.py +221 -0
- package/bundled-tools/clis/maggie_product_release.py +303 -0
- package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
- package/bundled-tools/runtime/assistant_approval.py +111 -0
- package/bundled-tools/runtime/cdp_client.py +246 -0
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Skill catalog tooling for any Agent Skills collection (``maggie skills``).
|
|
3
|
+
|
|
4
|
+
Maggie validates its own skills with ``scripts/validate_skills.py``; a product
|
|
5
|
+
that ships its own catalog (tens or hundreds of skills) needs the same checks
|
|
6
|
+
plus packaging for Claude Code plugin marketplaces:
|
|
7
|
+
|
|
8
|
+
- ``scaffold``: a new ``<root>/<name>/SKILL.md`` with valid front matter;
|
|
9
|
+
- ``validate``: front matter (``name`` = folder, lower-case, at most 64
|
|
10
|
+
characters; ``description`` at most 1024), duplicate names, unfinished
|
|
11
|
+
placeholders, relative links, optional pinned references (``references``
|
|
12
|
+
entries with a 40-character commit) and an optional catalog index;
|
|
13
|
+
- ``package``: category plugins plus one all-in-one plugin, each with
|
|
14
|
+
``.claude-plugin/plugin.json``, and a ``.claude-plugin/marketplace.json``;
|
|
15
|
+
- ``smoke``: re-validates a packaged marketplace (names, versions, sources,
|
|
16
|
+
every packaged skill) and runs ``claude plugin validate`` when available.
|
|
17
|
+
|
|
18
|
+
Skill bodies are copied as-is; nothing is published.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
32
|
+
LINK_RE = re.compile(r"\]\(([^)#\s]+)(?:#[^)]*)?\)")
|
|
33
|
+
PLACEHOLDER_RE = re.compile(r"\bTODO\b|replace-me|lorem ipsum", re.I)
|
|
34
|
+
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
35
|
+
IGNORED = {".git", "node_modules", "dist", "build"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_front_matter(text: str) -> tuple[dict, str]:
|
|
39
|
+
"""Parse a small YAML front matter subset: scalars, inline lists, one nesting level."""
|
|
40
|
+
if not text.startswith("---\n"):
|
|
41
|
+
raise ValueError("missing YAML front matter")
|
|
42
|
+
end = text.find("\n---", 4)
|
|
43
|
+
if end < 0:
|
|
44
|
+
raise ValueError("front matter is not closed")
|
|
45
|
+
data: dict = {}
|
|
46
|
+
current: str | None = None
|
|
47
|
+
for raw in text[4:end].splitlines():
|
|
48
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
49
|
+
continue
|
|
50
|
+
if raw.startswith((" ", "\t")) and current:
|
|
51
|
+
stripped = raw.strip()
|
|
52
|
+
if stripped.startswith("- "):
|
|
53
|
+
data.setdefault(current, [])
|
|
54
|
+
if isinstance(data[current], list):
|
|
55
|
+
data[current].append(_scalar(stripped[2:]))
|
|
56
|
+
elif ":" in stripped:
|
|
57
|
+
key, _, value = stripped.partition(":")
|
|
58
|
+
if not isinstance(data.get(current), dict):
|
|
59
|
+
data[current] = {}
|
|
60
|
+
data[current][key.strip()] = _scalar(value)
|
|
61
|
+
continue
|
|
62
|
+
key, sep, value = raw.partition(":")
|
|
63
|
+
if not sep:
|
|
64
|
+
raise ValueError(f"invalid front matter line: {raw[:60]}")
|
|
65
|
+
current = key.strip()
|
|
66
|
+
data[current] = _scalar(value) if value.strip() else None
|
|
67
|
+
return data, text[end + 4:]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _scalar(value: str) -> object:
|
|
71
|
+
value = value.strip()
|
|
72
|
+
if value.startswith("[") and value.endswith("]"):
|
|
73
|
+
return [_scalar(item) for item in value[1:-1].split(",") if item.strip()]
|
|
74
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
75
|
+
return value[1:-1]
|
|
76
|
+
if value in {"true", "false"}:
|
|
77
|
+
return value == "true"
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_skills(root: Path) -> list[Path]:
|
|
82
|
+
return sorted(path for path in root.rglob("SKILL.md") if not any(part in IGNORED for part in path.relative_to(root).parts))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate(root: Path, require_pinned: bool, catalog: Path | None) -> dict:
|
|
86
|
+
root = root.resolve()
|
|
87
|
+
errors: list[str] = []
|
|
88
|
+
warnings: list[str] = []
|
|
89
|
+
seen: dict[str, str] = {}
|
|
90
|
+
skills = find_skills(root)
|
|
91
|
+
if not skills:
|
|
92
|
+
errors.append("no SKILL.md files found")
|
|
93
|
+
records = []
|
|
94
|
+
for path in skills:
|
|
95
|
+
rel = str(path.relative_to(root))
|
|
96
|
+
text = path.read_text(encoding="utf-8")
|
|
97
|
+
try:
|
|
98
|
+
meta, body = parse_front_matter(text)
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
errors.append(f"{rel}: {error}"); continue
|
|
101
|
+
name = str(meta.get("name") or "")
|
|
102
|
+
description = str(meta.get("description") or "")
|
|
103
|
+
if not NAME_RE.match(name) or len(name) > 64:
|
|
104
|
+
errors.append(f"{rel}: name must be lower-case letters, digits and hyphens, at most 64 characters")
|
|
105
|
+
if name != path.parent.name:
|
|
106
|
+
errors.append(f"{rel}: name {name!r} must equal its folder {path.parent.name!r}")
|
|
107
|
+
if name in seen:
|
|
108
|
+
errors.append(f"{rel}: duplicate name {name!r} (also {seen[name]})")
|
|
109
|
+
seen[name] = rel
|
|
110
|
+
if not description.strip():
|
|
111
|
+
errors.append(f"{rel}: description is required")
|
|
112
|
+
elif len(description) > 1024:
|
|
113
|
+
errors.append(f"{rel}: description is {len(description)} characters; the limit is 1024")
|
|
114
|
+
elif len(description) < 40:
|
|
115
|
+
warnings.append(f"{rel}: description is very short; say what it does and when to use it")
|
|
116
|
+
if re.search(r"<[^>]+>", description):
|
|
117
|
+
errors.append(f"{rel}: description must not contain XML/HTML tags")
|
|
118
|
+
if PLACEHOLDER_RE.search(text):
|
|
119
|
+
errors.append(f"{rel}: unfinished placeholder")
|
|
120
|
+
for target in LINK_RE.findall(body):
|
|
121
|
+
if target.startswith(("http://", "https://", "mailto:")):
|
|
122
|
+
continue
|
|
123
|
+
if not (path.parent / target).resolve().exists():
|
|
124
|
+
errors.append(f"{rel}: broken link {target}")
|
|
125
|
+
references = meta.get("references")
|
|
126
|
+
if require_pinned:
|
|
127
|
+
for item in references if isinstance(references, list) else []:
|
|
128
|
+
commit = item.get("commit") if isinstance(item, dict) else None
|
|
129
|
+
if not commit or not COMMIT_RE.match(str(commit)):
|
|
130
|
+
errors.append(f"{rel}: reference is not pinned to a 40-character commit")
|
|
131
|
+
if len(body.splitlines()) > 500:
|
|
132
|
+
warnings.append(f"{rel}: body is over 500 lines; move detail into references/ files")
|
|
133
|
+
records.append({"name": name, "path": rel, "category": (meta.get("metadata") or {}).get("category") if isinstance(meta.get("metadata"), dict) else meta.get("category"), "version": (meta.get("metadata") or {}).get("version") if isinstance(meta.get("metadata"), dict) else meta.get("version")})
|
|
134
|
+
if catalog:
|
|
135
|
+
index = json.loads(catalog.read_text(encoding="utf-8"))
|
|
136
|
+
entries = index.get("skills", index if isinstance(index, list) else [])
|
|
137
|
+
listed = {str(item.get("id") or item.get("name")) for item in entries if isinstance(item, dict)}
|
|
138
|
+
for name in sorted(set(seen) - listed):
|
|
139
|
+
errors.append(f"catalog: {name} has a SKILL.md but no catalog entry")
|
|
140
|
+
for name in sorted(listed - set(seen)):
|
|
141
|
+
errors.append(f"catalog: {name} is listed but has no SKILL.md")
|
|
142
|
+
return {"schemaVersion": "maggie-skill-catalog-validation.v1", "passed": not errors, "skills": len(records), "records": records, "errors": errors, "warnings": warnings}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def scaffold(root: Path, name: str, description: str, category: str | None) -> Path:
|
|
146
|
+
if not NAME_RE.match(name) or len(name) > 64:
|
|
147
|
+
raise ValueError("name must be lower-case letters, digits and hyphens, at most 64 characters")
|
|
148
|
+
if not description.strip() or len(description) > 1024:
|
|
149
|
+
raise ValueError("description is required and at most 1024 characters")
|
|
150
|
+
target = root / (category or "") / name / "SKILL.md"
|
|
151
|
+
if target.exists():
|
|
152
|
+
raise ValueError(f"{target} already exists")
|
|
153
|
+
target.parent.mkdir(parents=True)
|
|
154
|
+
meta = f"---\nname: {name}\ndescription: {json.dumps(description, ensure_ascii=False)}\n"
|
|
155
|
+
if category:
|
|
156
|
+
meta += f"metadata:\n category: {category}\n version: 0.1.0\n"
|
|
157
|
+
title = name.replace("-", " ").title()
|
|
158
|
+
target.write_text(meta + f"---\n\n# {title}\n\n## When to use\n\n## Inputs\n\n## Steps\n\n## Output\n\n## Safety and limits\n", encoding="utf-8")
|
|
159
|
+
return target
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def package(root: Path, out: Path, prefix: str, owner: str, version: str, all_name: str | None, force: bool) -> dict:
|
|
163
|
+
report = validate(root, False, None)
|
|
164
|
+
if not report["passed"]:
|
|
165
|
+
raise ValueError("fix validation errors before packaging: " + "; ".join(report["errors"][:5]))
|
|
166
|
+
if not NAME_RE.match(prefix):
|
|
167
|
+
raise ValueError("--prefix must be lower-case kebab-case")
|
|
168
|
+
if out.exists():
|
|
169
|
+
if not force:
|
|
170
|
+
raise ValueError(f"{out} exists; pass --force to replace it")
|
|
171
|
+
shutil.rmtree(out)
|
|
172
|
+
root = root.resolve()
|
|
173
|
+
groups: dict[str, list[dict]] = {}
|
|
174
|
+
for record in report["records"]:
|
|
175
|
+
category = record["category"] or (Path(record["path"]).parts[0] if len(Path(record["path"]).parts) > 2 else "core")
|
|
176
|
+
groups.setdefault(re.sub(r"[^a-z0-9-]+", "-", str(category).lower()).strip("-") or "core", []).append(record)
|
|
177
|
+
plugins = []
|
|
178
|
+
|
|
179
|
+
def write_plugin(plugin_name: str, records: list[dict], description: str) -> None:
|
|
180
|
+
folder = out / "plugins" / plugin_name
|
|
181
|
+
(folder / ".claude-plugin").mkdir(parents=True)
|
|
182
|
+
for record in records:
|
|
183
|
+
shutil.copytree((root / record["path"]).parent, folder / "skills" / record["name"])
|
|
184
|
+
(folder / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": plugin_name, "description": description, "version": version, "author": {"name": owner}}, indent=2) + "\n", encoding="utf-8")
|
|
185
|
+
plugins.append({"name": plugin_name, "source": f"./plugins/{plugin_name}", "description": description, "version": version, "skills": len(records)})
|
|
186
|
+
|
|
187
|
+
for category, records in sorted(groups.items()):
|
|
188
|
+
write_plugin(f"{prefix}-{category}", records, f"{len(records)} {category} skills from {owner}")
|
|
189
|
+
if all_name is not False:
|
|
190
|
+
write_plugin(all_name or f"{prefix}-all", report["records"], f"All {len(report['records'])} skills from {owner} in one plugin")
|
|
191
|
+
marketplace = {"name": prefix, "owner": {"name": owner}, "description": f"{report['skills']} skills from {owner}, by category or all in one", "plugins": [{key: value for key, value in plugin.items() if key != "skills"} for plugin in plugins]}
|
|
192
|
+
(out / ".claude-plugin").mkdir(parents=True, exist_ok=True)
|
|
193
|
+
(out / ".claude-plugin" / "marketplace.json").write_text(json.dumps(marketplace, indent=2) + "\n", encoding="utf-8")
|
|
194
|
+
return {"schemaVersion": "maggie-skill-catalog-package.v1", "passed": True, "output": str(out), "plugins": plugins, "skills": report["skills"], "published": False}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def smoke(out: Path, use_claude: bool) -> dict:
|
|
198
|
+
errors: list[str] = []
|
|
199
|
+
checks: list[dict] = []
|
|
200
|
+
marketplace_path = out / ".claude-plugin" / "marketplace.json"
|
|
201
|
+
if not marketplace_path.is_file():
|
|
202
|
+
raise ValueError("no .claude-plugin/marketplace.json in the output folder")
|
|
203
|
+
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
|
|
204
|
+
if not marketplace.get("name") or not isinstance(marketplace.get("owner"), dict):
|
|
205
|
+
errors.append("marketplace.json needs name and owner")
|
|
206
|
+
names = [plugin.get("name") for plugin in marketplace.get("plugins", [])]
|
|
207
|
+
if len(names) != len(set(names)):
|
|
208
|
+
errors.append("marketplace.json lists a plugin twice")
|
|
209
|
+
for entry in marketplace.get("plugins", []):
|
|
210
|
+
folder = (out / str(entry.get("source", ""))).resolve()
|
|
211
|
+
manifest = folder / ".claude-plugin" / "plugin.json"
|
|
212
|
+
if not manifest.is_file():
|
|
213
|
+
errors.append(f"{entry.get('name')}: source {entry.get('source')} has no .claude-plugin/plugin.json"); continue
|
|
214
|
+
plugin = json.loads(manifest.read_text(encoding="utf-8"))
|
|
215
|
+
if plugin.get("name") != entry.get("name"):
|
|
216
|
+
errors.append(f"{entry.get('name')}: plugin.json name is {plugin.get('name')!r}")
|
|
217
|
+
if entry.get("version") and plugin.get("version") != entry.get("version"):
|
|
218
|
+
errors.append(f"{entry.get('name')}: plugin.json version {plugin.get('version')} differs from marketplace {entry.get('version')}")
|
|
219
|
+
packaged = validate(folder / "skills", False, None) if (folder / "skills").is_dir() else {"passed": False, "errors": ["no skills folder"], "skills": 0}
|
|
220
|
+
if not packaged["passed"]:
|
|
221
|
+
errors.extend(f"{entry.get('name')}: {error}" for error in packaged["errors"][:5])
|
|
222
|
+
checks.append({"plugin": entry.get("name"), "skills": packaged["skills"]})
|
|
223
|
+
claude = shutil.which("claude") if use_claude else None
|
|
224
|
+
claude_result = None
|
|
225
|
+
if use_claude and not claude:
|
|
226
|
+
errors.append("--claude was requested but the claude CLI is not on PATH")
|
|
227
|
+
elif claude:
|
|
228
|
+
run = subprocess.run([claude, "plugin", "validate", str(out)], capture_output=True, text=True, check=False, timeout=120)
|
|
229
|
+
claude_result = {"exitCode": run.returncode, "output": (run.stdout + run.stderr)[-600:]}
|
|
230
|
+
if run.returncode:
|
|
231
|
+
errors.append("claude plugin validate failed")
|
|
232
|
+
return {"schemaVersion": "maggie-skill-catalog-smoke.v1", "passed": not errors, "plugins": checks, "claudeValidate": claude_result, "errors": errors}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv: list[str] | None = None) -> int:
|
|
236
|
+
parser = argparse.ArgumentParser(prog="maggie skills", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
237
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
238
|
+
new = sub.add_parser("scaffold"); new.add_argument("--root", type=Path, required=True); new.add_argument("--name", required=True); new.add_argument("--description", required=True); new.add_argument("--category")
|
|
239
|
+
check = sub.add_parser("validate"); check.add_argument("--root", type=Path, required=True); check.add_argument("--require-pinned", action="store_true", help="references must pin a 40-character commit"); check.add_argument("--catalog", type=Path, help="catalog index JSON with skills[].id or .name")
|
|
240
|
+
pack = sub.add_parser("package"); pack.add_argument("--root", type=Path, required=True); pack.add_argument("--out", type=Path, required=True); pack.add_argument("--prefix", required=True, help="plugin name prefix and marketplace name"); pack.add_argument("--owner", required=True); pack.add_argument("--version", default="0.1.0"); pack.add_argument("--all-name", help="name of the all-in-one plugin (default <prefix>-all)"); pack.add_argument("--no-all", action="store_true"); pack.add_argument("--force", action="store_true")
|
|
241
|
+
test = sub.add_parser("smoke"); test.add_argument("--out", type=Path, required=True); test.add_argument("--claude", action="store_true", help="also run `claude plugin validate`")
|
|
242
|
+
args = parser.parse_args(argv)
|
|
243
|
+
try:
|
|
244
|
+
if args.command == "scaffold":
|
|
245
|
+
path = scaffold(args.root, args.name, args.description, args.category)
|
|
246
|
+
report = {"schemaVersion": "maggie-skill-scaffold.v1", "passed": True, "created": str(path)}
|
|
247
|
+
elif args.command == "validate":
|
|
248
|
+
report = validate(args.root, args.require_pinned, args.catalog)
|
|
249
|
+
report.pop("records", None)
|
|
250
|
+
elif args.command == "package":
|
|
251
|
+
report = package(args.root, args.out, args.prefix, args.owner, args.version, False if args.no_all else args.all_name, args.force)
|
|
252
|
+
else:
|
|
253
|
+
report = smoke(args.out, args.claude)
|
|
254
|
+
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
|
255
|
+
print(f"BLOCKED: maggie skills {args.command}: {error}", file=sys.stderr)
|
|
256
|
+
return 2
|
|
257
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
258
|
+
return 0 if report["passed"] else 1
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Reference implementation of signed one-time approval tokens for assistant writes.
|
|
2
|
+
|
|
3
|
+
An assistant (in-app agent, MCP server, ChatGPT/Claude app) may only run a
|
|
4
|
+
write after the person approves the exact call. The token binds:
|
|
5
|
+
|
|
6
|
+
- ``id``: a random approval ID (also the audit ``actionId`` and the root of
|
|
7
|
+
each write's idempotency key);
|
|
8
|
+
- ``calls``: the exact tool names and canonical arguments that were shown;
|
|
9
|
+
- ``principal``: a hash of the caller identity (never the raw key);
|
|
10
|
+
- ``scope``: ``account`` or the workspace/site the call acts on;
|
|
11
|
+
- ``expires``: a short TTL (10 minutes by default).
|
|
12
|
+
|
|
13
|
+
Format: ``base64url(canonical JSON) + "." + base64url(HMAC-SHA256)``.
|
|
14
|
+
|
|
15
|
+
Single use is enforced by the server, not the client: the in-app agent stores
|
|
16
|
+
the token with the model turn that proposed the write, the client answers
|
|
17
|
+
only yes/no, and the server consumes the stored token on the first approval.
|
|
18
|
+
A client that echoes ``{approved: true}`` without a token must still work —
|
|
19
|
+
returning the token to the client and expecting it back is the bug that made
|
|
20
|
+
every approval look "expired".
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import hashlib
|
|
27
|
+
import hmac
|
|
28
|
+
import json
|
|
29
|
+
import secrets
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
DEFAULT_TTL_SECONDS = 600
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApprovalError(ValueError):
|
|
36
|
+
"""Raised when a token is malformed, tampered, expired, reused or mismatched."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def canonical(value: object) -> str:
|
|
40
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _b64(data: bytes) -> str:
|
|
44
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _unb64(text: str) -> bytes:
|
|
48
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def principal_hash(identity: str) -> str:
|
|
52
|
+
return hashlib.sha256(("maggie-principal:" + identity).encode("utf-8")).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def issue(secret: bytes, identity: str, scope: str, calls: list[dict], ttl: int = DEFAULT_TTL_SECONDS, now: float | None = None) -> tuple[str, dict]:
|
|
56
|
+
if not calls or not all(isinstance(call, dict) and call.get("name") for call in calls):
|
|
57
|
+
raise ApprovalError("an approval needs at least one named call")
|
|
58
|
+
approval = {
|
|
59
|
+
"id": secrets.token_hex(16),
|
|
60
|
+
"calls": [{"name": call["name"], "args": call.get("args", {})} for call in calls],
|
|
61
|
+
"principal": principal_hash(identity),
|
|
62
|
+
"scope": scope,
|
|
63
|
+
"expires": int((now if now is not None else time.time()) + ttl),
|
|
64
|
+
}
|
|
65
|
+
body = canonical(approval).encode("utf-8")
|
|
66
|
+
signature = hmac.new(secret, body, hashlib.sha256).digest()
|
|
67
|
+
return _b64(body) + "." + _b64(signature), approval
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verify(secret: bytes, token: str, identity: str, scope: str, calls: list[dict], now: float | None = None) -> dict:
|
|
71
|
+
"""Check signature, expiry, principal, scope and the exact calls; does not consume."""
|
|
72
|
+
try:
|
|
73
|
+
body_text, signature_text = token.split(".", 1)
|
|
74
|
+
body, signature = _unb64(body_text), _unb64(signature_text)
|
|
75
|
+
except (ValueError, TypeError) as error:
|
|
76
|
+
raise ApprovalError("approval token is malformed") from error
|
|
77
|
+
if not hmac.compare_digest(hmac.new(secret, body, hashlib.sha256).digest(), signature):
|
|
78
|
+
raise ApprovalError("approval token signature does not match")
|
|
79
|
+
approval = json.loads(body)
|
|
80
|
+
if approval.get("expires", 0) < (now if now is not None else time.time()):
|
|
81
|
+
raise ApprovalError("approval expired; ask again")
|
|
82
|
+
if not hmac.compare_digest(approval.get("principal", ""), principal_hash(identity)):
|
|
83
|
+
raise ApprovalError("approval belongs to another caller")
|
|
84
|
+
if approval.get("scope") != scope:
|
|
85
|
+
raise ApprovalError("approval was issued for another scope")
|
|
86
|
+
expected = [{"name": call["name"], "args": call.get("args", {})} for call in calls]
|
|
87
|
+
if canonical(approval.get("calls")) != canonical(expected):
|
|
88
|
+
raise ApprovalError("approved calls differ from the calls being run")
|
|
89
|
+
return approval
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def idempotency_key(approval_id: str, call: dict) -> str:
|
|
93
|
+
return hashlib.sha256(f"{approval_id}:{canonical(call)}".encode("utf-8")).hexdigest()[:48]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TurnStore:
|
|
97
|
+
"""In-memory model of the server-side store: the token lives with the model turn."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self.turns: dict[str, str] = {}
|
|
101
|
+
|
|
102
|
+
def propose(self, turn_id: str, token: str) -> None:
|
|
103
|
+
self.turns[turn_id] = token
|
|
104
|
+
|
|
105
|
+
def consume(self, turn_id: str, client_token: str | None = None) -> str:
|
|
106
|
+
"""Return the token to verify (client-supplied or stored) and delete it: single use."""
|
|
107
|
+
stored = self.turns.pop(turn_id, None)
|
|
108
|
+
token = client_token or stored
|
|
109
|
+
if not token:
|
|
110
|
+
raise ApprovalError("no pending approval for this turn (already used or never proposed)")
|
|
111
|
+
return token
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Minimal Chrome DevTools Protocol client using only the standard library.
|
|
2
|
+
|
|
3
|
+
Maggie collectors need a real browser session (signed-in app routes, real
|
|
4
|
+
viewport screenshots) without adding Playwright or a websocket dependency.
|
|
5
|
+
This module implements just enough of RFC 6455 (client text frames, masked;
|
|
6
|
+
server frames with fragmentation, ping/pong and close) and the flattened CDP
|
|
7
|
+
session protocol to drive one tab.
|
|
8
|
+
|
|
9
|
+
Two ways to obtain a browser:
|
|
10
|
+
|
|
11
|
+
- ``attach(cdp_url)``: connect to an already running Chrome started with
|
|
12
|
+
``--remote-debugging-port`` (the host's signed-in session);
|
|
13
|
+
- ``launch(chrome, profile_dir)``: start headless Chrome with a dedicated
|
|
14
|
+
profile directory, so a rotated refresh token is persisted between renders.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import socket
|
|
23
|
+
import struct
|
|
24
|
+
import subprocess
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Callable
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
from urllib.request import urlopen
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CdpError(RuntimeError):
|
|
33
|
+
"""Raised for protocol, transport, or browser start failures."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WebSocket:
|
|
37
|
+
"""A blocking RFC 6455 client connection carrying text frames."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, url: str, timeout: float = 30.0) -> None:
|
|
40
|
+
parts = urlsplit(url)
|
|
41
|
+
if parts.scheme != "ws":
|
|
42
|
+
raise CdpError("only ws:// DevTools endpoints on this machine are supported")
|
|
43
|
+
host, port = parts.hostname or "127.0.0.1", parts.port or 80
|
|
44
|
+
self.sock = socket.create_connection((host, port), timeout=timeout)
|
|
45
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
46
|
+
path = parts.path + (f"?{parts.query}" if parts.query else "")
|
|
47
|
+
request = (
|
|
48
|
+
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
49
|
+
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
50
|
+
)
|
|
51
|
+
self.sock.sendall(request.encode())
|
|
52
|
+
response = b""
|
|
53
|
+
while b"\r\n\r\n" not in response:
|
|
54
|
+
chunk = self.sock.recv(4096)
|
|
55
|
+
if not chunk:
|
|
56
|
+
raise CdpError("DevTools endpoint closed the connection during the handshake")
|
|
57
|
+
response += chunk
|
|
58
|
+
head, _, self.buffer = response.partition(b"\r\n\r\n")
|
|
59
|
+
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
|
60
|
+
raise CdpError("DevTools endpoint refused the websocket upgrade")
|
|
61
|
+
|
|
62
|
+
def _read(self, count: int) -> bytes:
|
|
63
|
+
while len(self.buffer) < count:
|
|
64
|
+
chunk = self.sock.recv(max(65536, count - len(self.buffer)))
|
|
65
|
+
if not chunk:
|
|
66
|
+
raise CdpError("DevTools connection closed")
|
|
67
|
+
self.buffer += chunk
|
|
68
|
+
data, self.buffer = self.buffer[:count], self.buffer[count:]
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
def send_frame(self, payload: bytes, opcode: int = 1) -> None:
|
|
72
|
+
header = bytes([0x80 | opcode])
|
|
73
|
+
length = len(payload)
|
|
74
|
+
if length < 126:
|
|
75
|
+
header += bytes([0x80 | length])
|
|
76
|
+
elif length < 1 << 16:
|
|
77
|
+
header += bytes([0x80 | 126]) + struct.pack(">H", length)
|
|
78
|
+
else:
|
|
79
|
+
header += bytes([0x80 | 127]) + struct.pack(">Q", length)
|
|
80
|
+
mask = os.urandom(4)
|
|
81
|
+
self.sock.sendall(header + mask + bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)))
|
|
82
|
+
|
|
83
|
+
def send(self, text: str) -> None:
|
|
84
|
+
self.send_frame(text.encode("utf-8"))
|
|
85
|
+
|
|
86
|
+
def recv(self) -> str:
|
|
87
|
+
message = b""
|
|
88
|
+
while True:
|
|
89
|
+
first, second = self._read(2)
|
|
90
|
+
opcode, final = first & 0x0F, bool(first & 0x80)
|
|
91
|
+
length = second & 0x7F
|
|
92
|
+
if length == 126:
|
|
93
|
+
length = struct.unpack(">H", self._read(2))[0]
|
|
94
|
+
elif length == 127:
|
|
95
|
+
length = struct.unpack(">Q", self._read(8))[0]
|
|
96
|
+
mask = self._read(4) if second & 0x80 else b""
|
|
97
|
+
payload = self._read(length)
|
|
98
|
+
if mask:
|
|
99
|
+
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
|
100
|
+
if opcode == 0x9:
|
|
101
|
+
self.send_frame(payload, 0xA)
|
|
102
|
+
continue
|
|
103
|
+
if opcode == 0xA:
|
|
104
|
+
continue
|
|
105
|
+
if opcode == 0x8:
|
|
106
|
+
raise CdpError("DevTools connection closed by the browser")
|
|
107
|
+
message += payload
|
|
108
|
+
if final:
|
|
109
|
+
return message.decode("utf-8")
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
try:
|
|
113
|
+
self.send_frame(b"", 0x8)
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
self.sock.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Browser:
|
|
120
|
+
"""A browser-level CDP connection that drives one flattened page session."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, ws_url: str, process: subprocess.Popen | None = None, timeout: float = 60.0) -> None:
|
|
123
|
+
self.ws = WebSocket(ws_url)
|
|
124
|
+
self.process = process
|
|
125
|
+
self.timeout = timeout
|
|
126
|
+
self.next_id = 0
|
|
127
|
+
self.session: str | None = None
|
|
128
|
+
self.target: str | None = None
|
|
129
|
+
self.events: list[dict] = []
|
|
130
|
+
self.responses: dict[int, dict] = {}
|
|
131
|
+
self.handlers: dict[str, Callable[[dict], None]] = {}
|
|
132
|
+
|
|
133
|
+
def _dispatch(self, message: dict) -> None:
|
|
134
|
+
handler = self.handlers.get(message.get("method", ""))
|
|
135
|
+
if handler:
|
|
136
|
+
handler(message.get("params", {}))
|
|
137
|
+
else:
|
|
138
|
+
self.events.append(message)
|
|
139
|
+
|
|
140
|
+
def call(self, method: str, params: dict | None = None, session: bool = True) -> dict:
|
|
141
|
+
self.next_id += 1
|
|
142
|
+
payload = {"id": self.next_id, "method": method, "params": params or {}}
|
|
143
|
+
if session and self.session:
|
|
144
|
+
payload["sessionId"] = self.session
|
|
145
|
+
self.ws.send(json.dumps(payload))
|
|
146
|
+
call_id = self.next_id
|
|
147
|
+
deadline = time.monotonic() + self.timeout
|
|
148
|
+
while time.monotonic() < deadline:
|
|
149
|
+
# A handler may issue its own call while we wait, so responses can arrive out of order.
|
|
150
|
+
message = self.responses.pop(call_id, None) or json.loads(self.ws.recv())
|
|
151
|
+
if message.get("id") == call_id:
|
|
152
|
+
if "error" in message:
|
|
153
|
+
raise CdpError(f"{method} failed: {message['error'].get('message', 'unknown error')}")
|
|
154
|
+
return message.get("result", {})
|
|
155
|
+
if "id" in message:
|
|
156
|
+
self.responses[message["id"]] = message
|
|
157
|
+
continue
|
|
158
|
+
self._dispatch(message)
|
|
159
|
+
raise CdpError(f"{method} timed out")
|
|
160
|
+
|
|
161
|
+
def wait_for(self, method: str, timeout: float) -> dict | None:
|
|
162
|
+
for index, event in enumerate(self.events):
|
|
163
|
+
if event.get("method") == method:
|
|
164
|
+
return self.events.pop(index)
|
|
165
|
+
deadline = time.monotonic() + timeout
|
|
166
|
+
self.ws.sock.settimeout(max(0.1, timeout))
|
|
167
|
+
try:
|
|
168
|
+
while time.monotonic() < deadline:
|
|
169
|
+
try:
|
|
170
|
+
message = json.loads(self.ws.recv())
|
|
171
|
+
except (socket.timeout, TimeoutError):
|
|
172
|
+
return None
|
|
173
|
+
if message.get("method") == method:
|
|
174
|
+
return message
|
|
175
|
+
if "id" in message:
|
|
176
|
+
self.responses[message["id"]] = message
|
|
177
|
+
continue
|
|
178
|
+
self._dispatch(message)
|
|
179
|
+
finally:
|
|
180
|
+
self.ws.sock.settimeout(self.timeout)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def pump(self, seconds: float) -> None:
|
|
184
|
+
"""Process events (and handlers) for a fixed time, e.g. while the page settles."""
|
|
185
|
+
deadline = time.monotonic() + seconds
|
|
186
|
+
while time.monotonic() < deadline:
|
|
187
|
+
self.wait_for("__never__", max(0.05, deadline - time.monotonic()))
|
|
188
|
+
|
|
189
|
+
def open_tab(self, url: str = "about:blank") -> None:
|
|
190
|
+
self.target = self.call("Target.createTarget", {"url": url}, session=False)["targetId"]
|
|
191
|
+
self.session = self.call("Target.attachToTarget", {"targetId": self.target, "flatten": True}, session=False)["sessionId"]
|
|
192
|
+
|
|
193
|
+
def evaluate(self, expression: str) -> object:
|
|
194
|
+
result = self.call("Runtime.evaluate", {"expression": expression, "returnByValue": True, "awaitPromise": True})
|
|
195
|
+
if "exceptionDetails" in result:
|
|
196
|
+
raise CdpError("page script failed: " + str(result["exceptionDetails"].get("text", "exception"))[:200])
|
|
197
|
+
return result.get("result", {}).get("value")
|
|
198
|
+
|
|
199
|
+
def close(self) -> None:
|
|
200
|
+
try:
|
|
201
|
+
if self.target:
|
|
202
|
+
self.call("Target.closeTarget", {"targetId": self.target}, session=False)
|
|
203
|
+
except (CdpError, OSError):
|
|
204
|
+
pass
|
|
205
|
+
self.ws.close()
|
|
206
|
+
if self.process:
|
|
207
|
+
self.process.terminate()
|
|
208
|
+
try:
|
|
209
|
+
self.process.wait(timeout=10)
|
|
210
|
+
except subprocess.TimeoutExpired:
|
|
211
|
+
self.process.kill()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def attach(cdp_url: str) -> Browser:
|
|
215
|
+
"""Attach to a running Chrome exposing ``--remote-debugging-port`` on this machine."""
|
|
216
|
+
parts = urlsplit(cdp_url)
|
|
217
|
+
if parts.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
|
218
|
+
raise CdpError("--cdp-url must point at a DevTools port on this machine (127.0.0.1 or localhost)")
|
|
219
|
+
with urlopen(cdp_url.rstrip("/") + "/json/version", timeout=10) as response:
|
|
220
|
+
version = json.loads(response.read().decode("utf-8"))
|
|
221
|
+
url = version.get("webSocketDebuggerUrl")
|
|
222
|
+
if not url:
|
|
223
|
+
raise CdpError("DevTools endpoint did not report a browser websocket URL")
|
|
224
|
+
return Browser(url)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def launch(chrome: str, profile_dir: Path, headless: bool = True) -> Browser:
|
|
228
|
+
"""Start Chrome with a dedicated profile and connect to its browser target."""
|
|
229
|
+
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
port_file = profile_dir / "DevToolsActivePort"
|
|
231
|
+
port_file.unlink(missing_ok=True)
|
|
232
|
+
args = [chrome, "--remote-debugging-port=0", f"--user-data-dir={profile_dir}", "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars"]
|
|
233
|
+
if headless:
|
|
234
|
+
args += ["--headless=new", "--no-sandbox"]
|
|
235
|
+
process = subprocess.Popen(args + ["about:blank"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
236
|
+
deadline = time.monotonic() + 30
|
|
237
|
+
while time.monotonic() < deadline:
|
|
238
|
+
if port_file.is_file():
|
|
239
|
+
lines = port_file.read_text(encoding="utf-8").splitlines()
|
|
240
|
+
if len(lines) >= 2:
|
|
241
|
+
return Browser(f"ws://127.0.0.1:{lines[0].strip()}{lines[1].strip()}", process)
|
|
242
|
+
if process.poll() is not None:
|
|
243
|
+
break
|
|
244
|
+
time.sleep(0.1)
|
|
245
|
+
process.kill()
|
|
246
|
+
raise CdpError("Chrome did not expose a DevTools port; is another Chrome using this profile directory?")
|