From d9d2c60563dde88cc4b585f16b0372f65346c589 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 16 Jun 2026 13:32:04 +0530 Subject: [PATCH 1/3] feat: derive agent identity from a single private-key env var --- docs/site/reference/environment-variables.mdx | 17 ++++ src/authsome/cli/client.py | 39 ++++++++- src/authsome/cli/identity.py | 26 ++++-- src/authsome/server/routes/identities.py | 8 ++ src/authsome/server/store/repositories.py | 8 ++ tests/cli/test_client_signing.py | 83 +++++++++++++++++-- tests/identity/test_identity.py | 11 ++- tests/identity/test_registry.py | 30 +++++++ tests/server/test_pop_auth.py | 23 +++++ 9 files changed, 228 insertions(+), 17 deletions(-) diff --git a/docs/site/reference/environment-variables.mdx b/docs/site/reference/environment-variables.mdx index e659afac..4ca2e68e 100644 --- a/docs/site/reference/environment-variables.mdx +++ b/docs/site/reference/environment-variables.mdx @@ -11,8 +11,25 @@ Authsome interacts with environment variables in three roles: **inputs** that ch |----------|---------| | `AUTHSOME_BASE_URL` | The daemon URL. On client machines, set this to point the CLI and proxy at a remote daemon instead of auto-starting one locally. On the daemon host, set this to the public URL when behind a reverse proxy — authsome uses it to build OAuth callback URLs such as `/auth/callback/oauth`. Defaults to `http://127.0.0.1:7998`. | | `AUTHSOME_HOME` | Override the default `~/.authsome` directory. Useful for tests, ephemeral environments, and per-project vaults. | +| `AUTHSOME_IDENTITY_PRIVATE_KEY` | Hex-encoded Ed25519 private key. The agent's full identity is derived from this single value: the DID is computed locally and the human-readable handle is resolved from the identity server. Sufficient on its own — no `authsome init` or local identity files required. | +| `AUTHSOME_IDENTITY` | *(Optional, deprecated)* Explicit handle override used alongside `AUTHSOME_IDENTITY_PRIVATE_KEY`. No longer required: the handle is resolved from the identity server. See the migration note below. | | `HTTP_PROXY` / `HTTPS_PROXY` | Honored by authsome's own outbound HTTP requests (token endpoints, device flow polling). The proxy started by `authsome run` is **set** as these variables in the child process; it does not chain through them. | +### Agent identity from a single key + +For headless, CI, and container deployments, supply only the private key: + +```bash +export AUTHSOME_IDENTITY_PRIVATE_KEY= +uv run authsome list +``` + +On startup the CLI derives the DID from the key and asks the identity server for the handle bound to that DID. If the DID is not yet registered, a handle is generated and registered automatically, then the standard browser claim flow runs. + + +**Migration:** the previous setup required both `AUTHSOME_IDENTITY` (handle) and `AUTHSOME_IDENTITY_PRIVATE_KEY`. The key alone is now sufficient. `AUTHSOME_IDENTITY` is still honored as an explicit handle override but is deprecated and no longer required. Resolving the handle requires the daemon to be reachable at CLI startup. + + ### `AUTHSOME_BASE_URL` for remote daemons For remote daemon deployments: diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index b057acff..4c4d4184 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -16,6 +16,7 @@ import authsome.errors as err_mod from authsome.cli.identity import RuntimeIdentity from authsome.config import get_authsome_config +from authsome.identity.helpers import generate_handle from authsome.identity.proof import POP_AUTH_SCHEME, create_proof_jwt from authsome.server.config import get_server_config @@ -130,6 +131,8 @@ async def _request( async def _proof_headers(self, method: str, path: str, body: bytes) -> dict[str, str]: identity = await self.ensure_identity_ready() + if identity.handle is None: + raise RuntimeError("Identity handle could not be resolved from the identity server") token = create_proof_jwt( private_key=identity.signer, issuer=identity.did, @@ -150,6 +153,9 @@ async def ensure_identity_ready(self) -> RuntimeIdentity: runtime = self._runtime_identity() if self._server_registered: return runtime + if runtime.handle is None: + runtime = await self._resolve_env_identity(runtime) + self._identity = runtime await self._check_server_registration(runtime) self._server_registered = True return runtime @@ -159,23 +165,38 @@ def _runtime_identity(self) -> RuntimeIdentity: self._identity = RuntimeIdentity.load(self._home) return self._identity + async def _resolve_env_identity(self, runtime: RuntimeIdentity) -> RuntimeIdentity: + """Resolve a handle-less env identity's handle from the identity server. + + Looks up the handle bound to the DID. When the DID is unknown the agent + is brand new, so a handle is generated; the existing registration/claim + flow then registers it. + """ + handle = await self.resolve_handle_by_did(runtime.did) + if handle is None: + handle = generate_handle() + return runtime.model_copy(update={"handle": handle}) + async def _check_server_registration(self, runtime: RuntimeIdentity) -> None: """Verify registration with the server; register and claim if needed.""" + handle = runtime.handle + if handle is None: + raise RuntimeError("Identity handle could not be resolved from the identity server") try: - identity_status = await self.get_identity_status(runtime.handle) + identity_status = await self.get_identity_status(handle) except httpx.HTTPStatusError as exc: if exc.response.status_code != status.HTTP_404_NOT_FOUND: raise - identity_status = await self.register_identity(runtime.handle, runtime.did) + identity_status = await self.register_identity(handle, runtime.did) reg_status = identity_status.get("registration_status", "") if reg_status == "claim_required": claim_url = identity_status.get("claim_url", "") if claim_url: self._open_claim_url(claim_url) - await self._poll_claim_completion(runtime.handle) + await self._poll_claim_completion(handle) elif reg_status == "rejected": - raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server") + raise RuntimeError(f"Agent '{handle}' claim was rejected by the server") def _open_claim_url(self, claim_url: str) -> None: print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr) @@ -259,6 +280,16 @@ async def register_identity(self, handle: str, did: str) -> dict[str, Any]: async def get_identity_status(self, handle: str) -> dict[str, Any]: return await self._get(f"{API_PREFIX}/identities/{handle}", protected=False) + async def resolve_handle_by_did(self, did: str) -> str | None: + """Return the handle the identity server has bound to ``did``, or None if unknown.""" + try: + payload = await self._get(f"{API_PREFIX}/identities/by-did/{did}", protected=False) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == status.HTTP_404_NOT_FOUND: + return None + raise + return payload.get("identity") + async def remove(self, provider: str) -> None: await self._delete(f"{API_PREFIX}/providers/{provider}") diff --git a/src/authsome/cli/identity.py b/src/authsome/cli/identity.py index d9b30828..d580e213 100644 --- a/src/authsome/cli/identity.py +++ b/src/authsome/cli/identity.py @@ -23,9 +23,14 @@ class RuntimeIdentity(BaseModel): - """Resolved acting identity for the current process.""" + """Resolved acting identity for the current process. - handle: str + ``handle`` is server-registered metadata, not part of the cryptographic + identity. When only a private key is supplied via the environment the handle + is left unresolved (``None``) and filled in later from the identity server. + """ + + handle: str | None = None did: str signer: Ed25519PrivateKey @@ -35,6 +40,11 @@ class RuntimeIdentity(BaseModel): def from_pkey(cls, handle: str, signer: Ed25519PrivateKey) -> Self: return cls(handle=validate_handle(handle), did=public_key_to_did_key(signer.public_key()), signer=signer) + @classmethod + def from_env_private_key(cls, signer: Ed25519PrivateKey) -> Self: + """Build a handle-less identity from a private key; the handle is resolved later.""" + return cls(handle=None, did=public_key_to_did_key(signer.public_key()), signer=signer) + @classmethod def from_filesystem(cls, home: Path, handle: str) -> Self: metadata = cls.load_metadata(home, handle) @@ -42,14 +52,20 @@ def from_filesystem(cls, home: Path, handle: str) -> Self: @classmethod def load(cls, home: Path, env: Mapping[str, str] | None = None) -> Self: - """Resolve the acting process identity from env or local identity files.""" + """Resolve the acting process identity from env or local identity files. + + A private key alone is sufficient: the DID is derived locally and the + handle is resolved from the identity server before first use. Supplying + ``AUTHSOME_IDENTITY`` alongside the key keeps the explicit handle. + """ handle_override, private_key_hex = cls._env_identity_values(env) - if private_key_hex and not handle_override: - raise ValueError("AUTHSOME_IDENTITY_PRIVATE_KEY requires AUTHSOME_IDENTITY") if handle_override and private_key_hex: return cls.from_pkey(handle_override, private_key_from_hex(private_key_hex)) + if private_key_hex: + return cls.from_env_private_key(private_key_from_hex(private_key_hex)) + return cls.ensure_local(home, active_handle=handle_override) @classmethod diff --git a/src/authsome/server/routes/identities.py b/src/authsome/server/routes/identities.py index 6d770765..5e167721 100644 --- a/src/authsome/server/routes/identities.py +++ b/src/authsome/server/routes/identities.py @@ -55,6 +55,14 @@ async def register_identity(body: RegisterIdentityRequest, request: Request) -> return registration_status.to_payload() +@router.get("/by-did/{did:path}") +async def resolve_identity_by_did(did: str, request: Request) -> dict[str, str]: + registration = await request.app.state.store.identity_registry.resolve_by_did(did) + if registration is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") + return {"identity": registration.handle, "did": registration.did} + + @router.get("/{handle}") async def get_identity_status(handle: str, request: Request) -> dict[str, str]: registration_status = await request.app.state.identity_bootstrap.get_identity_status(handle=handle) diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index 1c647754..6d06355d 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -428,6 +428,14 @@ async def register(self, *, handle: str, did: str) -> IdentityRegistration: async def resolve(self, handle: str) -> IdentityRegistration | None: row = await self._db.fetch_one("SELECT * FROM identity_registrations WHERE handle = ?", [handle]) + return self._registration_from_row(row) + + async def resolve_by_did(self, did: str) -> IdentityRegistration | None: + row = await self._db.fetch_one("SELECT * FROM identity_registrations WHERE did = ?", [did]) + return self._registration_from_row(row) + + @staticmethod + def _registration_from_row(row: Any | None) -> IdentityRegistration | None: if row is None: return None return IdentityRegistration( diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index 67e17cde..ec5f588f 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -311,14 +311,87 @@ def fake_request(method, url, data=None, headers=None, timeout=None): @pytest.mark.asyncio -async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_path: Path) -> None: +async def test_env_private_key_only_resolves_handle_from_server(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "00" * 32) + source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip() + RuntimeIdentity.key_path(tmp_path, source.handle).unlink() + RuntimeIdentity.metadata_path(tmp_path, source.handle).unlink() + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) + resolved_handle = "resolved-from-server-0001" + calls: list[tuple[str, str]] = [] - client = AuthsomeApiClient("http://127.0.0.1:7998") + def fake_request(method, url, data=None, headers=None, timeout=None): + calls.append((method, url)) + response = Mock() + response.status_code = status.HTTP_200_OK + response.raise_for_status.return_value = None + if "/api/identities/by-did/" in url: + response.json.return_value = {"identity": resolved_handle, "did": source.did} + elif f"/api/identities/{resolved_handle}" in url: + response.json.return_value = {"identity": resolved_handle, "registration_status": "claimed"} + else: + response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}} + return response + + _patch_httpx_request(monkeypatch, fake_request) + + identity = await AuthsomeApiClient("http://127.0.0.1:7998").ensure_identity_ready() + + assert identity.handle == resolved_handle + assert identity.did == source.did + assert ( + "GET", + f"http://127.0.0.1:7998/api/identities/by-did/{source.did}", + ) in calls + + +@pytest.mark.asyncio +async def test_env_private_key_only_registers_generated_handle_when_did_unknown( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip() + RuntimeIdentity.key_path(tmp_path, source.handle).unlink() + RuntimeIdentity.metadata_path(tmp_path, source.handle).unlink() + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) + registered: dict = {} + + def fake_request(method, url, data=None, headers=None, timeout=None): + response = Mock() + if "/api/identities/by-did/" in url: + response.status_code = status.HTTP_404_NOT_FOUND + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND) + ) + elif url.endswith("/api/identities/register"): + response.status_code = status.HTTP_200_OK + response.raise_for_status.return_value = None + registered.update(json.loads(data.decode("utf-8"))) + response.json.return_value = { + "identity": registered["handle"], + "did": registered["did"], + "registration_status": "claimed", + } + elif "/api/identities/" in url and method == "GET": + response.status_code = status.HTTP_404_NOT_FOUND + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND) + ) + else: + response.status_code = status.HTTP_200_OK + response.raise_for_status.return_value = None + response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}} + return response + + _patch_httpx_request(monkeypatch, fake_request) + + identity = await AuthsomeApiClient("http://127.0.0.1:7998").ensure_identity_ready() - with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"): - await client.ensure_identity_ready() + assert identity.handle is not None + assert registered["handle"] == identity.handle + assert registered["did"] == source.did @pytest.mark.asyncio diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index 20286ce4..7e7ce7f5 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -95,6 +95,11 @@ def test_runtime_identity_creates_missing_handle_override(tmp_path: Path) -> Non assert RuntimeIdentity.key_path(tmp_path, runtime.handle).exists() -def test_runtime_identity_rejects_private_key_without_handle(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"): - RuntimeIdentity.load(tmp_path, env={"AUTHSOME_IDENTITY_PRIVATE_KEY": "00" * 32}) +def test_runtime_identity_env_private_key_only_defers_handle(tmp_path: Path) -> None: + source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip() + + runtime = RuntimeIdentity.load(tmp_path, env={"AUTHSOME_IDENTITY_PRIVATE_KEY": private_key_hex}) + + assert runtime.handle is None + assert runtime.did == source.did diff --git a/tests/identity/test_registry.py b/tests/identity/test_registry.py index 6821a7db..9def7e78 100644 --- a/tests/identity/test_registry.py +++ b/tests/identity/test_registry.py @@ -2,6 +2,7 @@ import pytest +from authsome.cli.identity import RuntimeIdentity from authsome.identity.principal import ClaimStatus from authsome.server.store import ServerStore, create_server_store from authsome.server.store.repositories import IdentityClaimRegistry @@ -34,6 +35,35 @@ async def test_claim_creates_principal_and_default_vault(tmp_path: Path) -> None await store.close() +@pytest.mark.asyncio +async def test_resolve_by_did_returns_registered_handle(tmp_path: Path) -> None: + store = await _store(tmp_path) + + try: + identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + await store.identity_registry.register(handle=identity.handle, did=identity.did) + + resolved = await store.identity_registry.resolve_by_did(identity.did) + + assert resolved is not None + assert resolved.handle == identity.handle + assert resolved.did == identity.did + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_resolve_by_did_returns_none_for_unknown_did(tmp_path: Path) -> None: + store = await _store(tmp_path) + + try: + identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + + assert await store.identity_registry.resolve_by_did(identity.did) is None + finally: + await store.close() + + @pytest.mark.asyncio async def test_claim_is_immutable_for_existing_identity(tmp_path: Path) -> None: store = await _store(tmp_path) diff --git a/tests/server/test_pop_auth.py b/tests/server/test_pop_auth.py index 2e0fb2e1..5c11bf28 100644 --- a/tests/server/test_pop_auth.py +++ b/tests/server/test_pop_auth.py @@ -133,6 +133,29 @@ def test_registration_requires_claim(monkeypatch, tmp_path: Path) -> None: assert "/claim?" in response.json()["claim_url"] +def test_resolve_identity_by_did_returns_handle(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + + with create_server_test_client() as client: + client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did}) + response = client.get(f"/api/identities/by-did/{identity.did}") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["identity"] == identity.handle + assert response.json()["did"] == identity.did + + +def test_resolve_identity_by_did_returns_404_for_unknown_did(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + + with create_server_test_client() as client: + response = client.get(f"/api/identities/by-did/{identity.did}") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_whoami_rejects_wrong_path_claim(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") From 8d94e4d37f9081109d49b159354a3c86143635e6 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 16 Jun 2026 13:35:29 +0530 Subject: [PATCH 2/3] fix: ruff check --- tests/cli/test_client_signing.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index ec5f588f..b977be44 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -347,9 +347,7 @@ def fake_request(method, url, data=None, headers=None, timeout=None): @pytest.mark.asyncio -async def test_env_private_key_only_registers_generated_handle_when_did_unknown( - monkeypatch, tmp_path: Path -) -> None: +async def test_env_private_key_only_registers_generated_handle_when_did_unknown(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip() From 3a9f17e9bce010d197b639f6af57e36b2932008a Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Tue, 16 Jun 2026 08:50:01 -0700 Subject: [PATCH 3/3] fix: recover from 409 when concurrent agents register the same DID When two processes with the same private key race to register, the loser gets a 409 from the identity server. Re-resolve the handle by DID and continue instead of failing startup. Co-authored-by: Cursor --- src/authsome/cli/client.py | 18 +++++++++-- tests/cli/test_client_signing.py | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index 4c4d4184..ef6c00e0 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -156,7 +156,8 @@ async def ensure_identity_ready(self) -> RuntimeIdentity: if runtime.handle is None: runtime = await self._resolve_env_identity(runtime) self._identity = runtime - await self._check_server_registration(runtime) + runtime = await self._check_server_registration(runtime) + self._identity = runtime self._server_registered = True return runtime @@ -177,7 +178,7 @@ async def _resolve_env_identity(self, runtime: RuntimeIdentity) -> RuntimeIdenti handle = generate_handle() return runtime.model_copy(update={"handle": handle}) - async def _check_server_registration(self, runtime: RuntimeIdentity) -> None: + async def _check_server_registration(self, runtime: RuntimeIdentity) -> RuntimeIdentity: """Verify registration with the server; register and claim if needed.""" handle = runtime.handle if handle is None: @@ -187,7 +188,17 @@ async def _check_server_registration(self, runtime: RuntimeIdentity) -> None: except httpx.HTTPStatusError as exc: if exc.response.status_code != status.HTTP_404_NOT_FOUND: raise - identity_status = await self.register_identity(handle, runtime.did) + try: + identity_status = await self.register_identity(handle, runtime.did) + except httpx.HTTPStatusError as reg_exc: + if reg_exc.response.status_code != status.HTTP_409_CONFLICT: + raise + resolved_handle = await self.resolve_handle_by_did(runtime.did) + if resolved_handle is None: + raise + handle = resolved_handle + runtime = runtime.model_copy(update={"handle": handle}) + identity_status = await self.get_identity_status(handle) reg_status = identity_status.get("registration_status", "") if reg_status == "claim_required": @@ -197,6 +208,7 @@ async def _check_server_registration(self, runtime: RuntimeIdentity) -> None: await self._poll_claim_completion(handle) elif reg_status == "rejected": raise RuntimeError(f"Agent '{handle}' claim was rejected by the server") + return runtime def _open_claim_url(self, claim_url: str) -> None: print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr) diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index b977be44..59531f59 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -392,6 +392,61 @@ def fake_request(method, url, data=None, headers=None, timeout=None): assert registered["did"] == source.did +@pytest.mark.asyncio +async def test_env_private_key_only_recovers_when_register_races_on_did(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip() + RuntimeIdentity.key_path(tmp_path, source.handle).unlink() + RuntimeIdentity.metadata_path(tmp_path, source.handle).unlink() + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) + existing_handle = "winner-handle-0001" + by_did_calls: list[str] = [] + + def fake_request(method, url, data=None, headers=None, timeout=None): + response = Mock() + if "/api/identities/by-did/" in url: + by_did_calls.append(url) + if len(by_did_calls) == 1: + response.status_code = status.HTTP_404_NOT_FOUND + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND) + ) + else: + response.status_code = status.HTTP_200_OK + response.raise_for_status.return_value = None + response.json.return_value = {"identity": existing_handle, "did": source.did} + elif url.endswith("/api/identities/register"): + response.status_code = status.HTTP_409_CONFLICT + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Conflict", + request=Mock(), + response=Mock(status_code=status.HTTP_409_CONFLICT), + ) + elif f"/api/identities/{existing_handle}" in url: + response.status_code = status.HTTP_200_OK + response.raise_for_status.return_value = None + response.json.return_value = {"identity": existing_handle, "registration_status": "claimed"} + elif "/api/identities/" in url and method == "GET": + response.status_code = status.HTTP_404_NOT_FOUND + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND) + ) + else: + response.status_code = status.HTTP_200_OK + response.raise_for_status.return_value = None + response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}} + return response + + _patch_httpx_request(monkeypatch, fake_request) + + identity = await AuthsomeApiClient("http://127.0.0.1:7998").ensure_identity_ready() + + assert identity.handle == existing_handle + assert identity.did == source.did + assert len(by_did_calls) > 1 + + @pytest.mark.asyncio async def test_env_identity_does_not_update_active_agent(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))