proxyagent 0.3.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyagent
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
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
5
  Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
6
6
  Author-email: Spawn Labs <teddy@spawnlabs.ai>
@@ -64,7 +64,7 @@ machine never sees a real credential.
64
64
  ## Try it with zero keys (local)
65
65
  ```bash
66
66
  pip install proxyagent && proxyagent serve # prints an admin token
67
- proxyagent token new local --admin pa_admin_… # mint a token
67
+ proxyagent token new local # works locally, no admin token needed # mint a token
68
68
  # call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
69
69
  curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
70
70
  -d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
@@ -81,7 +81,7 @@ proxyagent serve # prints an admin token + a dashboard at
81
81
 
82
82
  **2. Mint a machine token** (scoped + revocable):
83
83
  ```bash
84
- proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
84
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" # local: no admin token needed
85
85
  ```
86
86
 
87
87
  **3. Run any agent on any machine — no real key there:**
@@ -32,7 +32,7 @@ machine never sees a real credential.
32
32
  ## Try it with zero keys (local)
33
33
  ```bash
34
34
  pip install proxyagent && proxyagent serve # prints an admin token
35
- proxyagent token new local --admin pa_admin_… # mint a token
35
+ proxyagent token new local # works locally, no admin token needed # mint a token
36
36
  # call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
37
37
  curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
38
38
  -d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
@@ -49,7 +49,7 @@ proxyagent serve # prints an admin token + a dashboard at
49
49
 
50
50
  **2. Mint a machine token** (scoped + revocable):
51
51
  ```bash
52
- proxyagent token new macbook-01 --scope "anthropic:claude-*" --admin pa_admin_…
52
+ proxyagent token new macbook-01 --scope "anthropic:claude-*" # local: no admin token needed
53
53
  ```
54
54
 
55
55
  **3. Run any agent on any machine — no real key there:**
@@ -16,7 +16,7 @@ from typing import Optional
16
16
 
17
17
  from .harness import run # noqa: F401 (the headline SDK call)
18
18
 
19
- __version__ = "0.3.0"
19
+ __version__ = "0.3.1"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -16,6 +16,9 @@ console = Console()
16
16
  err = Console(stderr=True)
17
17
 
18
18
 
19
+ DEFAULT_PROXY = "http://127.0.0.1:8080"
20
+
21
+
19
22
  def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
20
23
  admin = admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN")
21
24
  if not admin:
@@ -25,6 +28,22 @@ def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
25
28
  return httpx.Client(base_url=proxy.rstrip("/"), headers={"x-admin-token": admin}, timeout=30)
26
29
 
27
30
 
31
+ def _is_remote(proxy: str, admin: Optional[str]) -> bool:
32
+ """Manage a REMOTE proxy (via admin API) only if an admin token is given or the
33
+ proxy isn't localhost. Otherwise operate on the LOCAL store directly — no admin
34
+ token needed, you already have filesystem access."""
35
+ from urllib.parse import urlparse
36
+ if admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN"):
37
+ return True
38
+ return urlparse(proxy).hostname not in (None, "127.0.0.1", "localhost", "0.0.0.0")
39
+
40
+
41
+ def _local_store():
42
+ from .config import Config
43
+ from .store import Store
44
+ return Store(Config.load().db_path)
45
+
46
+
28
47
  @app.command()
29
48
  def serve(host: str = "127.0.0.1", port: int = 8080):
30
49
  """Run the proxy server + dashboard."""
@@ -36,15 +55,27 @@ def serve(host: str = "127.0.0.1", port: int = 8080):
36
55
  config = Config.load()
37
56
  if config.admin_token_plain:
38
57
  console.print(Panel.fit(
39
- f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (shown once)\n"
58
+ f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (for the dashboard)\n"
40
59
  f" [yellow]{config.admin_token_plain}[/yellow]\n\n"
41
- f"[dim]Save it — you need it for the dashboard + `proxyagent token`.[/dim]",
60
+ f"[dim]Reveal anytime: [bold]proxyagent admin-token[/bold][/dim]",
42
61
  border_style="green"))
43
62
  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'}")
63
+ f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none — `proxyagent provider add anthropic --key …`'}")
64
+ console.print("[dim]Mint a machine token in another terminal:[/dim] [bold]proxyagent token new[/bold] [dim](works locally, no admin token needed)[/dim]")
45
65
  uvicorn.run(create_app(config), host=host, port=port, log_level="warning")
46
66
 
47
67
 
