agentvalet 0.2.1__tar.gz → 0.2.2__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.
Files changed (29) hide show
  1. {agentvalet-0.2.1 → agentvalet-0.2.2}/PKG-INFO +8 -3
  2. {agentvalet-0.2.1 → agentvalet-0.2.2}/README.md +7 -2
  3. {agentvalet-0.2.1 → agentvalet-0.2.2}/examples/README.md +3 -2
  4. {agentvalet-0.2.1 → agentvalet-0.2.2}/pyproject.toml +1 -1
  5. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/__init__.py +1 -1
  6. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/aio.py +2 -22
  7. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/client.py +7 -25
  8. agentvalet-0.2.2/src/agentvalet/env.py +264 -0
  9. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/register.py +4 -1
  10. {agentvalet-0.2.1 → agentvalet-0.2.2}/tests/conftest.py +18 -0
  11. {agentvalet-0.2.1 → agentvalet-0.2.2}/tests/test_client.py +53 -0
  12. agentvalet-0.2.2/tests/test_env.py +192 -0
  13. agentvalet-0.2.1/src/agentvalet/env.py +0 -119
  14. agentvalet-0.2.1/tests/test_env.py +0 -94
  15. {agentvalet-0.2.1 → agentvalet-0.2.2}/.gitignore +0 -0
  16. {agentvalet-0.2.1 → agentvalet-0.2.2}/examples/01_quickstart.py +0 -0
  17. {agentvalet-0.2.1 → agentvalet-0.2.2}/examples/02_approvals.py +0 -0
  18. {agentvalet-0.2.1 → agentvalet-0.2.2}/examples/03_langchain_tools.py +0 -0
  19. {agentvalet-0.2.1 → agentvalet-0.2.2}/examples/04_support_triage_agent.py +0 -0
  20. {agentvalet-0.2.1 → agentvalet-0.2.2}/examples/05_langgraph_subagents.py +0 -0
  21. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/_constants.py +0 -0
  22. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/_protocol.py +0 -0
  23. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/_signing.py +0 -0
  24. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/child.py +0 -0
  25. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/errors.py +0 -0
  26. {agentvalet-0.2.1 → agentvalet-0.2.2}/src/agentvalet/py.typed +0 -0
  27. {agentvalet-0.2.1 → agentvalet-0.2.2}/tests/test_bearer_identity.py +0 -0
  28. {agentvalet-0.2.1 → agentvalet-0.2.2}/tests/test_constants.py +0 -0
  29. {agentvalet-0.2.1 → agentvalet-0.2.2}/tests/test_register.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentvalet
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Call approved SaaS platforms from any Python agent. Your agent never holds the downstream credential.
5
5
  Project-URL: Homepage, https://agentvalet.ai
6
6
  Project-URL: Documentation, https://docs.agentvalet.ai
@@ -95,8 +95,13 @@ the call stays outside it.
95
95
 
96
96
  `from_env()` reads `AGENTVALET_AGENT_ID` / `AGENTVALET_OWNER_ID` (or the bare
97
97
  `AGENT_ID` / `OWNER_ID`) and finds the key via `AGENT_PRIVATE_KEY_B64`,
98
- `AGENT_PRIVATE_KEY_PATH`, `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. To
99
- wire it explicitly:
98
+ `AGENT_PRIVATE_KEY_PATH` (alias `AGENTVALET_PRIVATE_KEY_PATH`),
99
+ `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. Env vars always win. When no
100
+ agent id is set in the environment it falls back, in order, to `./.env.agentvalet`,
101
+ `~/.agentvalet/config.json` (what `agentvalet register` writes) and
102
+ `~/.agentvalet/agent.json`, taking the agent id, owner id, proxy URL and key path
103
+ from the same file. An agent saved under a label (`~/.agentvalet/<label>/`) is
104
+ never picked automatically: set the env vars for it. To wire it explicitly:
100
105
 
101
106
  ```python
102
107
  av = AgentValet(
@@ -63,8 +63,13 @@ the call stays outside it.
63
63
 
64
64
  `from_env()` reads `AGENTVALET_AGENT_ID` / `AGENTVALET_OWNER_ID` (or the bare
65
65
  `AGENT_ID` / `OWNER_ID`) and finds the key via `AGENT_PRIVATE_KEY_B64`,
66
- `AGENT_PRIVATE_KEY_PATH`, `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. To
67
- wire it explicitly:
66
+ `AGENT_PRIVATE_KEY_PATH` (alias `AGENTVALET_PRIVATE_KEY_PATH`),
67
+ `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. Env vars always win. When no
68
+ agent id is set in the environment it falls back, in order, to `./.env.agentvalet`,
69
+ `~/.agentvalet/config.json` (what `agentvalet register` writes) and
70
+ `~/.agentvalet/agent.json`, taking the agent id, owner id, proxy URL and key path
71
+ from the same file. An agent saved under a label (`~/.agentvalet/<label>/`) is
72
+ never picked automatically: set the env vars for it. To wire it explicitly:
68
73
 
69
74
  ```python
