Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ agent-runtime/
│ ├── events.py # Observability event system
│ ├── observability.py # Metrics and logging
│ ├── cli.py # CLI entry point
│ ├── serving.py # HTTP + HTTPS listeners with one shutdown
│ ├── tls.py # Loopback certificate and macOS keychain trust (Safari)
│ ├── api/
│ │ ├── health.py # Health check endpoints
│ │ ├── kernel.py # Kernel lifecycle endpoints
Expand Down
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ python -m agent_runtime.server

The runtime will start on `http://localhost:9477` by default.

### HTTPS for Safari

Safari blocks HTTPS pages from calling `http://127.0.0.1`, so a course page on
an HTTPS site cannot reach the runtime there. Run this once:

```bash
agent-runtime tls setup
```

It creates a certificate valid only for 127.0.0.1, ::1 and localhost (not a
certificate authority, 397 days) and, on macOS, trusts it for SSL in your login
keychain, which asks for your password. From then on `agent-runtime serve` also
listens on `https://127.0.0.1:9478`. `agent-runtime tls status` shows the
certificate and its expiry; `tls setup` renews it; `tls remove` untrusts and
deletes it. Chrome and Firefox reach `http://127.0.0.1:9477` without this step.

### Pairing

Loopback web apps (`localhost`, `127.0.0.1`, or `::1`, on any port) are trusted
Expand Down Expand Up @@ -105,14 +121,14 @@ agent-runtime serve
### JavaScript Example

```javascript
const RUNTIME_URL = 'http://127.0.0.1:9477';
// HTTPS (after `agent-runtime tls setup`) is the only address Safari can reach from
// an HTTPS page; Chrome and Firefox reach both. No targetAddressSpace option is
// needed: 127.0.0.1 is loopback, and Chrome rejects the "local" annotation for it.
const RUNTIME_URL = 'https://127.0.0.1:9478'; // or 'http://127.0.0.1:9477'

// Required when this page is served from a non-loopback origin.
async function pair() {
const res = await fetch(`${RUNTIME_URL}/pairing/request`, {
method: 'POST',
targetAddressSpace: 'local'
});
const res = await fetch(`${RUNTIME_URL}/pairing/request`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).detail);
return (await res.json()).token;
}
Expand Down Expand Up @@ -200,6 +216,7 @@ await executeStreaming('my-session', 'for i in range(5): print(i)', (output) =>
|----------|---------|-------------|
| `AGENT_RUNTIME_HOST` | `127.0.0.1` | Server bind address |
| `AGENT_RUNTIME_PORT` | `9477` | Server port |
| `AGENT_RUNTIME_HTTPS_PORT` | `9478` | HTTPS port, used once `agent-runtime tls setup` has run |
| `AGENT_RUNTIME_REQUIRE_PAIRING` | `true` | Require origin approval |
| `AGENT_RUNTIME_DEBUG` | `false` | Enable debug mode with hot reload |

Expand Down
58 changes: 43 additions & 15 deletions docs/integration.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Browser Integration

Agent Runtime exposes a vendor-neutral HTTP API at `http://127.0.0.1:9477`.
It is intended for browser-based courses and labs that execute Python on the
learner's own computer.
Agent Runtime exposes a vendor-neutral HTTP API at `http://127.0.0.1:9477` and,
once `agent-runtime tls setup` has run, the same API over HTTPS at
`https://127.0.0.1:9478`. It is intended for browser-based courses and labs
that execute Python on the learner's own computer.

## Connection flow

1. Call `GET /health` to detect the local runtime.
1. Call `GET /health` to detect the local runtime: `https://127.0.0.1:9478`
first, then `http://127.0.0.1:9477`. Safari reaches only the HTTPS address
(see below); Chrome and Firefox reach both.
2. If the page has a loopback origin, call runtime endpoints directly.
3. Otherwise, call `POST /pairing/request` and tell the learner to review the
approval prompt in the terminal that started Agent Runtime.
Expand All @@ -19,20 +22,27 @@ The browser cannot select the origin being approved. The runtime reads the
browser-controlled `Origin` header and binds the token to that exact value.

```javascript
const runtimeUrl = "http://127.0.0.1:9477";
// HTTPS first (the only one Safari allows from an HTTPS page), then HTTP.
async function findRuntime() {
for (const url of ["https://127.0.0.1:9478", "http://127.0.0.1:9477"]) {
try {
if ((await fetch(`${url}/health`)).ok) return url;
} catch {
// not listening, blocked by the browser, or the certificate is not trusted
}
}
return null;
}

async function pairRuntime() {
const response = await fetch(`${runtimeUrl}/pairing/request`, {
method: "POST",
targetAddressSpace: "local",
});
async function pairRuntime(runtimeUrl) {
const response = await fetch(`${runtimeUrl}/pairing/request`, { method: "POST" });
if (!response.ok) {
throw new Error((await response.json()).detail);
}
return (await response.json()).token;
}

async function runCell(token, labId, code) {
async function runCell(runtimeUrl, token, labId, code) {
const response = await fetch(`${runtimeUrl}/cell/run`, {
method: "POST",
headers: {
Expand All @@ -50,10 +60,28 @@ async function runCell(token, labId, code) {

## Browser security behavior

Chrome 142 and later asks the learner for Local Network Access permission when
a public site first contacts loopback. The `targetAddressSpace: "local"`
annotation declares that intent. Other browsers may still use Private Network
Access preflights, which the runtime answers for compatibility.
Measured on macOS, 2026-09-19, from an HTTPS page on a public origin:

| Browser | `http://127.0.0.1:9477` | `https://127.0.0.1:9478` (after `tls setup`) |
|---|---|---|
| Chrome 152 | works after the Local Network Access prompt | works |
| Firefox 155 | works | works |
| Safari 26.6 | blocked as mixed content (`TypeError: Load failed`) | works |

- **Chrome** (142 and later) asks the learner for Local Network Access
permission the first time a public site contacts loopback. If the learner
declines, requests fail with a CORS "Permission was denied" error. Do not pass
`targetAddressSpace: "local"`: `127.0.0.1` is in the `loopback` address
space, and Chrome 151 rejects the mismatch ("Request had a target IP address
space of `local` yet the resource is in address space `loopback`"). No
annotation is needed.
- **Safari** treats `http://127.0.0.1` as insecure content and blocks it from
HTTPS pages. `agent-runtime tls setup` creates a certificate for 127.0.0.1,
::1 and localhost and trusts it in the login keychain; `agent-runtime serve`
then also listens on `https://127.0.0.1:9478`.
- Browsers that still use Private Network Access preflights get the
`Access-Control-Allow-Private-Network` response the runtime sends for
compatibility.

CORS enables transport; it does not grant execution authority. Protected
endpoints still require either a loopback page origin or a bearer token issued
Expand Down
21 changes: 21 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ class ExecuteRequest(BaseModel):
cell_id: Optional[str] = None
```

### 7. Loopback TLS Certificate

`agent-runtime tls setup` lets Safari reach the runtime from HTTPS pages (it
blocks `http://127.0.0.1` there as mixed content). The certificate is:

- self-signed and generated on the machine; the private key stays in
`<runtime dir>/tls/` (directory `0700`, key `0600`) and is never sent anywhere;
- valid only for `127.0.0.1`, `::1` and `localhost`, for server authentication;
- not a certificate authority (`basicConstraints CA:FALSE`, no `keyCertSign`),
so trusting it cannot make the machine accept a certificate for any other
name, even if the key were stolen;
- valid for 397 days; `tls setup` renews it within 30 days of expiry.

On macOS it is added to the login keychain with SSL trust only. The HTTPS
listener serves the same app as the HTTP one: pairing and bearer tokens apply
unchanged. `agent-runtime tls remove` removes the trust setting, the keychain
entry and the files.

## Known Limitations

### 1. No Code Sandboxing
Expand Down Expand Up @@ -203,6 +221,9 @@ agent-runtime pairing revoke https://suspicious-origin.com

# Nuclear option - delete all pairing data
rm ~/.agent-runtime/paired_origins.json

# Untrust and delete the loopback HTTPS certificate
agent-runtime tls remove
```

### Cleaning Up Environments
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"httpx>=0.26.0",
"cryptography>=42.0.0",
]

[project.optional-dependencies]
Expand Down
3 changes: 2 additions & 1 deletion scripts/install-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,9 @@ print_success() {
echo "Quick start:"
echo " ${BLUE}agent-runtime serve${NC} # Start the runtime"
echo " ${BLUE}agent-runtime serve --no-pairing${NC} # Start without pairing (dev mode)"
echo " ${BLUE}agent-runtime tls setup${NC} # Once: HTTPS on 127.0.0.1:9478, needed by Safari"
echo ""
echo "Runtime will be available at: http://localhost:9477"
echo "Runtime will be available at: http://localhost:9477 (and https://127.0.0.1:9478 after tls setup)"
echo ""
echo "For more information:"
echo " ${BLUE}agent-runtime --help${NC}"
Expand Down
140 changes: 134 additions & 6 deletions src/agent_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ def main() -> None:
action="store_true",
help="Disable pairing requirement (for development)",
)
serve_parser.add_argument(
"--https-port",
type=int,
default=settings.https_port,
help=f"HTTPS port, used once 'tls setup' has run (default: {settings.https_port})",
)
serve_parser.add_argument(
"--no-https",
action="store_true",
help="Serve HTTP only, even when a TLS certificate exists",
)

# tls commands
tls_parser = subparsers.add_parser(
"tls", help="Manage the loopback HTTPS certificate (needed for Safari)"
)
tls_subparsers = tls_parser.add_subparsers(dest="tls_command")
tls_setup = tls_subparsers.add_parser(
"setup", help="Create the certificate and, on macOS, trust it in the login keychain"
)
tls_setup.add_argument("--force", action="store_true", help="Replace an existing certificate")
tls_setup.add_argument(
"--no-trust", action="store_true", help="Create the certificate without trusting it"
)
tls_subparsers.add_parser("status", help="Show the certificate, its expiry and trust")
tls_subparsers.add_parser("remove", help="Untrust and delete the certificate")

# env commands
env_parser = subparsers.add_parser("env", help="Manage environments")
Expand Down Expand Up @@ -75,15 +101,20 @@ def main() -> None:
run_env(args)
elif args.command == "pairing":
run_pairing(args)
elif args.command == "tls":
run_tls(args)
else:
parser.print_help()
sys.exit(1)


def run_serve(args: argparse.Namespace) -> None:
"""Run the server."""
"""Run the server: HTTP, plus HTTPS on loopback once `tls setup` has created a certificate."""
import asyncio

import uvicorn

from agent_runtime import tls
from agent_runtime.config import settings as cfg

setup_logging(structured=False)
Expand All @@ -92,17 +123,114 @@ def run_serve(args: argparse.Namespace) -> None:
if args.no_pairing:
cfg.require_pairing = False

cert = None if args.no_https or args.debug else tls.load(cfg.tls_cert_file, cfg.tls_key_file)
https_note = ""
if cert and _port_in_use(args.host, args.https_port):
https_note = f"HTTPS: port {args.https_port} is in use, serving HTTP only"
cert = None
elif cert and cert.expires_within(0):
https_note = "HTTPS: the certificate has expired; run 'agent-runtime tls setup'"
cert = None
elif cert and cert.expires_within(tls.RENEW_BEFORE_DAYS):
https_note = "HTTPS: the certificate expires soon; 'agent-runtime tls setup' renews it"
elif not cert and not args.no_https and tls.trust_supported():
https_note = "HTTPS: off. Safari needs it; run 'agent-runtime tls setup' once to enable it"

print(f"Starting Agent Runtime on http://{args.host}:{args.port}")
if cert:
print(f" and https://{args.host}:{args.https_port}")
if https_note:
print(https_note)
print(f"Pairing required: {cfg.require_pairing}")
print(f"Debug mode: {args.debug}")
print()

uvicorn.run(
"agent_runtime.server:app",
host=args.host,
port=args.port,
reload=args.debug,
if args.debug:
# Auto-reload needs uvicorn's own runner (an import string, one listener).
uvicorn.run("agent_runtime.server:app", host=args.host, port=args.port, reload=True)
return

from agent_runtime.server import app
from agent_runtime.serving import build_listeners, serve_all

listeners = build_listeners(
app,
args.host,
args.port,
https_port=args.https_port if cert else None,
certfile=str(cert.cert_path) if cert else None,
keyfile=str(cert.key_path) if cert else None,
)
asyncio.run(serve_all(listeners))


def _port_in_use(host: str, port: int) -> bool:
import socket

with socket.socket() as sock:
sock.settimeout(0.3)
return sock.connect_ex((host, port)) == 0


def run_tls(args: argparse.Namespace) -> None:
"""Handle tls commands."""
from agent_runtime import tls
from agent_runtime.config import settings as cfg

cert_path, key_path = cfg.tls_cert_file, cfg.tls_key_file

if args.tls_command == "setup":
info, created = tls.ensure(cert_path, key_path, force=args.force)
print(f"{'Created' if created else 'Using'} certificate {info.cert_path}")
print(f" valid for 127.0.0.1, ::1 and localhost until {info.not_after:%Y-%m-%d}")
print(f" SHA-256 {info.sha256}")
if args.no_trust:
print("Not trusted (--no-trust).")
elif not tls.trust_supported():
print(
"No trust step needed on this platform: Chrome and Firefox reach "
f"http://127.0.0.1:{cfg.port} directly. Safari, which needs HTTPS, is macOS only."
)
elif not created and tls.is_trusted(info):
print("Already trusted in the login keychain.")
else:
print("Adding it to the login keychain, trusted for SSL only.")
print("macOS asks for your password.")
try:
tls.trust(info)
except tls.TLSError as error:
print(f"Could not trust the certificate: {error}")
sys.exit(1)
print("Trusted.")
print(
f"Restart 'agent-runtime serve'; it also listens on https://127.0.0.1:{cfg.https_port}."
)

elif args.tls_command == "status":
found = tls.load(cert_path, key_path)
if found is None:
print("No certificate. Run 'agent-runtime tls setup' to enable HTTPS for Safari.")
return
trusted = tls.is_trusted(found)
state = {True: "trusted", False: "NOT trusted", None: "trust not checked on this platform"}
print(f"Certificate: {found.cert_path}")
renew = " (renew soon)" if found.expires_within(tls.RENEW_BEFORE_DAYS) else ""
print(f" expires {found.not_after:%Y-%m-%d}{renew}")
print(f" SHA-256 {found.sha256}")
print(f" {state[trusted]}")

elif args.tls_command == "remove":
found = tls.load(cert_path, key_path)
if found is None:
print("No certificate to remove.")
return
tls.untrust(found)
tls.remove_files(found)
print("Removed the certificate and its keychain trust. The runtime serves HTTP only.")

else:
print("Usage: agent-runtime tls [setup|status|remove]")
sys.exit(1)


def run_env(args: argparse.Namespace) -> None:
Expand Down
Loading
Loading