agentvalet 0.2.0__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.0 → agentvalet-0.2.2}/PKG-INFO +8 -3
  2. {agentvalet-0.2.0 → agentvalet-0.2.2}/README.md +7 -2
  3. {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/README.md +3 -2
  4. {agentvalet-0.2.0 → agentvalet-0.2.2}/pyproject.toml +1 -1
  5. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/__init__.py +1 -1
  6. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/_protocol.py +37 -0
  7. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/aio.py +9 -22
  8. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/client.py +14 -25
  9. agentvalet-0.2.2/src/agentvalet/env.py +264 -0
  10. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/register.py +4 -1
  11. {agentvalet-0.2.0 → agentvalet-0.2.2}/tests/conftest.py +18 -0
  12. agentvalet-0.2.2/tests/test_bearer_identity.py +63 -0
  13. {agentvalet-0.2.0 → agentvalet-0.2.2}/tests/test_client.py +54 -0
  14. agentvalet-0.2.2/tests/test_env.py +192 -0
  15. agentvalet-0.2.0/src/agentvalet/env.py +0 -119
  16. agentvalet-0.2.0/tests/test_env.py +0 -94
  17. {agentvalet-0.2.0 → agentvalet-0.2.2}/.gitignore +0 -0
  18. {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/01_quickstart.py +0 -0
  19. {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/02_approvals.py +0 -0
  20. {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/03_langchain_tools.py +0 -0
  21. {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/04_support_triage_agent.py +0 -0
  22. {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/05_langgraph_subagents.py +0 -0
  23. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/_constants.py +0 -0
  24. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/_signing.py +0 -0
  25. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/child.py +0 -0
  26. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/errors.py +0 -0
  27. {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/py.typed +0 -0
  28. {agentvalet-0.2.0 → agentvalet-0.2.2}/tests/test_constants.py +0 -0
  29. {agentvalet-0.2.0 → 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.0
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.0"
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.0"
40
+ __version__ = "0.2.2"
41
41
 
42
42
  __all__ = [
43
43
  "DEFAULT_PROXY_URL",
@@ -8,6 +8,7 @@ other, it belongs in this module instead.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import base64
11
12
  import json
12
13
  import re
13
14
  from collections.abc import Mapping
@@ -223,3 +224,39 @@ def diagnose_network_error(err: BaseException, proxy_url: str) -> str:
223
224
 
224
225
  def _as_str(value: Any) -> str | None:
225
226
  return value if isinstance(value, str) else None
227
+
228
+
229
+ def identity_from_bearer(token: str) -> dict[str, str]:
230
+ """Read the agent/owner id out of a bearer token's payload.
231
+
232
+ A child bearer token is a JWT carrying its own ``agent_id`` (and ``sub``,
233
+ and ``owner_id``). Without this the client has no idea who it is, which
234
+ breaks anything that has to name the subject — ``evaluate()`` sent an empty
235
+ ``subject.id`` and the proxy answered 400.
236
+
237
+ **This is not a security check.** The signature is not verified and must not
238
+ be trusted; the server is the sole authority on whether a token is valid.
239
+ This only recovers identifiers the caller would otherwise have to pass back
240
+ in by hand. Anything unparseable yields ``{}`` rather than raising: failing
241
+ locally would replace a clear 401 from the server with a confusing crash.
242
+ """
243
+ try:
244
+ parts = token.split(".")
245
+ if len(parts) != 3:
246
+ return {}
247
+ payload = parts[1]
248
+ payload += "=" * (-len(payload) % 4)
249
+ claims = json.loads(base64.urlsafe_b64decode(payload))
250
+ if not isinstance(claims, dict):
251
+ return {}
252
+ except Exception:
253
+ return {}
254
+
255
+ out: dict[str, str] = {}
256
+ agent_id = claims.get("agent_id") or claims.get("av_agent_id") or claims.get("sub")
257
+ owner_id = claims.get("owner_id") or claims.get("av_owner_id")
258
+ if isinstance(agent_id, str) and agent_id:
259
+ out["agent_id"] = agent_id
260
+ if isinstance(owner_id, str) and owner_id:
261
+ out["owner_id"] = owner_id
262
+ return out
@@ -27,6 +27,7 @@ from ._protocol import (
27
27
  build_action_body,
28
28
  build_issue_child_body,
29
29
  diagnose_network_error,
30
+ identity_from_bearer,
30
31
  interpret_action_response,
31
32
  interpret_approval_status,
32
33
  interpret_issue_child_response,
@@ -35,13 +36,7 @@ from ._protocol import (
35
36
  )
36
37
  from ._signing import sign_jwt
37
38
  from .child import ChildIdentity
38
- from .env import (
39
- agent_id_from_env,
40
- owner_id_from_env,
41
- private_key_from_env,
42
- proxy_url_from_env,
43
- session_id_from_env,
44
- )
39
+ from .env import resolve_from_env
45
40
  from .errors import ApprovalTimeoutError, ConfigError, NetworkError, ProxyError
46
41
 
47
42
  __all__ = ["AsyncAgentValet"]
@@ -90,21 +85,7 @@ class AsyncAgentValet:
90
85
 
91
86
  @classmethod
92
87
  def from_env(cls, **overrides: Any) -> AsyncAgentValet:
93
- agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
94
- owner_id = overrides.pop("owner_id", None) or owner_id_from_env()
95
- if not agent_id or not owner_id:
96
- raise ConfigError(
97
- "AgentValet identity not found in the environment. Set AGENTVALET_AGENT_ID and "
98
- "AGENTVALET_OWNER_ID (or AGENT_ID / OWNER_ID), or run `agentvalet register`."
99
- )
100
- return cls(
101
- agent_id=agent_id,
102
- owner_id=owner_id,
103
- private_key=overrides.pop("private_key", None) or private_key_from_env(),
104
- proxy_url=overrides.pop("proxy_url", None) or proxy_url_from_env(),
105
- session_id=overrides.pop("session_id", None) or session_id_from_env(),
106
- **overrides,
107
- )
88
+ return cls(**resolve_from_env(overrides), **overrides)
108
89
 
109
90
  @classmethod
110
91
  def from_bearer(
@@ -118,6 +99,12 @@ class AsyncAgentValet:
118
99
  """See :meth:`agentvalet.client.AgentValet.from_bearer`."""
119
100
  if not bearer_token:
120
101
  raise ConfigError("bearer_token is required")
102
+ # The token already carries the child's own agent/owner id. Recover them
103
+ # so anything that has to name the subject works in bearer mode too;
104
+ # an explicit override always wins.
105
+ derived = identity_from_bearer(bearer_token)
106
+ for key, value in derived.items():
107
+ overrides.setdefault(key, value)
121
108
  return cls(
122
109
  bearer_token=bearer_token,
123
110
  proxy_url=proxy_url or DEFAULT_PROXY_URL,
@@ -23,6 +23,7 @@ from ._protocol import (
23
23
  build_action_body,
24
24
  build_issue_child_body,
25
25
  diagnose_network_error,
26
+ identity_from_bearer,
26
27
  interpret_action_response,
27
28
  interpret_approval_status,
28
29
  interpret_issue_child_response,
@@ -31,13 +32,7 @@ from ._protocol import (
31
32
  )
32
33
  from ._signing import sign_jwt
33
34
  from .child import ChildIdentity
34
- from .env import (
35
- agent_id_from_env,
36
- owner_id_from_env,
37
- private_key_from_env,
38
- proxy_url_from_env,
39
- session_id_from_env,
40
- )
35
+ from .env import resolve_from_env
41
36
  from .errors import ApprovalTimeoutError, ConfigError, NetworkError, ProxyError
42
37
 
43
38
  __all__ = ["AgentValet"]
@@ -94,25 +89,13 @@ class AgentValet:
94
89
  def from_env(cls, **overrides: Any) -> AgentValet:
95
90
  """Build a client from the environment.
96
91
 
97
- Works with no arguments on a machine that has run ``agentvalet
98
- register`` (or ``npx @agentvalet/register``). See :mod:`agentvalet.env`
99
- 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.
100
97
  """
101
- agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
102
- owner_id = overrides.pop("owner_id", None) or owner_id_from_env()
103
- if not agent_id or not owner_id:
104
- raise ConfigError(
105
- "AgentValet identity not found in the environment. Set AGENTVALET_AGENT_ID and "
106
- "AGENTVALET_OWNER_ID (or AGENT_ID / OWNER_ID), or run `agentvalet register`."
107
- )
108
- return cls(
109
- agent_id=agent_id,
110
- owner_id=owner_id,
111
- private_key=overrides.pop("private_key", None) or private_key_from_env(),
112
- proxy_url=overrides.pop("proxy_url", None) or proxy_url_from_env(),
113
- session_id=overrides.pop("session_id", None) or session_id_from_env(),
114
- **overrides,
115
- )
98
+ return cls(**resolve_from_env(overrides), **overrides)
116
99
 
117
100
  @classmethod
118
101
  def from_bearer(
@@ -136,6 +119,12 @@ class AgentValet:
136
119
  """
137
120
  if not bearer_token:
138
121
  raise ConfigError("bearer_token is required")
122
+ # The token already carries the child's own agent/owner id. Recover them
123
+ # so anything that has to name the subject works in bearer mode too;
124
+ # an explicit override always wins.
125
+ derived = identity_from_bearer(bearer_token)
126
+ for key, value in derived.items():
127
+ overrides.setdefault(key, value)
139
128
  return cls(
140
129
  bearer_token=bearer_token,
141
130
  proxy_url=proxy_url or DEFAULT_PROXY_URL,
@@ -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."""
@@ -0,0 +1,63 @@
1
+ """Bearer-mode identity.
2
+
3
+ Its own module because test_client.py sets a module-level asyncio mark and
4
+ these are plain sync assertions about a classmethod — no event loop involved.
5
+ Still parametrized over both implementations, for the reason conftest gives:
6
+ agentvalet.aio must not silently drift from agentvalet.client.
7
+ """
8
+
9
+ import base64 as _base64
10
+ import json as _json
11
+
12
+ import pytest
13
+
14
+ from agentvalet import AgentValet, AsyncAgentValet, ChildIdentity
15
+
16
+ BEARER_CLASSES = [AgentValet, AsyncAgentValet]
17
+
18
+
19
+ def _jwt(payload: dict) -> str:
20
+ def seg(obj: dict) -> str:
21
+ return _base64.urlsafe_b64encode(_json.dumps(obj).encode()).rstrip(b"=").decode()
22
+
23
+ return f"{seg({'alg': 'RS256'})}.{seg(payload)}.not-a-real-signature"
24
+
25
+
26
+ @pytest.mark.parametrize("cls", BEARER_CLASSES)
27
+ def test_from_bearer_derives_identity_from_the_token(cls):
28
+ av = cls.from_bearer(_jwt({"agent_id": "agt_child123", "owner_id": "own_456", "sub": "agt_child123"}))
29
+ assert av.agent_id == "agt_child123"
30
+ assert av.owner_id == "own_456"
31
+
32
+
33
+ @pytest.mark.parametrize("cls", BEARER_CLASSES)
34
+ def test_from_bearer_prefers_an_explicit_override(cls):
35
+ av = cls.from_bearer(_jwt({"agent_id": "agt_from_token"}), agent_id="agt_explicit")
36
+ assert av.agent_id == "agt_explicit"
37
+
38
+
39
+ @pytest.mark.parametrize("cls", BEARER_CLASSES)
40
+ def test_from_bearer_falls_back_to_sub(cls):
41
+ assert cls.from_bearer(_jwt({"sub": "agt_from_sub"})).agent_id == "agt_from_sub"
42
+
43
+
44
+ @pytest.mark.parametrize("cls", BEARER_CLASSES)
45
+ def test_from_bearer_tolerates_a_token_it_cannot_parse(cls):
46
+ # A malformed token must not raise here. The server is the sole authority on
47
+ # token validity; failing locally would replace a clear 401 with a confusing
48
+ # client-side crash.
49
+ assert cls.from_bearer("not-a-jwt").agent_id == ""
50
+ assert cls.from_bearer(_jwt({"no": "identity"})).agent_id == ""
51
+
52
+
53
+ @pytest.mark.parametrize("cls", BEARER_CLASSES)
54
+ def test_child_identity_client_can_name_its_own_subject(cls):
55
+ """The path that actually broke: ChildIdentity.client() -> evaluate()."""
56
+ child = ChildIdentity(
57
+ child_agent_id="agt_child789",
58
+ bearer_token=_jwt({"agent_id": "agt_child789", "owner_id": "own_1"}),
59
+ expires_at="2099-01-01T00:00:00Z",
60
+ granted=[{"platform": "github", "scopes": ["github:repo.read"]}],
61
+ )
62
+ built = child.client() if cls is AgentValet else child.aio_client()
63
+ assert built.agent_id == "agt_child789", "an empty subject.id is what the proxy 400s on"
@@ -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!")
@@ -572,3 +625,4 @@ async def test_child_identity_client_and_aio_client_build_bearer_clients():
572
625
  async_client = identity.aio_client()
573
626
  assert isinstance(async_client, AsyncAgentValet)
574
627
  assert async_client._bearer_token == "tok_child_abc"
628
+
@@ -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