Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

10 KiB

agent-proxy

Standalone Hono / Node.js service that replaces the Python ASGI agent-proxy (products/tasks/backend/proxy.py). Serves the task-run live event plane: SSE stream reads for browsers and NDJSON event ingest from sandboxes, both backed by the same Redis streams that Django writes.

No Postgres, no Temporal client, no Celery — the service is a pure streaming plane. Side effects (Temporal heartbeats, awaiting-input push notifications) are delegated to a single internal Django callback endpoint.

Routes

Method Path Description
GET /v1/runs/:run/stream SSE read — streams task-run events to browsers
POST /v1/runs/:run/ingest NDJSON ingest — accepts events from sandbox agents
GET /_health Liveness probe
GET /_readyz Readiness probe (returns 503 while shutting down)
GET /health Liveness probe (alias)
GET /_metrics Prometheus metrics
OPTIONS * CORS preflight (204)

Tokens are accepted via Authorization: Bearer <token> on both legs. There is no ?token= query fallback: query strings leak into upstream infrastructure access logs, which would expose the run-scoped JWT. The browser uses fetch-event-source (which sets headers), not native EventSource.

Resume is via the Last-Event-ID header; ?start=latest tails from the newest available entry.

Environment variables

Variable Required Default Description
TASKS_REDIS_URL yes (prod) localhost:6379 Redis connection URL (unencrypted local Redis by default; use a TLS URL in production)
SANDBOX_JWT_PUBLIC_KEY yes — RS256 public key PEM (\n literals in env vars are normalized to real newlines before use)
AGENT_PROXY_DJANGO_CALLBACK_URL yes (prod) — Base URL of the Django service for side-effect callbacks (Temporal heartbeat, awaiting-input push), e.g. http://web:8000
AGENT_PROXY_CALLBACK_SECRET no '' Shared secret sent as X-Agent-Proxy-Secret on the Django callback; Django enforces it when the same value is set on both sides, so a sandbox cannot call the callback directly
TASKS_AGENT_PROXY_CORS_ORIGINS no '' Comma-separated allowed CORS origins; * allows all
PORT no 8003 HTTP listen port
HOST no 0.0.0.0 HTTP listen address
SHUTDOWN_GRACE_MS no 300000 Maximum drain budget on SIGTERM before force-exit (ms)
SHUTDOWN_PRESTOP_DELAY_MS no 0 Sleep before closing on SIGTERM (useful for Kubernetes preStop hooks)
NODE_ENV no — production enables strict startup checks
AGENT_PROXY_LOG_LEVEL no debug dev / info prod One of debug, info, warn, error. debug logs every connect / read / write

Running locally (end to end)

Both stream legs (browser read + sandbox ingest) route to this service when Django has the proxy URLs set and runs with DEBUG=True. Local dev disables the analytics SDK, so the URL settings are the opt-in (no feature flag needed locally).

All config is read from the repo-root .env, which flox loads into every process (DOTENV_FILE = ".env" in the flox manifest) — the same place Django, the Temporal worker, and the other services read from. There is no per-service .env to manage. (The dev script will also load an optional services/agent-proxy/.env override if one exists, mirroring services/mcp, but it is not needed.) flox caches the dotenv, so re-activate your shell or restart insightscli after editing .env.

1. Point Django at the proxy

In the repo-root .env (read by Django web and the Temporal worker):

DEBUG=1
TASKS_AGENT_PROXY_PUBLIC_URL=http://localhost:8003   # browser read leg
TASKS_AGENT_PROXY_INGEST_URL=http://localhost:8003   # sandbox ingest leg (auto-rewritten for the Docker sandbox)

2. Give this service its verify key + CORS

Same repo-root .env. This service is verify-only, so it needs the public half of Django's SANDBOX_JWT_PRIVATE_KEY. Derive it (double-quote the value, like the private key, so the PEM newlines survive flox's dotenv parser):

# from repo root, inside flox — prints the line to add to .env
python3 - <<'PY'
import re, pathlib
from cryptography.hazmat.primitives import serialization
priv = re.search(r'^SANDBOX_JWT_PRIVATE_KEY=(.*)$', pathlib.Path(".env").read_text(), re.M).group(1).strip().strip('"').strip("'")
key = serialization.load_pem_private_key(priv.replace("\\n", "\n").encode(), password=None)
pub = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode()
print('SANDBOX_JWT_PUBLIC_KEY="' + pub.replace(chr(10), "\\n") + '"')
PY

Add that line plus these to the root .env:

SANDBOX_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
TASKS_AGENT_PROXY_CORS_ORIGINS=http://localhost:8010
AGENT_PROXY_DJANGO_CALLBACK_URL=http://localhost:8000   # heartbeat + awaiting-input callbacks; omit to skip them

TASKS_REDIS_URL is optional locally — it defaults to localhost:6379, the same instance Django uses (the redis7 host maps to localhost).

3. Start

insightscli start runs this service automatically when the ai_features intent is enabled, listed as agent-proxy on :8003 (watch mode). Enable the intent with insightscli dev:setup if it is not already on.

insightscli start                  # Redis, Django (:8000), frontend (:8010), Temporal worker, agent-proxy (:8003)

To run only this service standalone (for example when ai_features is off):

insightscli start:agent-proxy      # this service on :8003 (watch mode); or: pnpm --filter @hanzo/agent-proxy dev

The proxy must be running before you start a task run. With the URL settings above there is no automatic failover: if the proxy is down the client fails to connect, it does not fall back to Django.

4. Verify both legs

Create a run at http://localhost:8010/project/1/tasks, then:

  • Read leg: in browser DevTools > Network the /v1/runs/:run/stream EventStream request host is localhost:8003 (not :8000). The /stream_token/ response carries stream_base_url: http://localhost:8003.
  • Write leg: the proxy console logs ingest activity as the sandbox POSTs, and events render live in the task UI.
  • Health: curl localhost:8003/_health and curl localhost:8003/_metrics.

Rolling back to Django

Unset TASKS_AGENT_PROXY_PUBLIC_URL and TASKS_AGENT_PROXY_INGEST_URL in the root .env and restart. No data migration is needed: the Redis stream format is identical, so the Django in-process path picks up seamlessly.

Building

# From repo root
docker build -f services/agent-proxy/Dockerfile -t insights-agent-proxy .

# Or build the JS bundle only (no Docker)
pnpm --filter @hanzo/agent-proxy build
# Output: services/agent-proxy/dist/agent-proxy-server.mjs

Testing

pnpm --filter @hanzo/agent-proxy test

Type checking

pnpm --filter @hanzo/agent-proxy typecheck

Cutover note

Browsers discover the proxy URL from the stream_base_url field returned by the Django stream_token endpoint. When TASKS_AGENT_PROXY_PUBLIC_URL is set on Django (read leg) it points to this service; the write leg routes here when TASKS_AGENT_PROXY_INGEST_URL is set. When unset, browsers and sandboxes use the Django in-process path instead — no client code changes are needed for the cutover.

The Redis stream key format (task-run-stream:{run_id}) and all companion keys are byte-identical between this Node service and the Python implementation. During the cutover window both services read and write the same streams safely.

To roll back: unset TASKS_AGENT_PROXY_PUBLIC_URL and TASKS_AGENT_PROXY_INGEST_URL on Django. No data migration is needed because the stream format is identical.

S3 hydration on resume gap is explicitly out of scope for this version — when a client's Last-Event-ID has been trimmed from Redis, the service emits a metric and continues reading from the oldest available entry, matching the current Python behavior exactly.