From 88a643cae963ad090feb7779c7debce2895f718a Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Wed, 7 Oct 2026 23:10:32 +0100 Subject: [PATCH] feat(agent): sign in with a handle, discovering the PDS from its DID document Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 +++ CMakeLists.txt | 5 ++++ include/wolfram/agent.h | 16 ++++++++++++ src/agent/agent.c | 42 ++++++++++++++++++++++++++++++ test/test_agent_login_discovered.c | 40 ++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+) create mode 100644 test/test_agent_login_discovered.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 98eb082e..c1f7295f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Everything that changes for someone using Wolfram goes here, newest first, in th ## [Unreleased] +### Added + +- `wf_agent_login_discovered` signs in with a handle or DID and no service: it resolves the account's PDS from its DID document (`#atproto_pds`), points the agent there, and logs in at that PDS. Clients no longer need the user to type the host their account lives on. ([#187](https://github.com/ewanc26/wolfram/pull/187)) + ### Fixed - CI bounds each `apt-get` call to a timeout and retries it three times. A stuck package mirror had left jobs running for an hour, which blocked the release that waits on `CI gate`. ([#186](https://github.com/ewanc26/wolfram/pull/186)) diff --git a/CMakeLists.txt b/CMakeLists.txt index d1460e4e..578dc3eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1713,6 +1713,11 @@ if(WOLFRAM_BUILD_TESTS) target_include_directories(test_agent_set_bearer PRIVATE test ${cjson_SOURCE_DIR}) add_test(NAME agent_set_bearer COMMAND test_agent_set_bearer) + add_executable(test_agent_login_discovered test/test_agent_login_discovered.c) + target_link_libraries(test_agent_login_discovered PRIVATE wolfram) + target_include_directories(test_agent_login_discovered PRIVATE test ${cjson_SOURCE_DIR}) + add_test(NAME agent_login_discovered COMMAND test_agent_login_discovered) + add_executable(test_time test/test_time.c) target_link_libraries(test_time PRIVATE wolfram) target_include_directories(test_time PRIVATE test ${cjson_SOURCE_DIR}) diff --git a/include/wolfram/agent.h b/include/wolfram/agent.h index 3063f721..3b4a2a71 100644 --- a/include/wolfram/agent.h +++ b/include/wolfram/agent.h @@ -71,6 +71,22 @@ wf_status wf_agent_set_tls_rng(wf_agent *agent, wf_tls_rng_fn fn, void *userdata); /* Session management */ +/* + * Sign in without knowing the account's PDS in advance. `identifier` is a + * handle or a DID; it is resolved (DNS TXT, then /.well-known/atproto-did for + * handles), the DID document's #atproto_pds endpoint is read, the agent is + * pointed at that PDS, and only then is the login sent there. So a user types + * their handle and password, and never the service they are hosted on. + * + * The agent's current base URL is used for resolution (any reachable host will + * do). `*out_pds` is set to the PDS the login was sent to and is + * heap-allocated; the caller frees it. On failure before the login nothing is + * changed: the agent keeps its previous base URL. WF_ERR_NOT_FOUND when the DID + * document has no PDS endpoint. + */ +wf_status wf_agent_login_discovered(wf_agent *agent, const char *identifier, + const char *password, char **out_pds); + wf_status wf_agent_login(wf_agent *agent, const char *identifier, const char *password); wf_status wf_agent_resume(wf_agent *agent, const wf_session_data *data); diff --git a/src/agent/agent.c b/src/agent/agent.c index d74b0195..f824e53f 100644 --- a/src/agent/agent.c +++ b/src/agent/agent.c @@ -2,6 +2,7 @@ #include "wolfram/util.h" #include "wolfram/identity.h" +#include "wolfram/xrpc.h" #include "wolfram/repo.h" #include "wolfram/richtext.h" #include "wolfram/server.h" @@ -650,6 +651,47 @@ wf_status wf_agent_login(wf_agent *agent, const char *identifier, return WF_OK; } +wf_status wf_agent_login_discovered(wf_agent *agent, const char *identifier, + const char *password, char **out_pds) { + if (out_pds) { + *out_pds = NULL; + } + if (!agent || !agent->session || !agent->client || !identifier || + !identifier[0] || !password || !out_pds) { + return WF_ERR_INVALID_ARG; + } + + char *did = NULL; + char *pds = NULL; + wf_status status; + if (wf_did_method_of(identifier) != WF_DID_METHOD_UNKNOWN) { + did = wf_str_dup(identifier); + status = did ? WF_OK : WF_ERR_ALLOC; + } else { + status = wf_handle_resolve(agent->client, identifier, &did); + } + if (status == WF_OK) { + status = + wf_did_resolve_service_by_id(agent->client, did, "#atproto_pds", + "AtprotoPersonalDataServer", &pds); + } + if (status != WF_OK) { + free(did); + free(pds); + return status; + } + + wf_xrpc_client_set_base_url(agent->client, pds); + status = wf_agent_login(agent, identifier, password); + free(did); + if (status != WF_OK) { + free(pds); + return status; + } + *out_pds = pds; + return WF_OK; +} + wf_status wf_agent_resume(wf_agent *agent, const wf_session_data *data) { if (!agent || !agent->session || !agent->client || !data) { return WF_ERR_INVALID_ARG; diff --git a/test/test_agent_login_discovered.c b/test/test_agent_login_discovered.c new file mode 100644 index 00000000..2b3f0dac --- /dev/null +++ b/test/test_agent_login_discovered.c @@ -0,0 +1,40 @@ +/* + * test_agent_login_discovered.c -- wf_agent_login_discovered's argument checks, + * and that a handle which cannot be resolved fails before any login is sent, + * with no PDS reported. The network path is exercised by the consumers' e2e + * runs. + */ + +#include "wolfram/agent.h" + +#include "test.h" + +#include + +int main(void) { + wf_agent *a = wf_agent_new("https://bsky.social"); + char *pds = NULL; + + WF_CHECK(a != NULL); + if (!a) WF_TEST_SUMMARY(); + + WF_CHECK(wf_agent_login_discovered(NULL, "alice.example.com", "pw", &pds) == + WF_ERR_INVALID_ARG); + WF_CHECK(pds == NULL); + WF_CHECK(wf_agent_login_discovered(a, "", "pw", &pds) == + WF_ERR_INVALID_ARG); + WF_CHECK(wf_agent_login_discovered(a, "alice.example.com", NULL, &pds) == + WF_ERR_INVALID_ARG); + WF_CHECK(wf_agent_login_discovered(a, "alice.example.com", "pw", NULL) == + WF_ERR_INVALID_ARG); + + /* `.invalid` never resolves (RFC 2606), so discovery fails and no login is + * sent: the out-parameter stays NULL. */ + pds = (char *)0x1; + WF_CHECK(wf_agent_login_discovered(a, "nobody.invalid", "pw", &pds) != + WF_OK); + WF_CHECK(pds == NULL); + + wf_agent_free(a); + WF_TEST_SUMMARY(); +}