proxyagent 0.1.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ *.db
9
+ .proxyagent/
10
+ admin_token
11
+ .env
12
+ .DS_Store
13
+ .pytest_cache/
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: proxyagent
3
+ Version: 0.1.0
4
+ Summary: Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools.
5
+ Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
6
+ Author-email: Spawn Labs <teddy@spawnlabs.ai>
7
+ License-Expression: Apache-2.0
8
+ Keywords: agents,claude,codex,gateway,llm,proxy,security,tools
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: fastapi>=0.110
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: pydantic>=2.0
18
+ Requires-Dist: rich>=13.0
19
+ Requires-Dist: typer>=0.12
20
+ Requires-Dist: uvicorn[standard]>=0.27
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ <div align="center">
27
+
28
+ # proxyagent
29
+
30
+ **Run any agent — Claude, Codex, custom — on any machine, with _no API key on the machine._**
31
+
32
+ A secure, self-hosted proxy for models **and** tools. Your keys live in one hardened place; every machine holds only a scoped, revocable token.
33
+
34
+ </div>
35
+
36
+ ---
37
+
38
+ Agents need model access (and tool access) to do anything. Today that means scattering
39
+ real API keys across every machine an agent runs on — a security nightmare. `proxyagent`
40
+ fixes it: stand up **one** proxy that holds the real credentials, and point every agent at
41
+ it. The machine gets a throwaway token; the real key never leaves the proxy.
42
+
43
+ ```
44
+ remote machine proxy (you host) upstream
45
+ ┌────────────────┐ token only ┌──────────────────┐ real key ┌───────────┐
46
+ │ claude / codex │ ───────────► │ proxyagent serve │ ─────────► │ Anthropic │
47
+ │ (no real key) │ ◄─────────── │ scope·log·tools │ ◄───────── │ OpenAI │
48
+ └────────────────┘ stream └──────────────────┘ └───────────┘
49
+ ```
50
+
51
+ ## How it works
52
+ Every harness honours `*_BASE_URL`, so the shim is trivial: point the base URL at the
53
+ proxy and use the **machine token** as the "api key." The proxy authenticates the token,
54
+ checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
55
+ machine never sees a real credential.
56
+
57
+ ## Quickstart
58
+
59
+ **1. Run the proxy** (on a box you control — it holds the real keys):
60
+ ```bash
61
+ pip install proxyagent
62
+ export ANTHROPIC_API_KEY=sk-ant-… # and/or OPENAI_API_KEY=sk-…
63
+ proxyagent serve # prints an admin token + a dashboard at :8080
64
+ ```
65
+
66
+ **2. Mint a machine token** (scoped + revocable):
67
+ ```bash
68
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
69
+ ```
70
+
71
+ **3. Run any agent on any machine — no real key there:**
72
+ ```bash
73
+ PROXYAGENT_TOKEN=pa_… proxyagent run claude-code \
74
+ --goal "build a SwiftUI todo app" --proxy https://proxy.you.com
75
+ # or: proxyagent run codex --goal "fix the failing tests" --token pa_…
76
+ ```
77
+
78
+ Or use any harness directly — just set the env and the proxy does the rest:
79
+ ```bash
80
+ export ANTHROPIC_BASE_URL=https://proxy.you.com/anthropic
81
+ export ANTHROPIC_API_KEY=pa_… # the machine token, not the real key
82
+ claude -p "ship it"
83
+ ```
84
+
85
+ ## The dashboard
86
+ `proxyagent serve` ships a dashboard at `/` — mint/revoke tokens, watch live usage and a
87
+ full request audit log, see configured providers + proxied tools. Paste the admin token to
88
+ open it.
89
+
90
+ ## Proxied tools — the same trick, for tools
91
+ The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
92
+ web search (and custom tools) without ever holding the tool's credential.
93
+
94
+ ```bash
95
+ export TAVILY_API_KEY=tvly-… # web_search uses this; agents never see it
96
+ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","headers":{"Authorization":"Bearer …"}}]'
97
+ # then send requests with header x-proxyagent-tools: on → tool defs are injected;
98
+ # the proxy executes calls to managed tools server-side (keys stay here).
99
+ ```
100
+
101
+ ## Security model
102
+ - **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
103
+ - **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
104
+ - **Scoped** (`provider:model` globs), **expiring** (TTL), **revocable**, **rate-limited**.
105
+ - **Constant-time** token comparison; sensitive headers redacted from logs.
106
+ - Admin API + dashboard gated by a separate admin token. Run it behind TLS.
107
+
108
+ ## SDK
109
+ ```python
110
+ import proxyagent
111
+
112
+ # host the proxy (embed in your own service):
113
+ app = proxyagent.create_app() # ASGI app
114
+
115
+ # mint tokens programmatically:
116
+ admin = proxyagent.Admin("https://proxy.you.com", "pa_admin_…")
117
+ token = admin.mint("ci-runner", scope=["anthropic:claude-*"], ttl_seconds=3600)
118
+
119
+ # run a harness on this machine, no key here:
120
+ proxyagent.run("claude-code", goal="build the app",
121
+ proxy="https://proxy.you.com", token=token)
122
+ ```
123
+
124
+ ## Supported harnesses
125
+ `claude-code`, `codex`, and any **custom** command (`--command "my-agent {goal}"`). Adding one
126
+ is a few lines — it just needs to respect `*_BASE_URL`.
127
+
128
+ ## License
129
+ Apache-2.0
@@ -0,0 +1,104 @@
1
+ <div align="center">
2
+
3
+ # proxyagent
4
+
5
+ **Run any agent — Claude, Codex, custom — on any machine, with _no API key on the machine._**
6
+
7
+ A secure, self-hosted proxy for models **and** tools. Your keys live in one hardened place; every machine holds only a scoped, revocable token.
8
+
9
+ </div>
10
+
11
+ ---
12
+
13
+ Agents need model access (and tool access) to do anything. Today that means scattering
14
+ real API keys across every machine an agent runs on — a security nightmare. `proxyagent`
15
+ fixes it: stand up **one** proxy that holds the real credentials, and point every agent at
16
+ it. The machine gets a throwaway token; the real key never leaves the proxy.
17
+
18
+ ```
19
+ remote machine proxy (you host) upstream
20
+ ┌────────────────┐ token only ┌──────────────────┐ real key ┌───────────┐
21
+ │ claude / codex │ ───────────► │ proxyagent serve │ ─────────► │ Anthropic │
22
+ │ (no real key) │ ◄─────────── │ scope·log·tools │ ◄───────── │ OpenAI │
23
+ └────────────────┘ stream └──────────────────┘ └───────────┘
24
+ ```
25
+
26
+ ## How it works
27
+ Every harness honours `*_BASE_URL`, so the shim is trivial: point the base URL at the
28
+ proxy and use the **machine token** as the "api key." The proxy authenticates the token,
29
+ checks its scope, **swaps in the real key**, forwards upstream, and logs the call. The
30
+ machine never sees a real credential.
31
+
32
+ ## Quickstart
33
+
34
+ **1. Run the proxy** (on a box you control — it holds the real keys):
35
+ ```bash
36
+ pip install proxyagent
37
+ export ANTHROPIC_API_KEY=sk-ant-… # and/or OPENAI_API_KEY=sk-…
38
+ proxyagent serve # prints an admin token + a dashboard at :8080
39
+ ```
40
+
41
+ **2. Mint a machine token** (scoped + revocable):
42
+ ```bash
43
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
44
+ ```
45
+
46
+ **3. Run any agent on any machine — no real key there:**
47
+ ```bash
48
+ PROXYAGENT_TOKEN=pa_… proxyagent run claude-code \
49
+ --goal "build a SwiftUI todo app" --proxy https://proxy.you.com
50
+ # or: proxyagent run codex --goal "fix the failing tests" --token pa_…
51
+ ```
52
+
53
+ Or use any harness directly — just set the env and the proxy does the rest:
54
+ ```bash
55
+ export ANTHROPIC_BASE_URL=https://proxy.you.com/anthropic
56
+ export ANTHROPIC_API_KEY=pa_… # the machine token, not the real key
57
+ claude -p "ship it"
58
+ ```
59
+
60
+ ## The dashboard
61
+ `proxyagent serve` ships a dashboard at `/` — mint/revoke tokens, watch live usage and a
62
+ full request audit log, see configured providers + proxied tools. Paste the admin token to
63
+ open it.
64
+
65
+ ## Proxied tools — the same trick, for tools
66
+ The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
67
+ web search (and custom tools) without ever holding the tool's credential.
68
+
69
+ ```bash
70
+ export TAVILY_API_KEY=tvly-… # web_search uses this; agents never see it
71
+ export PROXYAGENT_TOOLS='[{"name":"crm","url":"https://hooks.you.com/crm","headers":{"Authorization":"Bearer …"}}]'
72
+ # then send requests with header x-proxyagent-tools: on → tool defs are injected;
73
+ # the proxy executes calls to managed tools server-side (keys stay here).
74
+ ```
75
+
76
+ ## Security model
77
+ - **Real keys never leave the proxy** — read from env, never persisted, never logged, never returned.
78
+ - **Machine tokens are stored hashed** (SHA-256); plaintext shown once. A stolen DB yields nothing usable.
79
+ - **Scoped** (`provider:model` globs), **expiring** (TTL), **revocable**, **rate-limited**.
80
+ - **Constant-time** token comparison; sensitive headers redacted from logs.
81
+ - Admin API + dashboard gated by a separate admin token. Run it behind TLS.
82
+
83
+ ## SDK
84
+ ```python
85
+ import proxyagent
86
+
87
+ # host the proxy (embed in your own service):
88
+ app = proxyagent.create_app() # ASGI app
89
+
90
+ # mint tokens programmatically:
91
+ admin = proxyagent.Admin("https://proxy.you.com", "pa_admin_…")
92
+ token = admin.mint("ci-runner", scope=["anthropic:claude-*"], ttl_seconds=3600)
93
+
94
+ # run a harness on this machine, no key here:
95
+ proxyagent.run("claude-code", goal="build the app",
96
+ proxy="https://proxy.you.com", token=token)
97
+ ```
98
+
99
+ ## Supported harnesses
100
+ `claude-code`, `codex`, and any **custom** command (`--command "my-agent {goal}"`). Adding one
101
+ is a few lines — it just needs to respect `*_BASE_URL`.
102
+
103
+ ## License
104
+ Apache-2.0
@@ -0,0 +1,65 @@
1
+ """proxyagent — run any agent (Claude, Codex, custom) on any machine, with no API
2
+ key on the machine. A secure, self-hosted proxy for models *and* tools.
3
+
4
+ # on the proxy host (holds the real keys):
5
+ import proxyagent
6
+ proxyagent.serve() # or: $ proxyagent serve
7
+
8
+ # on any remote machine (holds only a throwaway token):
9
+ proxyagent.run("claude-code", goal="build the app",
10
+ proxy="https://proxy.you.com", token="pa_…")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from .harness import run # noqa: F401 (the headline SDK call)
18
+
19
+ __version__ = "0.1.0"
20
+ __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
+
22
+
23
+ def create_app(config=None):
24
+ """The ASGI app, for embedding behind your own server."""
25
+ from .server import create_app as _c
26
+ return _c(config)
27
+
28
+
29
+ def serve(host: str = "127.0.0.1", port: int = 8080, config=None):
30
+ """Run the proxy + dashboard."""
31
+ import uvicorn
32
+ from .config import Config
33
+ cfg = config or Config.load()
34
+ uvicorn.run(create_app(cfg), host=host, port=port, log_level="warning")
35
+
36
+
37
+ def Config(): # noqa: N802 — convenience re-export
38
+ from .config import Config as _C
39
+ return _C.load()
40
+
41
+
42
+ class Admin:
43
+ """Programmatic admin client — mint/list/revoke tokens against a running proxy."""
44
+
45
+ def __init__(self, proxy: str, admin_token: str):
46
+ import httpx
47
+ self._c = httpx.Client(base_url=proxy.rstrip("/"),
48
+ headers={"x-admin-token": admin_token}, timeout=30)
49
+
50
+ def mint(self, label: str = "machine", scope: Optional[list] = None,
51
+ ttl_seconds: Optional[int] = None, rate_limit: int = 0) -> str:
52
+ r = self._c.post("/admin/tokens", json={
53
+ "label": label, "scope": scope or ["*"], "ttl_seconds": ttl_seconds,
54
+ "rate_limit": rate_limit})
55
+ r.raise_for_status()
56
+ return r.json()["token"]
57
+
58
+ def tokens(self) -> list:
59
+ return self._c.get("/admin/tokens").json()["tokens"]
60
+
61
+ def revoke(self, token_id: str) -> None:
62
+ self._c.delete(f"/admin/tokens/{token_id}").raise_for_status()
63
+
64
+ def logs(self, limit: int = 100) -> list:
65
+ return self._c.get("/admin/logs", params={"limit": limit}).json()["logs"]
@@ -0,0 +1,145 @@
1
+ """proxyagent CLI — serve the proxy, mint tokens, run harnesses, watch usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ import httpx
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ app = typer.Typer(help="Run any agent on any machine — with no API key on the machine.", no_args_is_help=True)
15
+ console = Console()
16
+ err = Console(stderr=True)
17
+
18
+
19
+ def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
20
+ admin = admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN")
21
+ if not admin:
22
+ err.print("[red]Need an admin token[/red] (--admin or PROXYAGENT_ADMIN_TOKEN). "
23
+ "It's printed when you run [bold]proxyagent serve[/bold].")
24
+ raise typer.Exit(1)
25
+ return httpx.Client(base_url=proxy.rstrip("/"), headers={"x-admin-token": admin}, timeout=30)
26
+
27
+
28
+ @app.command()
29
+ def serve(host: str = "127.0.0.1", port: int = 8080):
30
+ """Run the proxy server + dashboard."""
31
+ import uvicorn
32
+
33
+ from .config import Config
34
+ from .server import create_app
35
+
36
+ config = Config.load()
37
+ if config.admin_token_plain:
38
+ console.print(Panel.fit(
39
+ f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (shown once)\n"
40
+ f" [yellow]{config.admin_token_plain}[/yellow]\n\n"
41
+ f"[dim]Save it — you need it for the dashboard + `proxyagent token`.[/dim]",
42
+ border_style="green"))
43
+ console.print(f"[dim]Dashboard:[/dim] http://{host}:{port} "
44
+ f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none — set ANTHROPIC_API_KEY / OPENAI_API_KEY'}")
45
+ uvicorn.run(create_app(config), host=host, port=port, log_level="warning")
46
+
47
+
48
+ @app.command("run")
49
+ def run_harness(
50
+ harness: str = typer.Argument(..., help="claude-code | codex | <custom>"),
51
+ goal: str = typer.Option(..., "--goal", "-g", help="What the agent should do."),
52
+ proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
53
+ token: Optional[str] = typer.Option(None, "--token", help="Machine token (or PROXYAGENT_TOKEN)."),
54
+ command: Optional[str] = typer.Option(None, "--command", help="Custom harness command template."),
55
+ cwd: Optional[str] = typer.Option(None, "--cwd"),
56
+ ):
57
+ """Run a harness on THIS machine, pointed at the proxy (no real key needed here)."""
58
+ from . import harness as H
59
+
60
+ tok = token or os.environ.get("PROXYAGENT_TOKEN")
61
+ if not tok:
62
+ err.print("[red]Need a machine token[/red] (--token or PROXYAGENT_TOKEN).")
63
+ raise typer.Exit(1)
64
+ console.print(f"[dim]→ {harness} via {proxy} (no key on this machine)[/dim]")
65
+ code = H.run(harness, goal, proxy_url=proxy, token=tok, cwd=cwd, command=command)
66
+ raise typer.Exit(code)
67
+
68
+
69
+ token_app = typer.Typer(help="Mint / list / revoke machine tokens.")
70
+ app.add_typer(token_app, name="token")
71
+
72
+
73
+ @token_app.command("new")
74
+ def token_new(
75
+ label: str = typer.Argument("machine"),
76
+ scope: list[str] = typer.Option(["*"], "--scope", help="Allowed provider:model globs, e.g. anthropic:claude-*"),
77
+ ttl: Optional[int] = typer.Option(None, "--ttl", help="Seconds until expiry."),
78
+ rate: int = typer.Option(0, "--rate", help="Max requests/min (0 = unlimited)."),
79
+ proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
80
+ admin: Optional[str] = typer.Option(None, "--admin"),
81
+ ):
82
+ """Mint a machine token — give it to a remote machine; it holds no real key."""
83
+ with _admin_client(proxy, admin) as c:
84
+ r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
85
+ "ttl_seconds": ttl, "rate_limit": rate})
86
+ if r.status_code >= 400:
87
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
88
+ d = r.json()
89
+ console.print(Panel.fit(
90
+ f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{d['token']}[/yellow]\n\n"
91
+ f"[dim]scope: {', '.join(scope)} · shown once[/dim]", border_style="green"))
92
+
93
+
94
+ @token_app.command("ls")
95
+ def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
96
+ admin: Optional[str] = typer.Option(None, "--admin")):
97
+ """List machine tokens."""
98
+ with _admin_client(proxy, admin) as c:
99
+ r = c.get("/admin/tokens")
100
+ if r.status_code >= 400:
101
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
102
+ rows = r.json()["tokens"]
103
+ if not rows:
104
+ console.print("[dim]No tokens.[/dim]"); return
105
+ t = Table(title="Machine tokens")
106
+ for col in ("ID", "Label", "Token", "Scope", "Status"):
107
+ t.add_column(col)
108
+ for k in rows:
109
+ t.add_row(k["id"], k["label"], k["masked"], ", ".join(k["scope"]),
110
+ "[red]revoked[/red]" if k["revoked"] else "[green]active[/green]")
111
+ console.print(t)
112
+
113
+
114
+ @token_app.command("revoke")
115
+ def token_revoke(token_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
116
+ admin: Optional[str] = typer.Option(None, "--admin")):
117
+ """Revoke a token by id."""
118
+ with _admin_client(proxy, admin) as c:
119
+ r = c.delete(f"/admin/tokens/{token_id}")
120
+ if r.status_code >= 400:
121
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
122
+ console.print(f"[green]✓[/green] revoked {token_id}")
123
+
124
+
125
+ @app.command()
126
+ def logs(limit: int = 50, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
127
+ admin: Optional[str] = typer.Option(None, "--admin")):
128
+ """Recent proxied requests (audit log)."""
129
+ with _admin_client(proxy, admin) as c:
130
+ r = c.get("/admin/logs", params={"limit": limit})
131
+ if r.status_code >= 400:
132
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
133
+ rows = r.json()["logs"]
134
+ t = Table(title="Requests")
135
+ for col in ("Token", "Provider", "Model", "Status", "In", "Out", "ms"):
136
+ t.add_column(col)
137
+ for g in rows:
138
+ t.add_row(g.get("token_label") or "", g.get("provider") or "", (g.get("model") or "")[:28],
139
+ str(g.get("status") or ""), str(g.get("prompt_tokens") or "-"),
140
+ str(g.get("completion_tokens") or "-"), str(g.get("latency_ms") or ""))
141
+ console.print(t)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ app()
@@ -0,0 +1,82 @@
1
+ """Configuration — provider upstreams, real credentials (env only), paths, admin auth.
2
+
3
+ Real keys are read from the environment and never persisted. The proxy is the ONLY
4
+ place they live.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .security import hash_token, new_token, ADMIN_PREFIX
14
+
15
+ HOME = Path(os.environ.get("PROXYAGENT_HOME", Path.home() / ".proxyagent"))
16
+
17
+
18
+ @dataclass
19
+ class Provider:
20
+ name: str
21
+ base_url: str # upstream API root
22
+ key_env: str # env var holding the REAL key
23
+ auth_style: str # "bearer" (OpenAI) | "x-api-key" (Anthropic)
24
+ extra_headers: dict = field(default_factory=dict)
25
+
26
+ @property
27
+ def key(self) -> str | None:
28
+ return os.environ.get(self.key_env)
29
+
30
+ def auth_headers(self) -> dict:
31
+ key = self.key
32
+ if not key:
33
+ return {}
34
+ if self.auth_style == "x-api-key":
35
+ return {"x-api-key": key, **self.extra_headers}
36
+ return {"Authorization": f"Bearer {key}", **self.extra_headers}
37
+
38
+
39
+ # Built-in upstreams. base_url overridable via env (e.g. Azure, self-hosted, gateways).
40
+ def _provider(name, default_base, key_env, style, extra=None) -> Provider:
41
+ base = os.environ.get(f"PROXYAGENT_{name.upper()}_BASE_URL", default_base)
42
+ return Provider(name, base.rstrip("/"), key_env, style, extra or {})
43
+
44
+
45
+ PROVIDERS: dict[str, Provider] = {
46
+ "anthropic": _provider(
47
+ "anthropic", "https://api.anthropic.com", "ANTHROPIC_API_KEY", "x-api-key",
48
+ {"anthropic-version": os.environ.get("ANTHROPIC_VERSION", "2023-06-01")},
49
+ ),
50
+ "openai": _provider("openai", "https://api.openai.com", "OPENAI_API_KEY", "bearer"),
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class Config:
56
+ home: Path = HOME
57
+ db_path: str = ""
58
+ admin_token_hash: str = ""
59
+ admin_token_plain: str | None = None # only set when freshly generated
60
+ request_timeout: float = 600.0
61
+
62
+ @classmethod
63
+ def load(cls) -> "Config":
64
+ HOME.mkdir(parents=True, exist_ok=True)
65
+ cfg = cls(db_path=str(HOME / "proxyagent.db"))
66
+ # Admin token: from env, or a persisted one, or freshly generated (shown once).
67
+ env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
68
+ admin_file = HOME / "admin_token"
69
+ if env_admin:
70
+ cfg.admin_token_hash = hash_token(env_admin)
71
+ elif admin_file.exists():
72
+ cfg.admin_token_hash = admin_file.read_text().strip()
73
+ else:
74
+ plain = new_token(ADMIN_PREFIX)
75
+ cfg.admin_token_hash = hash_token(plain)
76
+ admin_file.write_text(cfg.admin_token_hash)
77
+ admin_file.chmod(0o600)
78
+ cfg.admin_token_plain = plain
79
+ return cfg
80
+
81
+ def configured_providers(self) -> list[str]:
82
+ return [n for n, p in PROVIDERS.items() if p.key]
@@ -0,0 +1,73 @@
1
+ """Run an agent harness on a machine, pointed at the proxy — so the machine holds
2
+ only the throwaway proxy token, never a real key.
3
+
4
+ Almost every harness honours `*_BASE_URL`, so the shim is tiny: set the base URL to
5
+ the proxy, set the "api key" to the machine token, and launch the harness unmodified.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shlex
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable
15
+
16
+
17
+ @dataclass
18
+ class Harness:
19
+ name: str
20
+ # build argv from a goal (headless / non-interactive invocation)
21
+ launch: Callable[[str], list[str]]
22
+ check: list[str] = field(default_factory=list) # how to detect it's installed
23
+ install_hint: str = ""
24
+
25
+ def env(self, proxy_url: str, token: str) -> dict:
26
+ base = proxy_url.rstrip("/")
27
+ return {
28
+ "ANTHROPIC_BASE_URL": f"{base}/anthropic",
29
+ "ANTHROPIC_API_KEY": token,
30
+ "OPENAI_BASE_URL": f"{base}/openai/v1",
31
+ "OPENAI_API_BASE": f"{base}/openai/v1",
32
+ "OPENAI_API_KEY": token,
33
+ }
34
+
35
+
36
+ HARNESSES: dict[str, Harness] = {
37
+ "claude-code": Harness(
38
+ name="claude-code",
39
+ launch=lambda goal: ["claude", "-p", goal, "--permission-mode", "bypassPermissions"],
40
+ check=["claude", "--version"],
41
+ install_hint="npm i -g @anthropic-ai/claude-code",
42
+ ),
43
+ "codex": Harness(
44
+ name="codex",
45
+ launch=lambda goal: ["codex", "exec", goal],
46
+ check=["codex", "--version"],
47
+ install_hint="npm i -g @openai/codex",
48
+ ),
49
+ }
50
+
51
+
52
+ def register_custom(name: str, command: str) -> Harness:
53
+ """A custom harness: `command` is a template; {goal} is substituted."""
54
+ def _launch(goal: str) -> list[str]:
55
+ return shlex.split(command.replace("{goal}", goal)) if "{goal}" in command \
56
+ else shlex.split(command) + [goal]
57
+ h = Harness(name=name, launch=_launch)
58
+ HARNESSES[name] = h
59
+ return h
60
+
61
+
62
+ def run(harness: str, goal: str, *, proxy_url: str, token: str,
63
+ cwd: str | None = None, extra_env: dict | None = None,
64
+ command: str | None = None) -> int:
65
+ """Run a harness against a goal, pointed at the proxy. Streams to stdout.
66
+ Returns the exit code."""
67
+ h = HARNESSES.get(harness) or (register_custom(harness, command) if command else None)
68
+ if h is None:
69
+ raise ValueError(f"unknown harness {harness!r} (pass command=... for a custom one)")
70
+ env = {**os.environ, **h.env(proxy_url, token), **(extra_env or {})}
71
+ argv = h.launch(goal)
72
+ proc = subprocess.run(argv, cwd=cwd, env=env)
73
+ return proc.returncode