68
+ @app.command("admin-token")
69
+ def admin_token():
70
+ """Print this machine's admin token (for the dashboard)."""
71
+ from .config import Config
72
+ cfg = Config.load()
73
+ if cfg.admin_token_plain:
74
+ console.print(cfg.admin_token_plain)
75
+ else:
76
+ err.print("[yellow]Admin token is set via PROXYAGENT_ADMIN_TOKEN (not stored here).[/yellow]")
77
+
78
+
48
79
  @app.command("run")
49
80
  def run_harness(
50
81
  harness: str = typer.Argument(..., help="claude-code | codex | <custom>"),
@@ -122,13 +153,21 @@ def provider_add(
122
153
  admin: str = typer.Option(None, "--admin"),
123
154
  ):
124
155
  """Store a provider credential (encrypted if PROXYAGENT_SECRET_KEY is set)."""
125
- with _admin_client(proxy, admin) as c:
126
- r = c.post("/admin/providers", json={"provider": provider, "secret": key,
127
- "kind": kind, "label": label})
128
- if r.status_code >= 400:
129
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
130
- d = r.json()
131
- note = "[green]encrypted[/green]" if d["stored"] == "encrypted" else "[yellow]plaintext — set PROXYAGENT_SECRET_KEY[/yellow]"
156
+ from .config import PROVIDERS
157
+ if provider not in PROVIDERS:
158
+ err.print(f"[red]✗[/red] unknown provider; known: {', '.join(PROVIDERS)}"); raise typer.Exit(1)
159
+ if not _is_remote(proxy, admin):
160
+ from . import crypto
161
+ _local_store().add_credential(provider, key, kind=kind, label=label)
162
+ stored = "encrypted" if crypto.encryption_available() else "plaintext"
163
+ else:
164
+ with _admin_client(proxy, admin) as c:
165
+ r = c.post("/admin/providers", json={"provider": provider, "secret": key,
166
+ "kind": kind, "label": label})
167
+ if r.status_code >= 400:
168
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
169
+ stored = r.json()["stored"]
170
+ note = "[green]encrypted[/green]" if stored == "encrypted" else "[yellow]plaintext — set PROXYAGENT_SECRET_KEY[/yellow]"
132
171
  console.print(f"[green]✓[/green] stored [cyan]{provider}[/cyan] ({kind}) · {note}")
133
172
 
134
173
 
@@ -136,11 +175,19 @@ def provider_add(
136
175
  def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
137
176
  admin: str = typer.Option(None, "--admin")):
138
177
  """List stored provider credentials (secrets never shown)."""
139
- with _admin_client(proxy, admin) as c:
140
- r = c.get("/admin/providers")
141
- if r.status_code >= 400:
142
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
143
- d = r.json()
178
+ if not _is_remote(proxy, admin):
179
+ from . import crypto
180
+ from .config import PROVIDERS
181
+ creds = _local_store().list_credentials()
182
+ configured = sorted({n for n, p in PROVIDERS.items() if p.key}
183
+ | {c["provider"] for c in creds if c["active"]})
184
+ d = {"credentials": creds, "configured": configured, "encryption": crypto.encryption_available()}
185
+ else:
186
+ with _admin_client(proxy, admin) as c:
187
+ r = c.get("/admin/providers")
188
+ if r.status_code >= 400:
189
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
190
+ d = r.json()
144
191
  t = Table(title=f"Provider credentials · encryption {'on' if d['encryption'] else 'OFF'}")
145
192
  for col in ("ID", "Provider", "Kind", "Label", "Active"):
146
193
  t.add_column(col)
@@ -155,10 +202,14 @@ def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
155
202
  def provider_rm(cred_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
156
203
  admin: str = typer.Option(None, "--admin")):
157
204
  """Remove a stored credential."""
158
- with _admin_client(proxy, admin) as c:
159
- r = c.delete(f"/admin/providers/{cred_id}")
160
- if r.status_code >= 400:
161
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
205
+ if not _is_remote(proxy, admin):
206
+ if not _local_store().remove_credential(cred_id):
207
+ err.print(f"[red]✗[/red] no such credential"); raise typer.Exit(1)
208
+ else:
209
+ with _admin_client(proxy, admin) as c:
210
+ r = c.delete(f"/admin/providers/{cred_id}")
211
+ if r.status_code >= 400:
212
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
162
213
  console.print(f"[green]✓[/green] removed {cred_id}")
163
214
 
164
215
 
@@ -172,14 +223,17 @@ def token_new(
172
223
  admin: Optional[str] = typer.Option(None, "--admin"),
173
224
  ):
174
225
  """Mint a machine token — give it to a remote machine; it holds no real key."""
175
- with _admin_client(proxy, admin) as c:
176
- r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
177
- "ttl_seconds": ttl, "rate_limit": rate})
178
- if r.status_code >= 400:
179
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
180
- d = r.json()
226
+ if not _is_remote(proxy, admin):
227
+ plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl, rate_limit=rate)
228
+ else:
229
+ with _admin_client(proxy, admin) as c:
230
+ r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
231
+ "ttl_seconds": ttl, "rate_limit": rate})
232
+ if r.status_code >= 400:
233
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
234
+ plain = r.json()["token"]
181
235
  console.print(Panel.fit(
182
- f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{d['token']}[/yellow]\n\n"
236
+ f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{plain}[/yellow]\n\n"
183
237
  f"[dim]scope: {', '.join(scope)} · shown once[/dim]", border_style="green"))