70
75
  av = AgentValet(
@@ -9,8 +9,9 @@ agentvalet register --code <invite-or-enrollment-code>
9
9
  ```
10
10
 
11
11
  `register` generates an RSA keypair **on your machine** — only the public half
12
- is sent — and writes `~/.agentvalet/agent.key`. After that `AgentValet.from_env()`
13
- works with no arguments.
12
+ is sent — and writes `~/.agentvalet/agent.key` plus `~/.agentvalet/config.json`.
13
+ After that `AgentValet.from_env()` works with no arguments, as long as no
14
+ `AGENTVALET_AGENT_ID` / `AGENT_ID` env var points it at a different agent.
14
15
 
15
16
  | File | Use case |
16
17
  |---|---|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentvalet"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Call approved SaaS platforms from any Python agent. Your agent never holds the downstream credential."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -37,7 +37,7 @@ from .errors import (
37
37
  UpstreamError,
38
38
  )
39
39
 
40
- __version__ = "0.2.1"
40
+ __version__ = "0.2.2"
41
41
 
42
42
  __all__ = [
43
43
  "DEFAULT_PROXY_URL",
@@ -36,13 +36,7 @@ from ._protocol import (
36
36
  )
37
37
  from ._signing import sign_jwt
38
38
  from .child import ChildIdentity
39
- from .env import (
40
- agent_id_from_env,
41
- owner_id_from_env,
42
- private_key_from_env,
43
- proxy_url_from_env,
44
- session_id_from_env,
45
- )
39
+ from .env import resolve_from_env
46
40
  from .errors import ApprovalTimeoutError, ConfigError, NetworkError, ProxyError
47
41
 
48
42
  __all__ = ["AsyncAgentValet"]
@@ -91,21 +85,7 @@ class AsyncAgentValet:
91
85
 
92
86
  @classmethod
93
87
  def from_env(cls, **overrides: Any) -> AsyncAgentValet:
94
- agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
95
- owner_id = overrides.pop("owner_id", None) or owner_id_from_env()
96
- if not agent_id or not owner_id:
97
- raise ConfigError(
98
- "AgentValet identity not found in the environment. Set AGENTVALET_AGENT_ID and "
99
- "AGENTVALET_OWNER_ID (or AGENT_ID / OWNER_ID), or run `agentvalet register`."
100
- )
101
- return cls(
102
- agent_id=agent_id,
103
- owner_id=owner_id,
104
- private_key=overrides.pop("private_key", None) or private_key_from_env(),
105
- proxy_url=overrides.pop("proxy_url", None) or proxy_url_from_env(),
106
- session_id=overrides.pop("session_id", None) or session_id_from_env(),
107
- **overrides,
108
- )
88
+ return cls(**resolve_from_env(overrides), **overrides)
109
89
 
110
90
  @classmethod
111
91
  def from_bearer(
@@ -32,13 +32,7 @@ from ._protocol import (
32
32
  )
33
33
  from ._signing import sign_jwt
34
34
  from .child import ChildIdentity
35
- from .env import (
36
- agent_id_from_env,
37
- owner_id_from_env,
38
- private_key_from_env,
39
- proxy_url_from_env,
40
- session_id_from_env,
41
- )
35
+ from .env import resolve_from_env
42
36
  from .errors import ApprovalTimeoutError, ConfigError, NetworkError, ProxyError
43
37
 
44
38
  __all__ = ["AgentValet"]
@@ -95,25 +89,13 @@ class AgentValet:
95
89
  def from_env(cls, **overrides: Any) -> AgentValet:
96
90
  """Build a client from the environment.
97
91
 
98
- Works with no arguments on a machine that has run ``agentvalet
99
- register`` (or ``npx @agentvalet/register``). See :mod:`agentvalet.env`
100
- for the accepted variable names and key formats.
92
+ Env vars win. With no agent id in the environment, falls back to the
93
+ identity ``agentvalet register`` (or ``npx @agentvalet/register``) wrote
94
+ to ``~/.agentvalet/config.json`` (or ``./.env.agentvalet``), signing with
95
+ the key path recorded beside it. See :mod:`agentvalet.env` for the
96
+ accepted variable names, files and key formats.
101
97
  """
102
- agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
103
- owner_id = overrides.pop("owner_id", None) or owner_id_from_env()
104
- if not agent_id or not owner_id:
105
- raise ConfigError(
106
- "AgentValet identity not found in the environment. Set AGENTVALET_AGENT_ID and "
107
- "AGENTVALET_OWNER_ID (or AGENT_ID / OWNER_ID), or run `agentvalet register`."
108
- )
109
- return cls(
110
- agent_id=agent_id,
111
- owner_id=owner_id,
112
- private_key=overrides.pop("private_key", None) or private_key_from_env(),
113
- proxy_url=overrides.pop("proxy_url", None) or proxy_url_from_env(),
114
- session_id=overrides.pop("session_id", None) or session_id_from_env(),
115
- **overrides,
116
- )
98
+ return cls(**resolve_from_env(overrides), **overrides)
117
99
 
118
100
  @classmethod
119
101
  def from_bearer(
@@ -0,0 +1,264 @@
1
+ """Environment resolution for ``AgentValet.from_env()``.
2
+
3
+ Ported from ``packages/client/src/env.ts``. Accepts BOTH naming conventions
4
+ already in the wild: the bare names the Node CLI and MCPB write
5
+ (``AGENT_ID``, ``OWNER_ID``, ``PROXY_URL``, ``AGENT_PRIVATE_KEY*``) and
6
+ ``AGENTVALET_``-prefixed aliases, which is what the docs tell people to set.
7
+
8
+ Env vars always win. When no agent id is set in the environment,
9
+ ``from_env()`` falls back to the identity files the registration flows write
10
+ (see :func:`identity_from_disk`), so a machine that has run
11
+ ``agentvalet register`` or ``npx @agentvalet/register`` works with no extra
12
+ config. That is the single most important onboarding affordance in the package
13
+ — do not "tidy" it away.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from ._constants import DEFAULT_PROXY_URL
25
+ from .errors import ConfigError
26
+
27
+ __all__ = [
28
+ "agent_id_from_env",
29
+ "identity_from_disk",
30
+ "owner_id_from_env",
31
+ "private_key_from_env",
32
+ "proxy_url_from_env",
33
+ "session_id_from_env",
34
+ ]
35
+
36
+ #: Written by the register flow. Also read by the Node SDK and mcp-server.
37
+ KEY_PATH = Path.home() / ".agentvalet" / "agent.key"
38
+
39
+
40
+ def _read_env(name: str) -> str | None:
41
+ """Empty strings and unresolved MCPB placeholders count as not-set.
42
+
43
+ The MCPB writes a literal ``${user_config.foo}`` when a field is left
44
+ blank. Treating that as a real value produces a baffling 401 much later,
45
+ far from the actual mistake.
46
+ """
47
+ raw = os.environ.get(name)
48
+ if raw is None:
49
+ return None
50
+ value = raw.strip()
51
+ if not value:
52
+ return None
53
+ if value.startswith("${") and value.endswith("}"):
54
+ return None
55
+ return value
56
+
57
+
58
+ def _read_either(suffix: str) -> str | None:
59
+ """``AGENTVALET_``-prefixed name wins over the bare name when both are set."""
60
+ return _read_env(f"AGENTVALET_{suffix}") or _read_env(suffix)
61
+
62
+
63
+ def agent_id_from_env() -> str | None:
64
+ return _read_either("AGENT_ID")
65
+
66
+
67
+ def owner_id_from_env() -> str | None:
68
+ return _read_either("OWNER_ID")
69
+
70
+
71
+ def proxy_url_from_env(fallback: str | None = None) -> str:
72
+ """``AGENTVALET_API_URL`` (the name ``.env.agentvalet`` uses) is an alias.
73
+
74
+ ``fallback`` (e.g. from an identity file) applies only when no env var is set.
75
+ """
76
+ url = _read_either("PROXY_URL") or _read_env("AGENTVALET_API_URL") or fallback
77
+ return (url or DEFAULT_PROXY_URL).rstrip("/")
78
+
79
+
80
+ def session_id_from_env() -> str | None:
81
+ return _read_either("SESSION_ID")
82
+
83
+
84
+ def private_key_from_env(fallback_path: str | None = None) -> str:
85
+ """Resolve the RS256 private key PEM.
86
+
87
+ Precedence, matching the Node SDK exactly:
88
+
89
+ 1. ``AGENT_PRIVATE_KEY_B64`` — base64-encoded PEM (12-factor / container safe)
90
+ 2. ``AGENT_PRIVATE_KEY_PATH`` — path to a PEM file (``AGENTVALET_PRIVATE_KEY_PATH``,
91
+ the name ``.env.agentvalet`` uses, is an alias)
92
+ 3. ``AGENT_PRIVATE_KEY`` — raw multi-line PEM, or ``\\n``-escaped single line
93
+ 4. ``fallback_path`` — the key path recorded beside an on-disk identity
94
+ 5. ``~/.agentvalet/agent.key`` — written by the register flow
95
+ """
96
+ b64 = _read_either("AGENT_PRIVATE_KEY_B64")
97
+ if b64:
98
+ return base64.b64decode(b64).decode("utf-8").strip()
99
+
100
+ path = _read_either("AGENT_PRIVATE_KEY_PATH") or _read_env("AGENTVALET_PRIVATE_KEY_PATH")
101
+ if path:
102
+ try:
103
+ return Path(path).read_text(encoding="utf-8").strip()
104
+ except OSError as err:
105
+ raise ConfigError(f"Cannot read AGENT_PRIVATE_KEY_PATH ({path}): {err}") from err
106
+
107
+ raw = _read_either("AGENT_PRIVATE_KEY")
108
+ if raw:
109
+ unescaped = raw.replace("\\n", "\n")
110
+ # Secrets UIs routinely trim the PEM armour on paste. Re-wrap rather
111
+ # than failing deep inside the crypto layer with an opaque message.
112
+ if not unescaped.startswith("-----"):
113
+ return f"-----BEGIN PRIVATE KEY-----\n{unescaped}\n-----END PRIVATE KEY-----"
114
+ return unescaped
115
+
116
+ if fallback_path:
117
+ # Paired with the on-disk agent id. Failing loudly beats silently signing
118
+ # with the default key, which may belong to a different agent.
119
+ try:
120
+ return Path(fallback_path).read_text(encoding="utf-8").strip()
121
+ except OSError as err:
122
+ raise ConfigError(
123
+ f"Cannot read the agent key at {fallback_path} (named by your AgentValet "
124
+ f"identity file): {err}"
125
+ ) from err
126
+
127
+ disk = _read_disk_key()
128
+ if disk:
129
+ return disk
130
+
131
+ raise ConfigError(
132
+ "No private key found. Set AGENT_PRIVATE_KEY, AGENT_PRIVATE_KEY_PATH, or "
133
+ "AGENT_PRIVATE_KEY_B64, or run `agentvalet register` to create an agent and "
134
+ "write ~/.agentvalet/agent.key."
135
+ )
136
+
137
+
138
+ def _read_disk_key() -> str | None:
139
+ try:
140
+ return KEY_PATH.read_text(encoding="utf-8").strip() or None
141
+ except OSError:
142
+ return None
143
+
144
+
145
+ def _str(value: Any) -> str | None:
146
+ return value.strip() if isinstance(value, str) and value.strip() else None
147
+
148
+
149
+ def _read_json(path: Path) -> dict[str, Any] | None:
150
+ try:
151
+ value = json.loads(path.read_text(encoding="utf-8"))
152
+ except (OSError, ValueError):
153
+ return None
154
+ return value if isinstance(value, dict) else None
155
+
156
+
157
+ def _parse_dotenv(text: str) -> dict[str, str]:
158
+ out: dict[str, str] = {}
159
+ for line in text.splitlines():
160
+ line = line.strip()
161
+ if not line or line.startswith("#") or "=" not in line:
162
+ continue
163
+ key, _, value = line.partition("=")
164
+ value = value.strip()
165
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
166
+ value = value[1:-1]
167
+ out[key.strip()] = value
168
+ return out
169
+
170
+
171
+ def identity_from_disk(
172
+ cwd: Path | None = None, home: Path | None = None
173
+ ) -> dict[str, str | None] | None:
174
+ """The identity the registration flows leave on disk. First hit wins:
175
+
176
+ 1. ``<cwd>/.env.agentvalet`` — written by ``npx @agentvalet/register`` (project-level)
177
+ 2. ``~/.agentvalet/config.json`` — written by ``agentvalet register`` and the Node
178
+ CLI; the most recent registration
179
+ 3. ``~/.agentvalet/agent.json`` — written by ``agentvalet-register claim`` (unlabelled)
180
+
181
+ Returns ``{"agent_id", "owner_id", "key_path", "proxy_url", "source"}`` or
182
+ ``None``. Each source yields an agent id together with the key path written
183
+ beside it, never mixed with another source's. Labelled agents
184
+ (``~/.agentvalet/<label>/``) are not guessed at: point the env vars at them.
185
+ """
186
+ cwd = cwd or Path.cwd()
187
+ av_dir = (home or Path.home()) / ".agentvalet"
188
+
189
+ env_file = cwd / ".env.agentvalet"
190
+ try:
191
+ env_vars = _parse_dotenv(env_file.read_text(encoding="utf-8"))
192
+ except OSError:
193
+ env_vars = {}
194
+ agent_id = _str(env_vars.get("AGENTVALET_AGENT_ID"))
195
+ if agent_id:
196
+ return {
197
+ "agent_id": agent_id,
198
+ "owner_id": _str(env_vars.get("AGENTVALET_OWNER_ID")),
199
+ "key_path": _str(env_vars.get("AGENTVALET_PRIVATE_KEY_PATH")),
200
+ "proxy_url": _str(env_vars.get("AGENTVALET_API_URL")),
201
+ "source": str(env_file),
202
+ }
203
+
204
+ config_file = av_dir / "config.json"
205
+ cfg = _read_json(config_file) or {}
206
+ # Every register flow rewrites lastAgentId/lastKeyPath as a pair; the Node
207
+ # ``onboard`` flow leaves agentId/keyPath stale, so the "last" pair wins.
208
+ last = _str(cfg.get("lastAgentId"))
209
+ agent_id = last or _str(cfg.get("agentId"))
210
+ if agent_id:
211
+ return {
212
+ "agent_id": agent_id,
213
+ "owner_id": _str(cfg.get("ownerId")),
214
+ "key_path": _str(cfg.get("lastKeyPath") if last else cfg.get("keyPath")),
215
+ "proxy_url": _str(cfg.get("apiUrl")) or _str(cfg.get("proxyUrl")),
216
+ "source": str(config_file),
217
+ }
218
+
219
+ agent_file = av_dir / "agent.json"
220
+ ident = _read_json(agent_file) or {}
221
+ agent_id = _str(ident.get("agent_id"))
222
+ if agent_id:
223
+ return {
224
+ "agent_id": agent_id,
225
+ "owner_id": _str(ident.get("owner_id")),
226
+ "key_path": str(av_dir / "agent.key"),
227
+ "proxy_url": _str(ident.get("proxy_url")),
228
+ "source": str(agent_file),
229
+ }
230
+
231
+ return None
232
+
233
+
234
+ def resolve_from_env(overrides: dict[str, Any]) -> dict[str, Any]:
235
+ """Shared body of ``AgentValet.from_env`` / ``AsyncAgentValet.from_env``.
236
+
237
+ Pops the identity keys out of ``overrides`` and returns constructor kwargs.
238
+ Precedence per field: explicit override, then env vars, then the identity
239
+ file. The file is consulted only when no agent id is given, so an env agent
240
+ id is never paired with a file's key.
241
+ """
242
+ agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
243
+ disk = None if agent_id else identity_from_disk()
244
+ if disk:
245
+ agent_id = disk["agent_id"]
246
+ owner_id = (
247
+ overrides.pop("owner_id", None) or owner_id_from_env() or (disk or {}).get("owner_id")
248
+ )
249
+ if not agent_id or not owner_id:
250
+ hint = f" ({disk['source']} names an agent but no owner id.)" if disk else ""
251
+ raise ConfigError(
252
+ "AgentValet identity not found. Set AGENTVALET_AGENT_ID and AGENTVALET_OWNER_ID "
253
+ "(or AGENT_ID / OWNER_ID), or run `agentvalet register` to write "
254
+ "~/.agentvalet/config.json." + hint
255
+ )
256
+ return {
257
+ "agent_id": agent_id,
258
+ "owner_id": owner_id,
259
+ "private_key": overrides.pop("private_key", None)
260
+ or private_key_from_env((disk or {}).get("key_path")),
261
+ "proxy_url": overrides.pop("proxy_url", None)
262
+ or proxy_url_from_env((disk or {}).get("proxy_url")),
263
+ "session_id": overrides.pop("session_id", None) or session_id_from_env(),
264
+ }
@@ -173,7 +173,10 @@ def main(argv: list[str] | None = None) -> int:
173
173
  print(f" owner: {identity['ownerId']}")
174
174
  print(f" proxy: {identity['apiUrl']}")
175
175
  print(f" key: {AGENT_KEY_PATH} (private key never left this machine)")
176
- print("\nYou can now use AgentValet.from_env() with no further configuration.")
176
+ print(
177
+ f"\nSaved to {CONFIG_PATH}. AgentValet.from_env() reads it when "
178
+ "AGENTVALET_AGENT_ID is not set (env vars take precedence)."
179
+ )
177
180
  return 0
178
181
 
179
182
  return 1
@@ -19,6 +19,24 @@ from agentvalet import AgentValet, AsyncAgentValet
19
19
  PROXY = "https://api.example.test"
20
20
 
21
21
 
22
+ @pytest.fixture(autouse=True)
23
+ def isolated_disk(monkeypatch, tmp_path):
24
+ """Empty $HOME and cwd for every test.
25
+
26
+ ``from_env()`` falls back to ``~/.agentvalet/config.json`` and
27
+ ``./.env.agentvalet``; without this a developer machine that has run
28
+ ``agentvalet register`` would leak its real identity into the tests.
29
+ """
30
+ home = tmp_path / "home"
31
+ work = tmp_path / "work"
32
+ (home / ".agentvalet").mkdir(parents=True)
33
+ work.mkdir()
34
+ monkeypatch.setenv("HOME", str(home))
35
+ monkeypatch.setenv("USERPROFILE", str(home))
36
+ monkeypatch.chdir(work)
37
+ return home
38
+
39
+
22
40
  @pytest.fixture(scope="session")
23
41
  def private_key_pem() -> str:
24
42
  """One 2048-bit key for the whole session — keygen is slow."""
@@ -61,6 +61,59 @@ async def test_from_env_names_the_vars_when_identity_absent(make_client, monkeyp
61
61
  make_client.cls.from_env()
62
62
 
63
63
 
64
+ IDENTITY_ENV = (
65
+ "AGENTVALET_AGENT_ID", "AGENT_ID", "AGENTVALET_OWNER_ID", "OWNER_ID",
66
+ "AGENTVALET_PROXY_URL", "PROXY_URL", "AGENTVALET_API_URL",
67
+ "AGENTVALET_AGENT_PRIVATE_KEY", "AGENT_PRIVATE_KEY",
68
+ "AGENTVALET_AGENT_PRIVATE_KEY_B64", "AGENT_PRIVATE_KEY_B64",
69
+ "AGENTVALET_AGENT_PRIVATE_KEY_PATH", "AGENT_PRIVATE_KEY_PATH", "AGENTVALET_PRIVATE_KEY_PATH",
70
+ )
71
+
72
+
73
+ async def test_from_env_works_after_register_with_no_env_vars(
74
+ make_client, monkeypatch, isolated_disk, private_key_pem
75
+ ):
76
+ """The promise `agentvalet register` prints: no env vars, just the files it wrote."""
77
+ import json
78
+
79
+ from agentvalet import register
80
+
81
+ for name in IDENTITY_ENV:
82
+ monkeypatch.delenv(name, raising=False)
83
+ av_dir = isolated_disk / ".agentvalet"
84
+ monkeypatch.setattr(register, "AGENT_DIR", av_dir)
85
+ monkeypatch.setattr(register, "AGENT_KEY_PATH", av_dir / "agent.key")
86
+ monkeypatch.setattr(register, "CONFIG_PATH", av_dir / "config.json")
87
+ register.save_identity(
88
+ {"agentId": "agt_disk", "ownerId": "own_disk", "apiUrl": "https://disk.test/"},
89
+ private_key_pem,
90
+ )
91
+ assert json.loads((av_dir / "config.json").read_text())["agentId"] == "agt_disk"
92
+
93
+ client = make_client.cls.from_env()
94
+ assert (client.agent_id, client.owner_id, client.proxy_url) == (
95
+ "agt_disk", "own_disk", "https://disk.test",
96
+ )
97
+
98
+
99
+ async def test_from_env_never_pairs_an_env_agent_id_with_a_file(
100
+ make_client, monkeypatch, isolated_disk, private_key_pem
101
+ ):
102
+ import json
103
+
104
+ for name in IDENTITY_ENV:
105
+ monkeypatch.delenv(name, raising=False)
106
+ (isolated_disk / ".agentvalet" / "config.json").write_text(
107
+ json.dumps({"agentId": "agt_disk", "ownerId": "own_disk"}), encoding="utf-8"
108
+ )
109
+ monkeypatch.setenv("AGENT_ID", "agt_env")
110
+ with pytest.raises(ConfigError):
111
+ make_client.cls.from_env() # file ignored entirely -> no owner id
112
+ monkeypatch.setenv("OWNER_ID", "own_env")
113
+ client = make_client.cls.from_env(private_key=private_key_pem)
114
+ assert (client.agent_id, client.owner_id) == ("agt_env", "own_env")
115
+
116
+
64
117
  async def test_rejects_out_of_charset_session_id(make_client):
65
118
  with pytest.raises(ConfigError, match="session_id"):
66
119
  make_client(session_id="not a valid id!")
@@ -0,0 +1,192 @@
1
+ """Mirrors ``packages/client/tests/env.test.ts``."""
2
+
3
+ import base64
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from agentvalet._constants import DEFAULT_PROXY_URL
10
+ from agentvalet.env import (
11
+ agent_id_from_env,
12
+ identity_from_disk,
13
+ private_key_from_env,
14
+ proxy_url_from_env,
15
+ session_id_from_env,
16
+ )
17
+ from agentvalet.errors import ConfigError
18
+
19
+ PEM = "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----"
20
+
21
+ ALL_NAMES = [
22
+ "AGENTVALET_AGENT_ID", "AGENT_ID",
23
+ "AGENTVALET_OWNER_ID", "OWNER_ID",
24
+ "AGENTVALET_PROXY_URL", "PROXY_URL",
25
+ "AGENTVALET_AGENT_PRIVATE_KEY", "AGENT_PRIVATE_KEY",
26
+ "AGENTVALET_AGENT_PRIVATE_KEY_B64", "AGENT_PRIVATE_KEY_B64",
27
+ "AGENTVALET_AGENT_PRIVATE_KEY_PATH", "AGENT_PRIVATE_KEY_PATH",
28
+ "AGENTVALET_SESSION_ID", "SESSION_ID",
29
+ "AGENTVALET_PRIVATE_KEY_PATH", "AGENTVALET_API_URL",
30
+ ]
31
+
32
+
33
+ @pytest.fixture
34
+ def clean_env(monkeypatch):
35
+ """Blank every consulted name so a real env var can't leak into a test."""
36
+ for name in ALL_NAMES:
37
+ monkeypatch.delenv(name, raising=False)
38
+ return monkeypatch
39
+
40
+
41
+ class TestIdentity:
42
+ def test_prefixed_name_wins_over_bare(self, clean_env):
43
+ clean_env.setenv("AGENT_ID", "agt_bare")
44
+ clean_env.setenv("AGENTVALET_AGENT_ID", "agt_prefixed")
45
+ assert agent_id_from_env() == "agt_prefixed"
46
+
47
+ def test_falls_back_to_the_bare_name_the_cli_writes(self, clean_env):
48
+ clean_env.setenv("AGENT_ID", "agt_bare")
49
+ assert agent_id_from_env() == "agt_bare"
50
+
51
+ def test_unresolved_mcpb_placeholder_counts_as_not_set(self, clean_env):
52
+ # The MCPB writes a literal "${user_config.x}" for a blank field.
53
+ # Accepting it produces a baffling 401 far from the real mistake.
54
+ clean_env.setenv("AGENT_ID", "${user_config.agent_id}")
55
+ assert agent_id_from_env() is None
56
+
57
+ def test_proxy_url_defaults_and_strips_trailing_slash(self, clean_env):
58
+ assert proxy_url_from_env() == DEFAULT_PROXY_URL
59
+ clean_env.setenv("PROXY_URL", "https://self.hosted.test/")
60
+ assert proxy_url_from_env() == "https://self.hosted.test"
61
+
62
+ def test_session_id_absent_by_default(self, clean_env):
63
+ assert session_id_from_env() is None
64
+
65
+ def test_session_id_prefixed_name_wins_over_bare(self, clean_env):
66
+ clean_env.setenv("SESSION_ID", "sess_bare")
67
+ clean_env.setenv("AGENTVALET_SESSION_ID", "sess_prefixed")
68
+ assert session_id_from_env() == "sess_prefixed"
69
+
70
+
71
+ class TestPrivateKey:
72
+ def test_base64_takes_precedence(self, clean_env):
73
+ clean_env.setenv("AGENT_PRIVATE_KEY_B64", base64.b64encode(PEM.encode()).decode())
74
+ assert private_key_from_env() == PEM
75
+
76
+ def test_reads_a_pem_file_by_path(self, clean_env, tmp_path):
77
+ path = tmp_path / "agent.key"
78
+ path.write_text(PEM, encoding="utf-8")
79
+ clean_env.setenv("AGENT_PRIVATE_KEY_PATH", str(path))
80
+ assert private_key_from_env() == PEM
81
+
82
+ def test_unreadable_path_raises_config_error_naming_it(self, clean_env, tmp_path):
83
+ missing = tmp_path / "definitely-not-here.key"
84
+ clean_env.setenv("AGENT_PRIVATE_KEY_PATH", str(missing))
85
+ with pytest.raises(ConfigError, match="definitely-not-here"):
86
+ private_key_from_env()
87
+
88
+ def test_unescapes_a_collapsed_single_line_pem(self, clean_env):
89
+ clean_env.setenv(
90
+ "AGENT_PRIVATE_KEY",
91
+ "-----BEGIN PRIVATE KEY-----\\nAAAA\\n-----END PRIVATE KEY-----",
92
+ )
93
+ assert private_key_from_env() == PEM
94
+
95
+ def test_rewraps_a_blob_that_lost_its_pem_armour(self, clean_env):
96
+ # Secrets UIs routinely trim the delimiters on paste.
97
+ clean_env.setenv("AGENT_PRIVATE_KEY", "AAAA")
98
+ assert private_key_from_env() == PEM
99
+
100
+
101
+ class TestDotEnvAgentvaletAliases:
102
+ """``.env.agentvalet`` (written by the Node CLI) uses these names."""
103
+
104
+ def test_reads_agentvalet_private_key_path(self, clean_env, tmp_path):
105
+ path = tmp_path / "agent.key"
106
+ path.write_text(PEM, encoding="utf-8")
107
+ clean_env.setenv("AGENTVALET_PRIVATE_KEY_PATH", str(path))
108
+ assert private_key_from_env() == PEM
109
+
110
+ def test_reads_agentvalet_api_url_but_proxy_url_wins(self, clean_env):
111
+ clean_env.setenv("AGENTVALET_API_URL", "https://api-url.test/")
112
+ assert proxy_url_from_env() == "https://api-url.test"
113
+ clean_env.setenv("PROXY_URL", "https://proxy-url.test")
114
+ assert proxy_url_from_env() == "https://proxy-url.test"
115
+
116
+ def test_identity_file_key_path_comes_after_every_env_source(self, clean_env, tmp_path):
117
+ path = tmp_path / "file.key"
118
+ path.write_text("FILE", encoding="utf-8")
119
+ assert private_key_from_env(str(path)) == "FILE"
120
+ clean_env.setenv("AGENT_PRIVATE_KEY", PEM)
121
+ assert private_key_from_env(str(path)) == PEM
122
+
123
+ def test_missing_paired_key_fails_loudly(self, clean_env, tmp_path):
124
+ with pytest.raises(ConfigError, match="no-such-av-key"):
125
+ private_key_from_env(str(tmp_path / "no-such-av-key.key"))
126
+
127
+
128
+ class TestIdentityFromDisk:
129
+ @pytest.fixture
130
+ def dirs(self, tmp_path):
131
+ cwd, home = tmp_path / "proj", tmp_path / "h"
132
+ cwd.mkdir()
133
+ (home / ".agentvalet").mkdir(parents=True)
134
+ return cwd, home, home / ".agentvalet"
135
+
136
+ def test_none_when_nothing_registered(self, dirs):
137
+ cwd, home, _ = dirs
138
+ assert identity_from_disk(cwd, home) is None
139
+
140
+ def test_reads_env_agentvalet_written_by_node_cli(self, dirs):
141
+ cwd, home, _ = dirs
142
+ (cwd / ".env.agentvalet").write_text(
143
+ "# AgentValet — generated by agentvalet-register\r\n"
144
+ "AGENTVALET_AGENT_ID=agt_proj\r\n"
145
+ "AGENTVALET_OWNER_ID=own_proj\r\n"
146
+ "AGENTVALET_API_URL=https://self.hosted.test\r\n"
147
+ "AGENTVALET_PRIVATE_KEY_PATH=/keys/proj.key\r\n",
148
+ encoding="utf-8",
149
+ )
150
+ ident = identity_from_disk(cwd, home)
151
+ assert ident is not None
152
+ assert (ident["agent_id"], ident["owner_id"], ident["key_path"], ident["proxy_url"]) == (
153
+ "agt_proj", "own_proj", "/keys/proj.key", "https://self.hosted.test",
154
+ )
155
+
156
+ def test_project_file_beats_machine_config(self, dirs):
157
+ cwd, home, av = dirs
158
+ (cwd / ".env.agentvalet").write_text("AGENTVALET_AGENT_ID=agt_proj\n", encoding="utf-8")
159
+ (av / "config.json").write_text(json.dumps({"agentId": "agt_machine"}), encoding="utf-8")
160
+ assert identity_from_disk(cwd, home)["agent_id"] == "agt_proj"
161
+
162
+ def test_last_pair_wins_over_stale_agent_id(self, dirs):
163
+ # Node `onboard` updates only lastAgentId/lastKeyPath. Mixing the pairs
164
+ # would sign one agent's id with another agent's key.
165
+ cwd, home, av = dirs
166
+ (av / "config.json").write_text(json.dumps({
167
+ "agentId": "agt_old", "keyPath": "/old.key", "ownerId": "own_x",
168
+ "lastAgentId": "agt_new", "lastKeyPath": "/new.key", "proxyUrl": "https://p.test",
169
+ }), encoding="utf-8")
170
+ ident = identity_from_disk(cwd, home)
171
+ assert (ident["agent_id"], ident["key_path"], ident["proxy_url"]) == (
172
+ "agt_new", "/new.key", "https://p.test",
173
+ )
174
+
175
+ def test_account_only_config_is_not_an_identity(self, dirs):
176
+ cwd, home, av = dirs
177
+ (av / "config.json").write_text(json.dumps({"ownerId": "own_only"}), encoding="utf-8")
178
+ assert identity_from_disk(cwd, home) is None
179
+
180
+ def test_reads_claim_agent_json_with_default_key(self, dirs):
181
+ cwd, home, av = dirs
182
+ (av / "agent.json").write_text(json.dumps({
183
+ "agent_id": "agt_claim", "owner_id": "own_claim", "proxy_url": "https://x.test",
184
+ }), encoding="utf-8")
185
+ ident = identity_from_disk(cwd, home)
186
+ assert ident["agent_id"] == "agt_claim"
187
+ assert Path(ident["key_path"]) == av / "agent.key"
188
+
189
+ def test_corrupt_config_is_ignored(self, dirs):
190
+ cwd, home, av = dirs
191
+ (av / "config.json").write_text("{not json", encoding="utf-8")
192
+ assert identity_from_disk(cwd, home) is None
@@ -1,119 +0,0 @@
1
- """Environment resolution for ``AgentValet.from_env()``.
2
-
3
- Ported from ``packages/client/src/env.ts``. Accepts BOTH naming conventions
4
- already in the wild: the bare names the Node CLI and MCPB write
5
- (``AGENT_ID``, ``OWNER_ID``, ``PROXY_URL``, ``AGENT_PRIVATE_KEY*``) and
6
- ``AGENTVALET_``-prefixed aliases, which is what the docs tell people to set.
7
-
8
- A machine that has run ``agentvalet register`` (or ``npx @agentvalet/register``)
9
- already satisfies the bare set, so ``from_env()`` works with no extra config.
10
- That property is the single most important onboarding affordance in the package
11
- — do not "tidy" it into one canonical name.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import base64
17
- import os
18
- from pathlib import Path
19
-
20
- from ._constants import DEFAULT_PROXY_URL
21
- from .errors import ConfigError
22
-
23
- __all__ = [
24
- "agent_id_from_env",
25
- "owner_id_from_env",
26
- "private_key_from_env",
27
- "proxy_url_from_env",
28
- "session_id_from_env",
29
- ]
30
-
31
- #: Written by the register flow. Also read by the Node SDK and mcp-server.
32
- KEY_PATH = Path.home() / ".agentvalet" / "agent.key"
33
-
34
-
35
- def _read_env(name: str) -> str | None:
36
- """Empty strings and unresolved MCPB placeholders count as not-set.
37
-
38
- The MCPB writes a literal ``${user_config.foo}`` when a field is left
39
- blank. Treating that as a real value produces a baffling 401 much later,
40
- far from the actual mistake.
41
- """
42
- raw = os.environ.get(name)
43
- if raw is None:
44
- return None
45
- value = raw.strip()
46
- if not value:
47
- return None
48
- if value.startswith("${") and value.endswith("}"):
49
- return None
50
- return value
51
-
52
-
53
- def _read_either(suffix: str) -> str | None:
54
- """``AGENTVALET_``-prefixed name wins over the bare name when both are set."""
55
- return _read_env(f"AGENTVALET_{suffix}") or _read_env(suffix)
56
-
57
-
58
- def agent_id_from_env() -> str | None:
59
- return _read_either("AGENT_ID")
60
-
61
-
62
- def owner_id_from_env() -> str | None:
63
- return _read_either("OWNER_ID")
64
-
65
-
66
- def proxy_url_from_env() -> str:
67
- return (_read_either("PROXY_URL") or DEFAULT_PROXY_URL).rstrip("/")
68
-
69
-
70
- def session_id_from_env() -> str | None:
71
- return _read_either("SESSION_ID")
72
-
73
-
74
- def private_key_from_env() -> str:
75
- """Resolve the RS256 private key PEM.
76
-
77
- Precedence, matching the Node SDK exactly:
78
-
79
- 1. ``AGENT_PRIVATE_KEY_B64`` — base64-encoded PEM (12-factor / container safe)
80
- 2. ``AGENT_PRIVATE_KEY_PATH`` — path to a PEM file
81
- 3. ``AGENT_PRIVATE_KEY`` — raw multi-line PEM, or ``\\n``-escaped single line
82
- 4. ``~/.agentvalet/agent.key`` — written by the register flow
83
- """
84
- b64 = _read_either("AGENT_PRIVATE_KEY_B64")
85
- if b64:
86
- return base64.b64decode(b64).decode("utf-8").strip()
87
-
88
- path = _read_either("AGENT_PRIVATE_KEY_PATH")
89
- if path:
90
- try:
91
- return Path(path).read_text(encoding="utf-8").strip()
92
- except OSError as err:
93
- raise ConfigError(f"Cannot read AGENT_PRIVATE_KEY_PATH ({path}): {err}") from err
94
-
95
- raw = _read_either("AGENT_PRIVATE_KEY")
96
- if raw:
97
- unescaped = raw.replace("\\n", "\n")
98
- # Secrets UIs routinely trim the PEM armour on paste. Re-wrap rather
99
- # than failing deep inside the crypto layer with an opaque message.
100
- if not unescaped.startswith("-----"):
101
- return f"-----BEGIN PRIVATE KEY-----\n{unescaped}\n-----END PRIVATE KEY-----"
102
- return unescaped
103
-
104
- disk = _read_disk_key()
105
- if disk:
106
- return disk
107
-
108
- raise ConfigError(
109
- "No private key found. Set AGENT_PRIVATE_KEY, AGENT_PRIVATE_KEY_PATH, or "
110
- "AGENT_PRIVATE_KEY_B64, or run `agentvalet register` to create an agent and "
111
- "write ~/.agentvalet/agent.key."
112
- )
113
-
114
-
115
- def _read_disk_key() -> str | None:
116
- try:
117
- return KEY_PATH.read_text(encoding="utf-8").strip() or None
118
- except OSError:
119
- return None
@@ -1,94 +0,0 @@
1
- """Mirrors ``packages/client/tests/env.test.ts``."""
2
-
3
- import base64
4
-
5
- import pytest
6
-
7
- from agentvalet._constants import DEFAULT_PROXY_URL
8
- from agentvalet.env import (
9
- agent_id_from_env,
10
- private_key_from_env,
11
- proxy_url_from_env,
12
- session_id_from_env,
13
- )
14
- from agentvalet.errors import ConfigError
15
-
16
- PEM = "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----"
17
-
18
- ALL_NAMES = [
19
- "AGENTVALET_AGENT_ID", "AGENT_ID",
20
- "AGENTVALET_OWNER_ID", "OWNER_ID",
21
- "AGENTVALET_PROXY_URL", "PROXY_URL",
22
- "AGENTVALET_AGENT_PRIVATE_KEY", "AGENT_PRIVATE_KEY",
23
- "AGENTVALET_AGENT_PRIVATE_KEY_B64", "AGENT_PRIVATE_KEY_B64",
24
- "AGENTVALET_AGENT_PRIVATE_KEY_PATH", "AGENT_PRIVATE_KEY_PATH",
25
- "AGENTVALET_SESSION_ID", "SESSION_ID",
26
- ]
27
-
28
-
29
- @pytest.fixture
30
- def clean_env(monkeypatch):
31
- """Blank every consulted name so a real env var can't leak into a test."""
32
- for name in ALL_NAMES:
33
- monkeypatch.delenv(name, raising=False)
34
- return monkeypatch
35
-
36
-
37
- class TestIdentity:
38
- def test_prefixed_name_wins_over_bare(self, clean_env):
39
- clean_env.setenv("AGENT_ID", "agt_bare")
40
- clean_env.setenv("AGENTVALET_AGENT_ID", "agt_prefixed")
41
- assert agent_id_from_env() == "agt_prefixed"
42
-
43
- def test_falls_back_to_the_bare_name_the_cli_writes(self, clean_env):
44
- clean_env.setenv("AGENT_ID", "agt_bare")
45
- assert agent_id_from_env() == "agt_bare"
46
-
47
- def test_unresolved_mcpb_placeholder_counts_as_not_set(self, clean_env):
48
- # The MCPB writes a literal "${user_config.x}" for a blank field.
49
- # Accepting it produces a baffling 401 far from the real mistake.
50
- clean_env.setenv("AGENT_ID", "${user_config.agent_id}")
51
- assert agent_id_from_env() is None
52
-
53
- def test_proxy_url_defaults_and_strips_trailing_slash(self, clean_env):
54
- assert proxy_url_from_env() == DEFAULT_PROXY_URL
55
- clean_env.setenv("PROXY_URL", "https://self.hosted.test/")
56
- assert proxy_url_from_env() == "https://self.hosted.test"
57
-
58
- def test_session_id_absent_by_default(self, clean_env):
59
- assert session_id_from_env() is None
60
-
61
- def test_session_id_prefixed_name_wins_over_bare(self, clean_env):
62
- clean_env.setenv("SESSION_ID", "sess_bare")
63
- clean_env.setenv("AGENTVALET_SESSION_ID", "sess_prefixed")
64
- assert session_id_from_env() == "sess_prefixed"
65
-
66
-
67
- class TestPrivateKey:
68
- def test_base64_takes_precedence(self, clean_env):
69
- clean_env.setenv("AGENT_PRIVATE_KEY_B64", base64.b64encode(PEM.encode()).decode())
70
- assert private_key_from_env() == PEM
71
-
72
- def test_reads_a_pem_file_by_path(self, clean_env, tmp_path):
73
- path = tmp_path / "agent.key"
74
- path.write_text(PEM, encoding="utf-8")
75
- clean_env.setenv("AGENT_PRIVATE_KEY_PATH", str(path))
76
- assert private_key_from_env() == PEM
77
-
78
- def test_unreadable_path_raises_config_error_naming_it(self, clean_env, tmp_path):
79
- missing = tmp_path / "definitely-not-here.key"
80
- clean_env.setenv("AGENT_PRIVATE_KEY_PATH", str(missing))
81
- with pytest.raises(ConfigError, match="definitely-not-here"):
82
- private_key_from_env()
83
-
84
- def test_unescapes_a_collapsed_single_line_pem(self, clean_env):
85
- clean_env.setenv(
86
- "AGENT_PRIVATE_KEY",
87
- "-----BEGIN PRIVATE KEY-----\\nAAAA\\n-----END PRIVATE KEY-----",
88
- )
89
- assert private_key_from_env() == PEM
90
-
91
- def test_rewraps_a_blob_that_lost_its_pem_armour(self, clean_env):
92
- # Secrets UIs routinely trim the delimiters on paste.
93
- clean_env.setenv("AGENT_PRIVATE_KEY", "AAAA")
94
- assert private_key_from_env() == PEM
File without changes