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.
- {agentvalet-0.2.0 → agentvalet-0.2.2}/PKG-INFO +8 -3
- {agentvalet-0.2.0 → agentvalet-0.2.2}/README.md +7 -2
- {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/README.md +3 -2
- {agentvalet-0.2.0 → agentvalet-0.2.2}/pyproject.toml +1 -1
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/__init__.py +1 -1
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/_protocol.py +37 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/aio.py +9 -22
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/client.py +14 -25
- agentvalet-0.2.2/src/agentvalet/env.py +264 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/register.py +4 -1
- {agentvalet-0.2.0 → agentvalet-0.2.2}/tests/conftest.py +18 -0
- agentvalet-0.2.2/tests/test_bearer_identity.py +63 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/tests/test_client.py +54 -0
- agentvalet-0.2.2/tests/test_env.py +192 -0
- agentvalet-0.2.0/src/agentvalet/env.py +0 -119
- agentvalet-0.2.0/tests/test_env.py +0 -94
- {agentvalet-0.2.0 → agentvalet-0.2.2}/.gitignore +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/01_quickstart.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/02_approvals.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/03_langchain_tools.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/04_support_triage_agent.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/examples/05_langgraph_subagents.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/_constants.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/_signing.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/child.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/errors.py +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/src/agentvalet/py.typed +0 -0
- {agentvalet-0.2.0 → agentvalet-0.2.2}/tests/test_constants.py +0 -0
- {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.
|
|
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
|
|
99
|
-
|
|
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
|
|
67
|
-
|
|
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
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
98
|
-
register`` (or ``npx @agentvalet/register``)
|
|
99
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|