arc-builder-kit 0.2.0__py3-none-any.whl

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 (58) hide show
  1. arc_builder_kit/__init__.py +4 -0
  2. arc_builder_kit/__main__.py +6 -0
  3. arc_builder_kit/_paths.py +47 -0
  4. arc_builder_kit/cli.py +277 -0
  5. arc_builder_kit/config/arc_testnet.facts.json +31 -0
  6. arc_builder_kit/doctor.py +936 -0
  7. arc_builder_kit/examples/agent-commerce-components/components.js +200 -0
  8. arc_builder_kit/examples/agent-commerce-components/index.html +120 -0
  9. arc_builder_kit/examples/agent-commerce-flows/flows.js +271 -0
  10. arc_builder_kit/examples/agent-commerce-flows/index.html +114 -0
  11. arc_builder_kit/examples/agent-commerce-live/commerce-live.js +190 -0
  12. arc_builder_kit/examples/agent-commerce-live/index.html +105 -0
  13. arc_builder_kit/examples/agent-commerce-review-packet/index.html +96 -0
  14. arc_builder_kit/examples/agent-commerce-review-packet/packet.js +125 -0
  15. arc_builder_kit/examples/agent-identity-profile-preview/identity.js +126 -0
  16. arc_builder_kit/examples/agent-identity-profile-preview/index.html +104 -0
  17. arc_builder_kit/examples/arc-agent-treasury-lab/index.html +152 -0
  18. arc_builder_kit/examples/arc-agent-treasury-lab/treasury.js +532 -0
  19. arc_builder_kit/examples/arc-testnet-operator-evidence/evidence.example.json +47 -0
  20. arc_builder_kit/examples/arc-testnet-wallet-send-gate/index.html +233 -0
  21. arc_builder_kit/examples/arc-testnet-wallet-send-gate/live-infrastructure-policy.example.json +59 -0
  22. arc_builder_kit/examples/arc-testnet-wallet-send-gate/wallet-send-gate.js +472 -0
  23. arc_builder_kit/examples/circle-wallet-integration/index.html +155 -0
  24. arc_builder_kit/examples/circle-wallet-integration/wallet-lab.js +91 -0
  25. arc_builder_kit/examples/job-escrow-simulator/index.html +121 -0
  26. arc_builder_kit/examples/job-escrow-simulator/simulator.js +162 -0
  27. arc_builder_kit/examples/payment-intent-demo/index.html +132 -0
  28. arc_builder_kit/examples/payment-intent-playground/index.html +301 -0
  29. arc_builder_kit/examples/payment-intent-playground/playground.js +835 -0
  30. arc_builder_kit/examples/payment-intent-receipt-matcher/index.html +157 -0
  31. arc_builder_kit/examples/payment-intent-receipt-matcher/matcher.js +877 -0
  32. arc_builder_kit/examples/receipt-verifier-playground/index.html +120 -0
  33. arc_builder_kit/examples/receipt-verifier-playground/verifier.js +226 -0
  34. arc_builder_kit/examples/receipt-viewer/index.html +138 -0
  35. arc_builder_kit/examples/receipt-viewer/receipt-viewer.js +472 -0
  36. arc_builder_kit/examples/transaction-status-playground/index.html +135 -0
  37. arc_builder_kit/examples/transaction-status-playground/status.js +518 -0
  38. arc_builder_kit/examples/x402-local-challenge-server/.env.example +25 -0
  39. arc_builder_kit/examples/x402-local-challenge-server/README.md +111 -0
  40. arc_builder_kit/examples/x402-local-challenge-server/server.py +711 -0
  41. arc_builder_kit/mcp_server.py +463 -0
  42. arc_builder_kit/release_packet.py +469 -0
  43. arc_builder_kit/templates/README.md +25 -0
  44. arc_builder_kit/templates/job-escrow-starter/README.md +25 -0
  45. arc_builder_kit/templates/job-escrow-starter/index.html +41 -0
  46. arc_builder_kit/templates/job-escrow-starter/index.js +14 -0
  47. arc_builder_kit/templates/payment-intent-starter/README.md +25 -0
  48. arc_builder_kit/templates/payment-intent-starter/index.html +42 -0
  49. arc_builder_kit/templates/payment-intent-starter/index.js +7 -0
  50. arc_builder_kit/templates/x402-agent-starter/README.md +29 -0
  51. arc_builder_kit/templates/x402-agent-starter/server.py +201 -0
  52. arc_builder_kit/validate_repo.py +2212 -0
  53. arc_builder_kit-0.2.0.dist-info/METADATA +543 -0
  54. arc_builder_kit-0.2.0.dist-info/RECORD +58 -0
  55. arc_builder_kit-0.2.0.dist-info/WHEEL +5 -0
  56. arc_builder_kit-0.2.0.dist-info/entry_points.txt +3 -0
  57. arc_builder_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
  58. arc_builder_kit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,936 @@