184
238
 
185
239
 
@@ -187,11 +241,17 @@ def token_new(
187
241
  def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
188
242
  admin: Optional[str] = typer.Option(None, "--admin")):
189
243
  """List machine tokens."""
190
- with _admin_client(proxy, admin) as c:
191
- r = c.get("/admin/tokens")
192
- if r.status_code >= 400:
193
- err.print(f"[red][/red] {r.text}"); raise typer.Exit(1)
194
- rows = r.json()["tokens"]
244
+ if not _is_remote(proxy, admin):
245
+ import json as _json
246
+ rows = [{"id": t["id"], "label": t["label"], "masked": t["masked"],
247
+ "scope": _json.loads(t["scope_json"]), "revoked": t["revoked"]}
248
+ for t in _local_store().list_tokens()]
249
+ else:
250
+ with _admin_client(proxy, admin) as c:
251
+ r = c.get("/admin/tokens")
252
+ if r.status_code >= 400:
253
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
254
+ rows = r.json()["tokens"]
195
255
  if not rows:
196
256
  console.print("[dim]No tokens.[/dim]"); return
197
257
  t = Table(title="Machine tokens")
@@ -207,10 +267,14 @@ def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
207
267
  def token_revoke(token_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
208
268
  admin: Optional[str] = typer.Option(None, "--admin")):
209
269
  """Revoke a token by id."""
210
- with _admin_client(proxy, admin) as c:
211
- r = c.delete(f"/admin/tokens/{token_id}")
212
- if r.status_code >= 400:
213
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
270
+ if not _is_remote(proxy, admin):
271
+ if not _local_store().revoke_token(token_id):
272
+ err.print(f"[red]✗[/red] no such token"); raise typer.Exit(1)
273
+ else:
274
+ with _admin_client(proxy, admin) as c:
275
+ r = c.delete(f"/admin/tokens/{token_id}")
276
+ if r.status_code >= 400:
277
+ err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
214
278
  console.print(f"[green]✓[/green] revoked {token_id}")
215
279
 
216
280
 
@@ -74,16 +74,21 @@ class Config:
74
74
  # Admin token: from env, or a persisted one, or freshly generated (shown once).
75
75
  env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
76
76
  admin_file = HOME / "admin_token"
77
+ existing = admin_file.read_text().strip() if admin_file.exists() else ""
77
78
  if env_admin:
79
+ # Production: trust the env token, persist nothing.
78
80
  cfg.admin_token_hash = hash_token(env_admin)
79
- elif admin_file.exists():
80
- cfg.admin_token_hash = admin_file.read_text().strip()
81
+ elif existing.startswith(ADMIN_PREFIX):
82
+ # Local: the plaintext is stored (0600) so the dashboard stays reachable.
83
+ cfg.admin_token_plain = existing
84
+ cfg.admin_token_hash = hash_token(existing)
81
85
  else:
86
+ # Fresh (or migrating an old hash-only file we can't recover): regenerate.
82
87
  plain = new_token(ADMIN_PREFIX)
83
- cfg.admin_token_hash = hash_token(plain)
84
- admin_file.write_text(cfg.admin_token_hash)
88
+ admin_file.write_text(plain)
85
89
  admin_file.chmod(0o600)
86
90
  cfg.admin_token_plain = plain
91
+ cfg.admin_token_hash = hash_token(plain)
87
92
  return cfg
88
93
 
89
94
  def configured_providers(self) -> list[str]:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes