diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2423c..b0282e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +### Added + +- `halvorai-ingest init` (also `python -m halvorai_ingest init`) and `run_init()`: generate a source key, register it with `POST /v1/sources`, write `.env.local` and send a test event, as `npx @halvorai/ingest init` does. Python users no longer need Node.js to connect to HalvorAI. + ## 0.2.0 (unreleased) ### Breaking diff --git a/README.md b/README.md index 28d1c60..8bd3d5a 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,15 @@ halvor.flush(timeout=5) ## Send to HalvorAI (early access) -With a sandbox key (`hk_test_…`) and a source key registered in the dashboard: +With a sandbox key (`hk_test_…`), register this machine as a source. `init` creates an Ed25519 key here, registers its public half, writes `HALVOR_API_KEY` and `HALVOR_SOURCE_KEY` to `.env.local`, sends one test event and prints a dashboard link: + +```bash +halvorai-ingest init # or: python -m halvorai_ingest init +``` + +It asks for the key unless `--api-key` or `HALVOR_API_KEY` is set. Other options: `--endpoint`, `--name`, `--cwd`, `--dashboard`. The private key never leaves your machine; keep it in your secret manager. + +Then load `.env.local` (or set the same variables) and create the client: ```python import os diff --git a/halvorai_ingest/__init__.py b/halvorai_ingest/__init__.py index 5dea8b9..e4f3138 100644 --- a/halvorai_ingest/__init__.py +++ b/halvorai_ingest/__init__.py @@ -2,6 +2,7 @@ from .client import ClientStats, HalvorClient from .context import agent_context, current_context +from .init import InitResult, run_init from .hash import evidence_sha256, hash_instruction from .redact import redact_deep, redact_text from .sign import generate_source_key, sign_event, verify_event @@ -21,4 +22,6 @@ "generate_source_key", "sign_event", "verify_event", + "run_init", + "InitResult", ] diff --git a/halvorai_ingest/__main__.py b/halvorai_ingest/__main__.py new file mode 100644 index 0000000..dd8a8c9 --- /dev/null +++ b/halvorai_ingest/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .cli import main + +sys.exit(main()) diff --git a/halvorai_ingest/cli.py b/halvorai_ingest/cli.py new file mode 100644 index 0000000..01c0b63 --- /dev/null +++ b/halvorai_ingest/cli.py @@ -0,0 +1,43 @@ +"""Command line: `halvorai-ingest init` (or `python -m halvorai_ingest init`).""" + +from __future__ import annotations + +import argparse +import getpass +import os +import sys + +from .init import run_init + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="halvorai-ingest") + commands = parser.add_subparsers(dest="command", required=True) + init = commands.add_parser( + "init", + help="register this machine as a source, write .env.local and send a test event", + ) + init.add_argument("--api-key", help="hk_test_… or hk_live_… (default: HALVOR_API_KEY, else asks)") + init.add_argument("--endpoint", help="API base URL (default: HALVOR_ENDPOINT, else from the key)") + init.add_argument("--dashboard", help="dashboard origin for the printed link") + init.add_argument("--name", help="source name (default: this machine's hostname)") + init.add_argument("--cwd", default=".", help="folder for .env.local (default: current folder)") + args = parser.parse_args(argv) + + api_key = args.api_key or os.environ.get("HALVOR_API_KEY") or getpass.getpass("API key: ").strip() + try: + run_init( + api_key, + cwd=args.cwd, + endpoint=args.endpoint or os.environ.get("HALVOR_ENDPOINT") or None, + name=args.name, + dashboard=args.dashboard, + ) + except Exception as exc: + print(f"halvorai-ingest init failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/halvorai_ingest/init.py b/halvorai_ingest/init.py new file mode 100644 index 0000000..cbaabd8 --- /dev/null +++ b/halvorai_ingest/init.py @@ -0,0 +1,169 @@ +"""`halvorai-ingest init`: register this machine as a source, as `npx @halvorai/ingest init` does.""" + +from __future__ import annotations + +import json +import re +import socket +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, urlsplit + +import httpx + +from .client import API_KEY_PATTERN, HalvorClient, default_endpoint +from .sign import generate_source_key + +DASHBOARD_URL = "https://app.halvorai.com" +SANDBOX_DASHBOARD_URL = "https://app.sandbox.halvorai.com" + + +@dataclass +class InitResult: + source_id: str + event_id: str + env_path: Path + dashboard_url: str + endpoint: str + environment: str + + +def dashboard_for(endpoint: str) -> str: + """The dashboard that belongs to an API endpoint, as in the Node.js SDK.""" + url = urlsplit(endpoint) + host = url.hostname or "" + if host == "api.halvorai.com" or not url.scheme: + return DASHBOARD_URL + if host == "api.sandbox.halvorai.com": + return SANDBOX_DASHBOARD_URL + # The local dashboard is signed in to at localhost:3000, whatever loopback name the API has. + if url.port == 8787: + loopback = host in ("127.0.0.1", "localhost", "::1") + return f"{url.scheme}://{'localhost' if loopback else host}:3000" + if host.startswith("api."): + return f"{url.scheme}://app.{host[4:]}" + return f"{url.scheme}://{url.netloc}" + + +def upsert_env_file(path: Path, values: dict[str, str]) -> None: + """Set KEY=value lines in a dotenv file, keeping every other line.""" + path.parent.mkdir(parents=True, exist_ok=True) + content = path.read_text(encoding="utf-8") if path.exists() else "" + if content and not content.endswith("\n"): + content += "\n" + for key, value in values.items(): + quoted = ( + "'" + value.replace("'", "'\\''") + "'" + if re.search(r"[\s\"'`#]", value) + else value + ) + line = f"{key}={quoted}" + pattern = re.compile(rf"^{re.escape(key)}=.*$", re.MULTILINE) + if pattern.search(content): + content = pattern.sub(lambda _: line, content, count=1) + else: + content += line + "\n" + path.write_text(content, encoding="utf-8") + + +def _register_source( + http: httpx.Client, endpoint: str, api_key: str, name: str, public_jwk: dict[str, str] +) -> str: + res = http.post( + f"{endpoint}/v1/sources", + json={"name": name, "adapter": "custom", "public_jwk": public_jwk}, + headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"}, + ) + if res.status_code != 201: + raise RuntimeError(f"POST /v1/sources failed ({res.status_code}): {res.text or res.reason_phrase}") + source_id = res.json().get("id") + if not isinstance(source_id, str) or not source_id.startswith("src_"): + raise RuntimeError("POST /v1/sources returned no source id") + return source_id + + +def run_init( + api_key: str, + *, + cwd: str | Path = ".", + endpoint: str | None = None, + name: str | None = None, + dashboard: str | None = None, + transport: httpx.BaseTransport | None = None, + log: Callable[[str], None] = print, +) -> InitResult: + """ + Generate an Ed25519 source key, register its public half (`POST /v1/sources`), write + `HALVOR_API_KEY` and `HALVOR_SOURCE_KEY` to `.env.local`, and send one test event. + The private key never leaves this machine. + """ + if not API_KEY_PATTERN.match(api_key): + raise ValueError("api key must look like hk_test_… or hk_live_…") + environment = "live" if api_key.startswith("hk_live_") else "test" + base = (endpoint or default_endpoint(api_key, environment)).rstrip("/") + source_name = ((name or "").strip() or socket.gethostname() or "local")[:100] + + log("Generating Ed25519 source key…") + private_jwk: dict[str, Any] = dict(generate_source_key()) + public_jwk = {k: v for k, v in private_jwk.items() if k not in ("d", "kid")} + + log(f"Registering source at {base}…") + with httpx.Client(timeout=30.0, transport=transport) as http: + source_id = _register_source(http, base, api_key, source_name, public_jwk) + private_jwk["kid"] = source_id + + env_path = Path(cwd).resolve() / ".env.local" + values = {"HALVOR_API_KEY": api_key, "HALVOR_SOURCE_KEY": json.dumps(private_jwk, separators=(",", ":"))} + # Saved only when chosen, so a later HalvorClient() talks to the same API. + if endpoint is not None: + values["HALVOR_ENDPOINT"] = base + upsert_env_file(env_path, values) + log(f"Wrote {env_path}") + + log("Sending a test event…") + errors: list[str] = [] + client = HalvorClient( + api_key=api_key, + source_key=private_jwk, + endpoint=base, + transport=transport, + exit_hook=False, + on_error=lambda e: errors.append(f"{e.get('code')}: {e.get('message')}"), + ) + event_id = client.emit( + { + "type": "payment.settled", + "agent": {"id": "agt_halvor_init", "name": "HalvorAI init"}, + "owner": {"id": "own_halvor_init", "type": "business", "display_name": "HalvorAI init"}, + "payment": { + "payment_id": f"init:{source_id}", + "rail": "x402", + "amount": { + "value": "1", + "unit": "atomic", + "asset": { + "network": "eip155:84532", + "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "symbol": "USDC", + "decimals": 6, + }, + }, + "status": "settled", + "description": "HalvorAI init test event", + "rail_ref": {"scheme": "exact", "network": "eip155:84532"}, + }, + "payee": {"type": "api_provider", "name": "HalvorAI init"}, + } + ) + flushed = client.flush(timeout=30) + client.close(timeout=1) + if not flushed["drained"] or errors: + raise RuntimeError(f"test event was not accepted: {'; '.join(errors) or 'flush timed out'}") + + dashboard_url = f"{(dashboard or dashboard_for(base)).rstrip('/')}/payments?event={quote(event_id)}" + log("") + log(f"Source {source_id} is ready.") + log(f"See it in the dashboard: {dashboard_url}") + log(f"First event id: {event_id}") + return InitResult(source_id, event_id, env_path, dashboard_url, base, environment) diff --git a/pyproject.toml b/pyproject.toml index 5758d54..2891952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "halvorai-ingest" -version = "0.3.0" +version = "0.4.0" description = "Signed, attributed payment events for AI agents. Never in the payment path." readme = "README.md" requires-python = ">=3.10" @@ -30,6 +30,9 @@ dependencies = [ "rfc8785>=0.1.4", ] +[project.scripts] +halvorai-ingest = "halvorai_ingest.cli:main" + [project.urls] Homepage = "https://halvorai.com" Documentation = "https://docs.halvorai.com" diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..a5b679e --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from halvorai_ingest import run_init, verify_event +from halvorai_ingest.cli import main +from halvorai_ingest.init import dashboard_for, upsert_env_file + +API_KEY = "hk_test_" + "a" * 32 + + +class FakeApi: + def __init__(self, *, source_status: int = 201, events_status: int = 202) -> None: + self.source_status = source_status + self.events_status = events_status + self.public_jwk: dict[str, Any] | None = None + self.envelopes: list[dict[str, Any]] = [] + self.urls: list[str] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.urls.append(str(request.url)) + assert request.headers["authorization"] == f"Bearer {API_KEY}" + body = json.loads(request.content) + if request.url.path == "/v1/sources": + assert body["adapter"] == "custom" + self.public_jwk = body["public_jwk"] + if self.source_status != 201: + return httpx.Response(self.source_status, json={"error": {"code": "forbidden"}}) + return httpx.Response(201, json={"id": "src_01TEST"}) + if request.url.path == "/v1/events": + self.envelopes.extend(body["events"]) + if self.events_status != 202: + return httpx.Response(self.events_status, json={"error": {"code": "unauthorized"}}) + results = [{"id": e["event"]["id"], "status": "accepted"} for e in body["events"]] + return httpx.Response(202, json={"results": results}) + return httpx.Response(404) + + +def test_init_registers_the_public_key_writes_env_and_sends_a_signed_event(tmp_path: Path) -> None: + api = FakeApi() + lines: list[str] = [] + result = run_init(API_KEY, cwd=tmp_path, name="ci box", transport=httpx.MockTransport(api), log=lines.append) + + assert api.urls[0] == "https://api.sandbox.halvorai.com/v1/sources" + assert api.public_jwk is not None and "d" not in api.public_jwk + assert result.source_id == "src_01TEST" + assert result.environment == "test" + assert result.dashboard_url == f"https://app.sandbox.halvorai.com/payments?event={result.event_id}" + + env = (tmp_path / ".env.local").read_text(encoding="utf-8") + assert f"HALVOR_API_KEY={API_KEY}\n" in env + assert "HALVOR_ENDPOINT" not in env + raw = env.split("HALVOR_SOURCE_KEY=", 1)[1].strip() + private_jwk = json.loads(raw.strip("'")) + assert private_jwk["kid"] == "src_01TEST" and private_jwk["x"] == api.public_jwk["x"] + + [envelope] = api.envelopes + assert envelope["event"]["id"] == result.event_id + assert envelope["event"]["source"]["id"] == "src_01TEST" + assert verify_event(envelope["event"], envelope["sig"], api.public_jwk) + assert any("Source src_01TEST is ready." in line for line in lines) + + +def test_init_keeps_other_env_lines_and_saves_a_chosen_endpoint(tmp_path: Path) -> None: + (tmp_path / ".env.local").write_text("OTHER=1\nHALVOR_API_KEY=old", encoding="utf-8") + api = FakeApi() + result = run_init( + API_KEY, + cwd=tmp_path, + endpoint="http://127.0.0.1:8787/", + transport=httpx.MockTransport(api), + log=lambda _: None, + ) + env = (tmp_path / ".env.local").read_text(encoding="utf-8") + assert env.startswith("OTHER=1\n") + assert env.count("HALVOR_API_KEY=") == 1 and "old" not in env + assert "HALVOR_ENDPOINT=http://127.0.0.1:8787\n" in env + assert api.urls[0] == "http://127.0.0.1:8787/v1/sources" + assert result.dashboard_url.startswith("http://localhost:3000/payments?event=") + + +def test_init_reports_a_refused_registration_and_writes_nothing(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match=r"POST /v1/sources failed \(403\)"): + run_init(API_KEY, cwd=tmp_path, transport=httpx.MockTransport(FakeApi(source_status=403)), log=lambda _: None) + assert not (tmp_path / ".env.local").exists() + + +def test_init_fails_when_the_test_event_is_refused(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="test event was not accepted"): + run_init(API_KEY, cwd=tmp_path, transport=httpx.MockTransport(FakeApi(events_status=401)), log=lambda _: None) + + +def test_init_rejects_a_malformed_key(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="hk_test_"): + run_init("sk_live_nope", cwd=tmp_path, log=lambda _: None) + + +def test_cli_exits_non_zero_with_a_message(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + assert main(["init", "--api-key", "bad", "--cwd", str(tmp_path)]) == 1 + assert "halvorai-ingest init failed" in capsys.readouterr().err + + +def test_dashboard_for_matches_the_node_sdk() -> None: + assert dashboard_for("https://api.halvorai.com") == "https://app.halvorai.com" + assert dashboard_for("https://api.sandbox.halvorai.com") == "https://app.sandbox.halvorai.com" + assert dashboard_for("http://localhost:8787") == "http://localhost:3000" + assert dashboard_for("https://api.example.com") == "https://app.example.com" + assert dashboard_for("not a url") == "https://app.halvorai.com" + + +def test_upsert_quotes_values_with_spaces_or_quotes(tmp_path: Path) -> None: + path = tmp_path / "nested" / ".env.local" + upsert_env_file(path, {"A": "plain", "B": "it's spaced"}) + assert path.read_text(encoding="utf-8") == "A=plain\nB='it'\\''s spaced'\n" diff --git a/uv.lock b/uv.lock index 07732f4..37257ad 100644 --- a/uv.lock +++ b/uv.lock @@ -323,7 +323,7 @@ wheels = [ [[package]] name = "halvorai-ingest" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "cryptography" },