1
+ #!/usr/bin/env python3
2
+ """Arc Builder Doctor: one safe local health command for the builder kit.
3
+
4
+ This script is an orchestrator and reporter, not a second validator. It runs the
5
+ existing dependency-free checks, reads their results, and prints a single
6
+ structured verdict. It is Python-standard-library only.
7
+
8
+ Hard boundaries (see docs/arc-builder-doctor.md):
9
+ - Arc Testnet only; no mainnet facts, support, or fallback.
10
+ - No wallet connection, private-key input, signing, or transaction broadcast.
11
+ - Zero network calls by default; network checks are opt-in and read-only.
12
+ - No shell command-string execution; children run as argument lists.
13
+ - No repository files are mutated and no servers are started.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import http.client as http_client
20
+ import html
21
+ import json
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import time
27
+ from contextlib import redirect_stdout
28
+ from datetime import datetime, timezone
29
+ from io import StringIO
30
+ from pathlib import Path
31
+ from typing import Any, Callable, Iterable
32
+ from urllib import error as urllib_error
33
+ from urllib import request as urllib_request
34
+ from urllib.parse import urlparse
35
+
36
+ from arc_builder_kit._paths import CONFIG_DIR, IS_SOURCE_CHECKOUT, REPO_ROOT as ROOT
37
+
38
+ KIND = "arc_builder_doctor_report"
39
+ SCHEMA_VERSION = 1
40
+
41
+ STATUS_PASS = "pass"
42
+ STATUS_WARN = "warn"
43
+ STATUS_FAIL = "fail"
44
+ STATUS_SKIP = "skip"
45
+ VALID_STATUSES = (STATUS_PASS, STATUS_WARN, STATUS_FAIL, STATUS_SKIP)
46
+
47
+ MIN_PYTHON = (3, 10)
48
+ MIN_NODE_MAJOR = 18
49
+
50
+ EXPECTED_CHAIN_ID_DECIMAL = 5042002
51
+ EXPECTED_CHAIN_ID_HEX = "0x4cef52"
52
+
53
+ CANONICAL_BASE_URL = "https://anstrays.github.io/arc-mcp-builder-assistant/"
54
+ ALLOWED_PUBLIC_HOST = "anstrays.github.io"
55
+
56
+ # Bounds. Children run as argument lists with explicit timeouts and the captured
57
+ # output is truncated so a noisy or hostile child cannot flood the report.
58
+ QUICK_CHILD_TIMEOUT = 60
59
+ FULL_CHILD_TIMEOUT = 300
60
+ NODE_VERSION_TIMEOUT = 15
61
+ DEFAULT_NETWORK_TIMEOUT = 10
62
+ MAX_NETWORK_TIMEOUT = 30
63
+ MAX_CHILD_CAPTURE = 4000
64
+ MAX_NETWORK_BYTES = 1_000_000
65
+ DETAIL_LIMIT = 240
66
+
67
+ # Critical builder-kit files the doctor itself depends on. This is an
68
+ # orchestrator-level sanity list, not a copy of the full completion contract.
69
+ SOURCE_CRITICAL_FILES = (
70
+ "README.md",
71
+ "SECURITY.md",
72
+ "scripts/arc_builder_doctor.py",
73
+ "scripts/test_arc_builder_doctor.py",
74
+ "docs/arc-builder-doctor.md",
75
+ "scripts/test_all.py",
76
+ "scripts/check_completion.py",
77
+ "scripts/validate_repo.py",
78
+ "scripts/check_arc_testnet_status.py",
79
+ "config/arc_testnet.facts.json",
80
+ "scripts/validate_arc_testnet_facts.py",
81
+ "scripts/test_arc_testnet_facts.py",
82
+ "scripts/test_public_claims.py",
83
+ "scripts/validate_live_infrastructure_policy.py",
84
+ "scripts/test_workflow_security.py",
85
+ "examples/arc-testnet-wallet-send-gate/index.html",
86
+ "examples/arc-testnet-wallet-send-gate/wallet-send-gate.js",
87
+ "examples/arc-testnet-wallet-send-gate/live-infrastructure-policy.example.json",
88
+ )
89
+ INSTALLED_CRITICAL_FILES = (
90
+ "__init__.py",
91
+ "cli.py",
92
+ "doctor.py",
93
+ "mcp_server.py",
94
+ "release_packet.py",
95
+ "validate_repo.py",
96
+ "config/arc_testnet.facts.json",
97
+ "templates/payment-intent-starter/README.md",
98
+ "templates/x402-agent-starter/server.py",
99
+ "templates/job-escrow-starter/README.md",
100
+ "examples/payment-intent-receipt-matcher/index.html",
101
+ "examples/arc-testnet-wallet-send-gate/live-infrastructure-policy.example.json",
102
+ "examples/x402-local-challenge-server/server.py",
103
+ )
104
+ CRITICAL_FILES = SOURCE_CRITICAL_FILES if IS_SOURCE_CHECKOUT else INSTALLED_CRITICAL_FILES
105
+
106
+ _REDACTED = "[redacted]"
107
+
108
+ # Patterns for credential-like material that must never reach the report. These
109
+ # are detection rules, not real credentials; they are written so they do not
110
+ # themselves match the repository secret scanner.
111
+ _REDACTION_RULES = (
112
+ re.compile(r"gh[oprsu]_[A-Za-z0-9_]{16,}"),
113
+ re.compile(r"github_pat_[A-Za-z0-9_]{16,}"),
114
+ re.compile(r"AKIA[0-9A-Z]{12,}"),
115
+ re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
116
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
117
+ re.compile(r"\b0x[0-9a-fA-F]{40,}\b"),
118
+ re.compile(r"\b[0-9a-fA-F]{64,}\b"),
119
+ re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-]{8,}"),
120
+ re.compile(r"(?i)\bauthorization\b\s*[:=]\s*\S+"),
121
+ re.compile(
122
+ r"(?i)(?:api[_-]?key|secret|token|password|private[_-]?key|entity[_-]?secret)"
123
+ r"\s*[:=]\s*\S{6,}"
124
+ ),
125
+ )
126
+
127
+
128
+ class ForeignRedirectError(Exception):
129
+ """Raised when a public-site request is redirected to an unreviewed host."""
130
+
131
+
132
+ class OversizedResponseError(Exception):
133
+ """Raised when a public-site response exceeds the reviewed size bound."""
134
+
135
+
136
+ def redact(text: str) -> str:
137
+ """Replace credential-like substrings with a fixed marker."""
138
+ if not text:
139
+ return ""
140
+ for rule in _REDACTION_RULES:
141
+ text = rule.sub(_REDACTED, text)
142
+ return text
143
+
144
+
145
+ def safe_detail(text: str, limit: int = DETAIL_LIMIT) -> str:
146
+ """Redact, collapse whitespace, and bound a human-facing detail string."""
147
+ collapsed = " ".join(redact(text).split())
148
+ if len(collapsed) > limit:
149
+ collapsed = collapsed[: limit - 3].rstrip() + "..."
150
+ return collapsed
151
+
152
+
153
+ def make_check(
154
+ check_id: str,
155
+ label: str,
156
+ status: str,
157
+ detail: str,
158
+ *,
159
+ source: str | None = None,
160
+ duration_ms: int = 0,
161
+ ) -> dict[str, Any]:
162
+ if status not in VALID_STATUSES:
163
+ raise ValueError(f"invalid check status for {check_id}: {status!r}")
164
+ check: dict[str, Any] = {
165
+ "id": check_id,
166
+ "label": label,
167
+ "status": status,
168
+ "detail": safe_detail(detail),
169
+ "durationMs": max(0, int(duration_ms)),
170
+ }
171
+ if source:
172
+ check["source"] = source
173
+ return check
174
+
175
+
176
+ def _elapsed_ms(start: float) -> int:
177
+ return int((time.monotonic() - start) * 1000)
178
+
179
+
180
+ class ChildResult:
181
+ __slots__ = ("returncode", "stdout", "stderr", "timed_out")
182
+
183
+ def __init__(self, returncode: int, stdout: str, stderr: str, timed_out: bool) -> None:
184
+ self.returncode = returncode
185
+ self.stdout = stdout
186
+ self.stderr = stderr
187
+ self.timed_out = timed_out
188
+
189
+ def summary_line(self) -> str:
190
+ for stream in (self.stdout, self.stderr):
191
+ for line in reversed(stream.splitlines()):
192
+ if line.strip():
193
+ return line.strip()
194
+ return ""
195
+
196
+
197
+ def run_child(argv: list[str], timeout: int) -> ChildResult:
198
+ """Run a child process as an argument list with a bounded timeout.
199
+
200
+ No shell is involved and captured output is truncated. A timeout produces a
201
+ fail-closed ChildResult rather than hanging.
202
+ """
203
+ try:
204
+ completed = subprocess.run(
205
+ argv,
206
+ cwd=str(ROOT),
207
+ capture_output=True,
208
+ text=True,
209
+ timeout=timeout,
210
+ check=False,
211
+ )
212
+ except subprocess.TimeoutExpired:
213
+ return ChildResult(returncode=124, stdout="", stderr="", timed_out=True)
214
+ except (OSError, ValueError) as exc:
215
+ return ChildResult(returncode=125, stdout="", stderr=str(exc), timed_out=False)
216
+ stdout = (completed.stdout or "")[:MAX_CHILD_CAPTURE]
217
+ stderr = (completed.stderr or "")[:MAX_CHILD_CAPTURE]
218
+ return ChildResult(returncode=completed.returncode, stdout=stdout, stderr=stderr, timed_out=False)
219
+
220
+
221
+ def _fetch_public(url: str, timeout: int, max_bytes: int) -> tuple[int, str]:
222
+ """GET a reviewed public URL, rejecting redirects to unreviewed hosts."""
223
+ parsed = urlparse(url)
224
+ if parsed.scheme != "https" or parsed.hostname != ALLOWED_PUBLIC_HOST:
225
+ raise ForeignRedirectError(f"refusing non-allowlisted URL host: {parsed.hostname}")
226
+
227
+ handler = _HostPinnedRedirectHandler()
228
+ opener = urllib_request.build_opener(handler)
229
+ request = urllib_request.Request(
230
+ url,
231
+ method="GET",
232
+ headers={"User-Agent": "arc-builder-doctor", "Accept": "text/html"},
233
+ )
234
+ with opener.open(request, timeout=timeout) as response:
235
+ final_host = urlparse(response.geturl()).hostname
236
+ if final_host != ALLOWED_PUBLIC_HOST:
237
+ raise ForeignRedirectError(f"redirected to unreviewed host: {final_host}")
238
+ status = int(getattr(response, "status", 0) or 0)
239
+ raw = response.read(max_bytes + 1)
240
+ if len(raw) > max_bytes:
241
+ raise OversizedResponseError("public response exceeded the 1 MB safety limit")
242
+ return status, raw.decode("utf-8", "replace")
243
+
244
+
245
+ class _HostPinnedRedirectHandler(urllib_request.HTTPRedirectHandler):
246
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
247
+ if urlparse(newurl).hostname != ALLOWED_PUBLIC_HOST:
248
+ raise ForeignRedirectError(f"redirect to unreviewed host: {urlparse(newurl).hostname}")
249
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
250
+
251
+
252
+ class Options:
253
+ def __init__(
254
+ self,
255
+ *,
256
+ full: bool = False,
257
+ include_arc_rpc: bool = False,
258
+ include_public_site: bool = False,
259
+ strict: bool = False,
260
+ network_timeout: int = DEFAULT_NETWORK_TIMEOUT,
261
+ ) -> None:
262
+ self.full = full
263
+ self.include_arc_rpc = include_arc_rpc
264
+ self.include_public_site = include_public_site
265
+ self.strict = strict
266
+ self.network_timeout = max(1, min(MAX_NETWORK_TIMEOUT, int(network_timeout)))
267
+
268
+ def optional_unavailable_status(self) -> str:
269
+ return STATUS_FAIL if self.strict else STATUS_WARN
270
+
271
+
272
+ # --- individual checks --------------------------------------------------------
273
+
274
+
275
+ def check_python(options: Options) -> dict[str, Any]:
276
+ start = time.monotonic()
277
+ version = sys.version_info
278
+ ok = (version.major, version.minor) >= MIN_PYTHON
279
+ detail = f"Python {version.major}.{version.minor}.{version.micro}"
280
+ if not ok:
281
+ detail += f"; requires >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]}"
282
+ return make_check(
283
+ "runtime.python",
284
+ "Python runtime",
285
+ STATUS_PASS if ok else STATUS_FAIL,
286
+ detail,
287
+ duration_ms=_elapsed_ms(start),
288
+ )
289
+
290
+
291
+ def check_node(options: Options) -> dict[str, Any]:
292
+ start = time.monotonic()
293
+ node = shutil.which("node")
294
+ if not node:
295
+ return make_check(
296
+ "runtime.node",
297
+ "Node.js runtime",
298
+ STATUS_WARN,
299
+ "Node.js not found; behavioral harnesses are skipped (no npm install needed)",
300
+ duration_ms=_elapsed_ms(start),
301
+ )
302
+ result = run_child([node, "--version"], NODE_VERSION_TIMEOUT)
303
+ if result.timed_out or result.returncode != 0:
304
+ return make_check(
305
+ "runtime.node",
306
+ "Node.js runtime",
307
+ STATUS_WARN,
308
+ "Node.js present but version probe failed",
309
+ duration_ms=_elapsed_ms(start),
310
+ )
311
+ text = result.stdout.strip()
312
+ match = re.match(r"v(\d+)\.", text)
313
+ major = int(match.group(1)) if match else 0
314
+ if major >= MIN_NODE_MAJOR:
315
+ return make_check(
316
+ "runtime.node",
317
+ "Node.js runtime",
318
+ STATUS_PASS,
319
+ f"Node.js {text}",
320
+ duration_ms=_elapsed_ms(start),
321
+ )
322
+ return make_check(
323
+ "runtime.node",
324
+ "Node.js runtime",
325
+ STATUS_WARN,
326
+ f"Node.js {text} is older than the recommended {MIN_NODE_MAJOR}+",
327
+ duration_ms=_elapsed_ms(start),
328
+ )
329
+
330
+
331
+ def check_required_files(options: Options) -> dict[str, Any]:
332
+ start = time.monotonic()
333
+ missing = [relative for relative in CRITICAL_FILES if not (ROOT / relative).is_file()]
334
+ if missing:
335
+ return make_check(
336
+ "repo.required_files",
337
+ "Required builder-kit files",
338
+ STATUS_FAIL,
339
+ f"missing {len(missing)} critical file(s): " + ", ".join(missing[:5]),
340
+ duration_ms=_elapsed_ms(start),
341
+ )
342
+ return make_check(
343
+ "repo.required_files",
344
+ "Required builder-kit files",
345
+ STATUS_PASS,
346
+ f"{len(CRITICAL_FILES)} critical files present",
347
+ duration_ms=_elapsed_ms(start),
348
+ )
349
+
350
+
351
+ def _python_script_check(
352
+ check_id: str,
353
+ label: str,
354
+ script_relative: str,
355
+ *,
356
+ timeout: int = QUICK_CHILD_TIMEOUT,
357
+ args: Iterable[str] = (),
358
+ ) -> dict[str, Any]:
359
+ start = time.monotonic()
360
+ argv = [sys.executable, str(ROOT / script_relative), *args]
361
+ result = run_child(argv, timeout)
362
+ if result.timed_out:
363
+ return make_check(
364
+ check_id,
365
+ label,
366
+ STATUS_FAIL,
367
+ f"{script_relative} timed out after {timeout}s",
368
+ source=script_relative,
369
+ duration_ms=_elapsed_ms(start),
370
+ )
371
+ if result.returncode == 0:
372
+ return make_check(
373
+ check_id,
374
+ label,
375
+ STATUS_PASS,
376
+ result.summary_line() or "passed",
377
+ source=script_relative,
378
+ duration_ms=_elapsed_ms(start),
379
+ )
380
+ return make_check(
381
+ check_id,
382
+ label,
383
+ STATUS_FAIL,
384
+ f"exit {result.returncode}: {result.summary_line()}",
385
+ source=script_relative,
386
+ duration_ms=_elapsed_ms(start),
387
+ )
388
+
389
+
390
+ def check_clean_safety_markers(options: Options) -> dict[str, Any]:
391
+ return _python_script_check(
392
+ "repo.clean_safety_markers",
393
+ "Safe-scope completion markers",
394
+ "scripts/check_completion.py",
395
+ )
396
+
397
+
398
+ def check_public_claims(options: Options) -> dict[str, Any]:
399
+ return _python_script_check(
400
+ "repo.public_claims",
401
+ "Public claims boundaries",
402
+ "scripts/test_public_claims.py",
403
+ )
404
+
405
+
406
+ def check_live_infrastructure_policy(options: Options) -> dict[str, Any]:
407
+ return _python_script_check(
408
+ "repo.live_infrastructure_policy",
409
+ "Live infrastructure policy",
410
+ "scripts/validate_live_infrastructure_policy.py",
411
+ )
412
+
413
+
414
+ def check_arc_testnet_facts(options: Options) -> dict[str, Any]:
415
+ return _python_script_check(
416
+ "repo.arc_testnet_facts",
417
+ "Arc Testnet facts consistency",
418
+ "scripts/validate_arc_testnet_facts.py",
419
+ )
420
+
421
+
422
+ def check_workflow_security(options: Options) -> dict[str, Any]:
423
+ return _python_script_check(
424
+ "repo.workflow_security",
425
+ "GitHub Actions workflow security",
426
+ "scripts/test_workflow_security.py",
427
+ )
428
+
429
+
430
+ def check_installed_package(options: Options) -> dict[str, Any]:
431
+ """Run the wheel's dependency-free resource and safety validation."""
432
+ start = time.monotonic()
433
+ try:
434
+ from arc_builder_kit.validate_repo import validate_installed_package
435
+
436
+ with redirect_stdout(StringIO()):
437
+ validate_installed_package()
438
+ except (OSError, RuntimeError, SystemExit, ValueError) as exc:
439
+ return make_check(
440
+ "package.integrity",
441
+ "Installed package integrity",
442
+ STATUS_FAIL,
443
+ str(exc) or "installed package validation failed",
444
+ duration_ms=_elapsed_ms(start),
445
+ )
446
+ return make_check(
447
+ "package.integrity",
448
+ "Installed package integrity",
449
+ STATUS_PASS,
450
+ "reviewed resources and Arc Testnet safety policy are present",
451
+ duration_ms=_elapsed_ms(start),
452
+ )
453
+
454
+
455
+ def check_canonical_suite(options: Options) -> dict[str, Any]:
456
+ if not IS_SOURCE_CHECKOUT:
457
+ return make_check(
458
+ "repo.canonical_suite",
459
+ "Canonical regression suite",
460
+ STATUS_SKIP,
461
+ "full repository suite requires a source checkout",
462
+ )
463
+ return _python_script_check(
464
+ "repo.canonical_suite",
465
+ "Canonical regression suite",
466
+ "scripts/test_all.py",
467
+ timeout=FULL_CHILD_TIMEOUT,
468
+ )
469
+
470
+
471
+ def check_arc_testnet_status(options: Options) -> dict[str, Any]:
472
+ if not IS_SOURCE_CHECKOUT:
473
+ return _check_installed_arc_testnet_status(options)
474
+ start = time.monotonic()
475
+ unavailable = options.optional_unavailable_status()
476
+ argv = [
477
+ sys.executable,
478
+ str(ROOT / "scripts/check_arc_testnet_status.py"),
479
+ "--timeout",
480
+ str(options.network_timeout),
481
+ ]
482
+ result = run_child(argv, options.network_timeout + 10)
483
+ source = "scripts/check_arc_testnet_status.py"
484
+ if result.timed_out:
485
+ return make_check(
486
+ "arc_testnet.read_only_status",
487
+ "Arc Testnet read-only status",
488
+ unavailable,
489
+ "Arc Testnet RPC timed out",
490
+ source=source,
491
+ duration_ms=_elapsed_ms(start),
492
+ )
493
+ try:
494
+ payload = json.loads(result.stdout)
495
+ except (ValueError, TypeError):
496
+ return make_check(
497
+ "arc_testnet.read_only_status",
498
+ "Arc Testnet read-only status",
499
+ unavailable,
500
+ "Arc Testnet RPC returned malformed output",
501
+ source=source,
502
+ duration_ms=_elapsed_ms(start),
503
+ )
504
+ status_obj = payload.get("status") if isinstance(payload, dict) else None
505
+ if isinstance(status_obj, dict) and "chainIdDecimal" in status_obj:
506
+ chain_decimal = status_obj.get("chainIdDecimal")
507
+ chain_hex = status_obj.get("chainIdHex")
508
+ if chain_decimal == EXPECTED_CHAIN_ID_DECIMAL and chain_hex == EXPECTED_CHAIN_ID_HEX:
509
+ if result.returncode != 0 or payload.get("ok") is not True:
510
+ return make_check(
511
+ "arc_testnet.read_only_status",
512
+ "Arc Testnet read-only status",
513
+ unavailable,
514
+ "Arc Testnet status helper did not confirm success",
515
+ source=source,
516
+ duration_ms=_elapsed_ms(start),
517
+ )
518
+ return make_check(
519
+ "arc_testnet.read_only_status",
520
+ "Arc Testnet read-only status",
521
+ STATUS_PASS,
522
+ f"Arc Testnet chain {chain_decimal} ({chain_hex})",
523
+ source=source,
524
+ duration_ms=_elapsed_ms(start),
525
+ )
526
+ return make_check(
527
+ "arc_testnet.read_only_status",
528
+ "Arc Testnet read-only status",
529
+ STATUS_FAIL,
530
+ f"unexpected chain id: {chain_decimal} ({chain_hex}); "
531
+ f"expected {EXPECTED_CHAIN_ID_DECIMAL} ({EXPECTED_CHAIN_ID_HEX})",
532
+ source=source,
533
+ duration_ms=_elapsed_ms(start),
534
+ )
535
+ return make_check(
536
+ "arc_testnet.read_only_status",
537
+ "Arc Testnet read-only status",
538
+ unavailable,
539
+ "Arc Testnet RPC unavailable",
540
+ source=source,
541
+ duration_ms=_elapsed_ms(start),
542
+ )
543
+
544
+
545
+ class _NoRpcRedirectHandler(urllib_request.HTTPRedirectHandler):
546
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
547
+ raise ForeignRedirectError("Arc Testnet RPC redirects are not allowed")
548
+
549
+
550
+ def _check_installed_arc_testnet_status(options: Options) -> dict[str, Any]:
551
+ """Perform the explicit read-only chain-id probe from an installed wheel."""
552
+ start = time.monotonic()
553
+ unavailable = options.optional_unavailable_status()
554
+ source = "https://rpc.testnet.arc.network"
555
+ try:
556
+ facts = json.loads((CONFIG_DIR / "arc_testnet.facts.json").read_text(encoding="utf-8"))
557
+ rpc_url = facts["network"]["rpcUrl"]
558
+ if rpc_url != source:
559
+ raise ValueError("reviewed RPC URL does not match the pinned Arc Testnet endpoint")
560
+ body = json.dumps(
561
+ {"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []}
562
+ ).encode("utf-8")
563
+ request = urllib_request.Request(
564
+ rpc_url,
565
+ data=body,
566
+ method="POST",
567
+ headers={
568
+ "Content-Type": "application/json",
569
+ "Accept": "application/json",
570
+ "User-Agent": "arc-builder-doctor",
571
+ },
572
+ )
573
+ opener = urllib_request.build_opener(_NoRpcRedirectHandler())
574
+ with opener.open(request, timeout=options.network_timeout) as response:
575
+ raw = response.read(MAX_NETWORK_BYTES + 1)
576
+ if len(raw) > MAX_NETWORK_BYTES:
577
+ raise OversizedResponseError("Arc Testnet RPC response exceeded 1 MB")
578
+ payload = json.loads(raw.decode("utf-8"))
579
+ chain_hex = payload.get("result") if isinstance(payload, dict) else None
580
+ if not isinstance(chain_hex, str):
581
+ raise ValueError("Arc Testnet RPC response did not contain a chain id")
582
+ chain_hex = chain_hex.lower()
583
+ chain_decimal = int(chain_hex, 16)
584
+ except (
585
+ ForeignRedirectError,
586
+ OversizedResponseError,
587
+ OSError,
588
+ ValueError,
589
+ KeyError,
590
+ json.JSONDecodeError,
591
+ urllib_error.URLError,
592
+ http_client.HTTPException,
593
+ TimeoutError,
594
+ ) as exc:
595
+ return make_check(
596
+ "arc_testnet.read_only_status",
597
+ "Arc Testnet read-only status",
598
+ unavailable,
599
+ f"Arc Testnet RPC unavailable: {exc}",
600
+ source=source,
601
+ duration_ms=_elapsed_ms(start),
602
+ )
603
+ if chain_decimal != EXPECTED_CHAIN_ID_DECIMAL or chain_hex != EXPECTED_CHAIN_ID_HEX:
604
+ return make_check(
605
+ "arc_testnet.read_only_status",
606
+ "Arc Testnet read-only status",
607
+ STATUS_FAIL,
608
+ f"unexpected chain id: {chain_decimal} ({chain_hex}); expected "
609
+ f"{EXPECTED_CHAIN_ID_DECIMAL} ({EXPECTED_CHAIN_ID_HEX})",
610
+ source=source,
611
+ duration_ms=_elapsed_ms(start),
612
+ )
613
+ return make_check(
614
+ "arc_testnet.read_only_status",
615
+ "Arc Testnet read-only status",
616
+ STATUS_PASS,
617
+ f"Arc Testnet chain {chain_decimal} ({chain_hex})",
618
+ source=source,
619
+ duration_ms=_elapsed_ms(start),
620
+ )
621
+
622
+
623
+ _PUBLIC_SITE_TARGETS = (
624
+ ("public_site.root", "Public site root", CANONICAL_BASE_URL, ("Arc MCP Builder Assistant",)),
625
+ (
626
+ "public_site.wallet_gate",
627
+ "Public wallet-send lab",
628
+ CANONICAL_BASE_URL + "examples/arc-testnet-wallet-send-gate/",
629
+ ("Arc Testnet", "Disabled by default"),
630
+ ),
631
+ (
632
+ "public_site.docs_viewer",
633
+ "Public docs viewer",
634
+ CANONICAL_BASE_URL + "docs/view.html",
635
+ ("Arc MCP Builder Assistant",),
636
+ ),
637
+ )
638
+
639
+
640
+ def _check_public_site_target(
641
+ options: Options,
642
+ check_id: str,
643
+ label: str,
644
+ url: str,
645
+ markers: tuple[str, ...],
646
+ ) -> dict[str, Any]:
647
+ start = time.monotonic()
648
+ unavailable = options.optional_unavailable_status()
649
+ try:
650
+ status, body = _fetch_public(url, options.network_timeout, MAX_NETWORK_BYTES)
651
+ except ForeignRedirectError:
652
+ return make_check(
653
+ check_id,
654
+ label,
655
+ STATUS_FAIL,
656
+ "rejected redirect to unreviewed host",
657
+ source=url,
658
+ duration_ms=_elapsed_ms(start),
659
+ )
660
+ except OversizedResponseError:
661
+ return make_check(
662
+ check_id,
663
+ label,
664
+ STATUS_FAIL,
665
+ "public response exceeded the 1 MB safety limit",
666
+ source=url,
667
+ duration_ms=_elapsed_ms(start),
668
+ )
669
+ except (urllib_error.URLError, http_client.HTTPException, TimeoutError, OSError, ValueError) as exc:
670
+ return make_check(
671
+ check_id,
672
+ label,
673
+ unavailable,
674
+ f"public site unavailable: {exc.__class__.__name__}",
675
+ source=url,
676
+ duration_ms=_elapsed_ms(start),
677
+ )
678
+ if status != 200:
679
+ return make_check(
680
+ check_id,
681
+ label,
682
+ unavailable,
683
+ f"unexpected HTTP status {status}",
684
+ source=url,
685
+ duration_ms=_elapsed_ms(start),
686
+ )
687
+ if any(marker not in body for marker in markers):
688
+ return make_check(
689
+ check_id,
690
+ label,
691
+ unavailable,
692
+ "expected public safety marker missing",
693
+ source=url,
694
+ duration_ms=_elapsed_ms(start),
695
+ )
696
+ return make_check(
697
+ check_id,
698
+ label,
699
+ STATUS_PASS,
700
+ "HTTP 200 with expected public markers",
701
+ source=url,
702
+ duration_ms=_elapsed_ms(start),
703
+ )
704
+
705
+
706
+ def check_public_site(options: Options) -> list[dict[str, Any]]:
707
+ return [
708
+ _check_public_site_target(options, check_id, label, url, markers)
709
+ for check_id, label, url, markers in _PUBLIC_SITE_TARGETS
710
+ ]
711
+
712
+
713
+ # --- assembly -----------------------------------------------------------------
714
+
715
+ _DEFAULT_CHECKS: tuple[Callable[[Options], dict[str, Any]], ...] = (
716
+ (
717
+ check_python,
718
+ check_node,
719
+ check_required_files,
720
+ check_clean_safety_markers,
721
+ check_public_claims,
722
+ check_live_infrastructure_policy,
723
+ check_arc_testnet_facts,
724
+ check_workflow_security,
725
+ )
726
+ if IS_SOURCE_CHECKOUT
727
+ else (
728
+ check_python,
729
+ check_node,
730
+ check_required_files,
731
+ check_installed_package,
732
+ )
733
+ )
734
+
735
+
736
+ def collect_checks(options: Options) -> list[dict[str, Any]]:
737
+ checks: list[dict[str, Any]] = [runner(options) for runner in _DEFAULT_CHECKS]
738
+ if options.full:
739
+ checks.append(check_canonical_suite(options))
740
+ if options.include_arc_rpc:
741
+ checks.append(check_arc_testnet_status(options))
742
+ if options.include_public_site:
743
+ checks.extend(check_public_site(options))
744
+ for check in checks:
745
+ if check["status"] not in VALID_STATUSES:
746
+ raise ValueError(f"check {check.get('id')} produced invalid status")
747
+ return checks
748
+
749
+
750
+ def overall_status(checks: list[dict[str, Any]]) -> str:
751
+ statuses = {check["status"] for check in checks}
752
+ if STATUS_FAIL in statuses:
753
+ return STATUS_FAIL
754
+ if STATUS_WARN in statuses:
755
+ return STATUS_WARN
756
+ return STATUS_PASS
757
+
758
+
759
+ def _generated_at() -> str:
760
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
761
+
762
+
763
+ def build_report(options: Options) -> dict[str, Any]:
764
+ checks = collect_checks(options)
765
+ return {
766
+ "kind": KIND,
767
+ "schemaVersion": SCHEMA_VERSION,
768
+ "overallStatus": overall_status(checks),
769
+ "generatedAt": _generated_at(),
770
+ "mode": {
771
+ "localOnly": not (options.include_arc_rpc or options.include_public_site),
772
+ "arcRpcIncluded": options.include_arc_rpc,
773
+ "publicSiteIncluded": options.include_public_site,
774
+ "full": options.full,
775
+ "strict": options.strict,
776
+ },
777
+ "checks": checks,
778
+ "safety": {
779
+ "walletConnected": False,
780
+ "privateKeysAccepted": False,
781
+ "signingEnabled": False,
782
+ "transactionBroadcast": False,
783
+ "custodyEnabled": False,
784
+ "mainnetEnabled": False,
785
+ "autonomousSpending": False,
786
+ "networkChecksOptIn": True,
787
+ },
788
+ }
789
+
790
+
791
+ _STATUS_GLYPH = {
792
+ STATUS_PASS: "PASS",
793
+ STATUS_WARN: "WARN",
794
+ STATUS_FAIL: "FAIL",
795
+ STATUS_SKIP: "SKIP",
796
+ }
797
+
798
+
799
+ def render_human(report: dict[str, Any]) -> str:
800
+ lines = [
801
+ "Arc Builder Doctor",
802
+ f" generatedAt : {report['generatedAt']}",
803
+ " mode : "
804
+ + ", ".join(key for key, value in report["mode"].items() if value) or " mode : (none)",
805
+ ]
806
+ lines.append(" checks:")
807
+ for check in report["checks"]:
808
+ source = f" [{check['source']}]" if check.get("source") else ""
809
+ lines.append(
810
+ f" {_STATUS_GLYPH.get(check['status'], check['status']):4} "
811
+ f"{check['id']}: {check['detail']}{source}"
812
+ )
813
+ lines.append(f" overall : {report['overallStatus'].upper()}")
814
+ lines.append(
815
+ " safety : non-custodial, testnet-only, no signing/broadcast, network checks opt-in"
816
+ )
817
+ return "\n".join(lines)
818
+
819
+
820
+ def markdown_cell(value: Any) -> str:
821
+ """Escape a value for a single-line Markdown table cell."""
822
+ escaped = html.escape(str(value), quote=True)
823
+ for token in ("\\", "|", "`", "*", "_", "[", "]"):
824
+ escaped = escaped.replace(token, "\\" + token)
825
+ return escaped.replace("\r", " ").replace("\n", " ")
826
+
827
+
828
+ def render_markdown(report: dict[str, Any]) -> str:
829
+ enabled_modes = [key for key, value in report["mode"].items() if value]
830
+ lines = [
831
+ "# Arc Builder Doctor",
832
+ "",
833
+ f"- **Overall:** `{markdown_cell(report['overallStatus'].upper())}`",
834
+ f"- **Generated:** `{markdown_cell(report['generatedAt'])}`",
835
+ f"- **Mode:** `{markdown_cell(', '.join(enabled_modes) or 'none')}`",
836
+ "",
837
+ "## Checks",
838
+ "",
839
+ "| Status | Check | Detail | Source | Duration |",
840
+ "| --- | --- | --- | --- | ---: |",
841
+ ]
842
+ for check in report["checks"]:
843
+ lines.append(
844
+ "| "
845
+ + " | ".join(
846
+ (
847
+ markdown_cell(_STATUS_GLYPH.get(check["status"], check["status"])),
848
+ markdown_cell(check["id"]),
849
+ markdown_cell(check["detail"]),
850
+ markdown_cell(check.get("source", "")),
851
+ markdown_cell(f"{check['durationMs']} ms"),
852
+ )
853
+ )
854
+ + " |"
855
+ )
856
+ lines.extend(
857
+ (
858
+ "",
859
+ "## Safety Boundaries",
860
+ "",
861
+ "| Boundary | Enabled |",
862
+ "| --- | --- |",
863
+ )
864
+ )
865
+ for key, value in report["safety"].items():
866
+ lines.append(f"| {markdown_cell(key)} | {markdown_cell(str(value).lower())} |")
867
+ return "\n".join(lines)
868
+
869
+
870
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
871
+ parser = argparse.ArgumentParser(
872
+ prog="arc_builder_doctor",
873
+ description=(
874
+ "Arc Builder Doctor: one safe local health command for the Arc MCP "
875
+ "builder kit. Default mode makes zero network calls. Optional Arc "
876
+ "Testnet RPC and public-site checks are opt-in and read-only; they "
877
+ "never connect a wallet, sign, or broadcast a transaction."
878
+ ),
879
+ )
880
+ output = parser.add_mutually_exclusive_group()
881
+ output.add_argument("--json", action="store_true", help="print only the JSON report to stdout")
882
+ output.add_argument(
883
+ "--markdown",
884
+ action="store_true",
885
+ help="print a Markdown report suitable for CI summaries or PR comments",
886
+ )
887
+ parser.add_argument(
888
+ "--full",
889
+ action="store_true",
890
+ help="also run the canonical regression suite (scripts/test_all.py)",
891
+ )
892
+ parser.add_argument(
893
+ "--include-arc-rpc",
894
+ action="store_true",
895
+ help="opt in to a read-only Arc Testnet RPC chain-id check",
896
+ )
897
+ parser.add_argument(
898
+ "--include-public-site",
899
+ action="store_true",
900
+ help="opt in to read-only GET checks of the public GitHub Pages site",
901
+ )
902
+ parser.add_argument(
903
+ "--strict",
904
+ action="store_true",
905
+ help="treat an unavailable requested optional network check as a failure",
906
+ )
907
+ parser.add_argument(
908
+ "--network-timeout",
909
+ type=int,
910
+ default=DEFAULT_NETWORK_TIMEOUT,
911
+ help=f"per-request network timeout in seconds (1-{MAX_NETWORK_TIMEOUT})",
912
+ )
913
+ return parser.parse_args(argv)
914
+
915
+
916
+ def main(argv: list[str] | None = None) -> int:
917
+ args = parse_args(argv)
918
+ options = Options(
919
+ full=args.full,
920
+ include_arc_rpc=args.include_arc_rpc,
921
+ include_public_site=args.include_public_site,
922
+ strict=args.strict,
923
+ network_timeout=args.network_timeout,
924
+ )
925
+ report = build_report(options)
926
+ if args.json:
927
+ print(json.dumps(report, indent=2))
928
+ elif args.markdown:
929
+ print(render_markdown(report))
930
+ else:
931
+ print(render_human(report))
932
+ return 1 if report["overallStatus"] == STATUS_FAIL else 0
933
+
934
+
935
+ if __name__ == "__main__":
936
+ raise SystemExit(main())