Skip to content

fix: quickstart hand-rolls the actor assertion the SDK now builds - #297

Open
KunalJavelin wants to merge 2 commits into
mainfrom
fix/quickstart-use-build-actor-assertion
Open

fix: quickstart hand-rolls the actor assertion the SDK now builds#297
KunalJavelin wants to merge 2 commits into
mainfrom
fix/quickstart-use-build-actor-assertion

Conversation

@KunalJavelin

Copy link
Copy Markdown
Contributor

What is stale

Section 4 hand-rolls the RFC 7523 actor assertion with pyjwt.encode, then exchanges it with issue_token_exchange. Both have had SDK helpers since highflame-sdk#32:

  • build_actor_assertion() — builds the assertion
  • delegate_to() — builds it and performs the exchange, resolving aud from the client's own issuer

What changed

The manual cells stay. This is ZeroID's own repo and section 4 is titled "Agent-to-Agent Delegation (RFC 8693)" — walking through the mechanics is the point there, and replacing them would remove the explanation. This adds the one-call equivalent immediately after, so a reader knows they do not have to hand-roll either in their own code.

Two cells, purely additive.

Verified the helper is a drop-in

Compared build_actor_assertion() output against what the notebook writes by hand, decoding both:

alg           build_actor_assertion=ES256    manual=ES256
iss == sub    both the agent's WIMSE URI
aud           issuer as a string; manual uses a list — server accepts both
ttl           120s default (configurable)    manual 300s
extra         adds a `jti` nonce the manual version omits

So it is equivalent and slightly better — the nonce guards replay, which the hand-rolled version does not.

The claim set is exacting (iss must be the WIMSE URI exactly, aud must be the issuer) and the server reports any mistake as an undifferentiated invalid_grant. That is what makes hand-rolling it expensive, and worth saying in the notebook.

Deliberately left alone

localhost:8899 and the separate ZeroIDClient are correct here, and I want to be explicit because they look like staleness at a glance:

  • This notebook is a walkthrough of a locally-run ZeroID — its own prerequisites say "Start ZeroID locally". Pointing it at SaaS would break its premise.
  • Using the unified Highflame client in the ZeroID service's own repo would be odd; ZeroIDClient is the right surface for a notebook about ZeroID.

highflame-sdk#124's audit flagged these patterns, but it was auditing highflame-sdk/examples/zeroid_quickstart.ipynb — a different, shorter notebook that was separately rewritten in 332ca15a. This one covers considerably more (OAuth2 client credentials, introspection and revocation, credential policies, CAE signals) and serves a different purpose.

Not fixed here, worth flagging

identities.create appears twice and works against a local instance in dev mode, but returns [403] management token not permitted on this route against SaaS — verified today. Harmless for this notebook's local premise, but a reader who moves to SaaS will hit it. Happy to add a note if you want; left out to keep this change tight. Related: highflame-sdk#138.

Testing

Not executed — it needs a locally-built ZeroID, which I do not have running. The added cells use the same variables the surrounding cells already define (tool_agent, tool_agent_private_key, issuer_url, orchestrator_token), and the helper behaviour is verified above against SaaS. Worth a run by someone with a local instance before merge.

Refs highflame-sdk#124.

Section 4 signs the RFC 7523 assertion with `pyjwt.encode` and exchanges it with
`issue_token_exchange`. Both have had SDK helpers since highflame-sdk#32:
`build_actor_assertion()` builds the assertion, and `delegate_to()` builds it and
performs the exchange in one call, resolving `aud` from the client's own issuer.

Keeping the manual cells rather than replacing them — this is ZeroID's own repo
and the mechanics are the point of that section. Adds the one-call equivalent
after them, so a reader knows they do not have to hand-roll either in their own
code.

Verified the helper is a drop-in for what the notebook writes by hand:

    alg           build_actor_assertion=ES256   manual=ES256
    iss == sub    both the agent's WIMSE URI
    aud           issuer (string vs the manual list; server accepts both)
    ttl           120s default, configurable   manual 300s
    extra         adds a `jti` nonce the manual version omits

The claim set is exacting — `iss` must be the WIMSE URI exactly, `aud` must be
the issuer — and the server reports any mistake as an undifferentiated
`invalid_grant`, which is what makes hand-rolling it expensive.

Left alone deliberately: `localhost:8899` and the separate `ZeroIDClient` are
correct here. This notebook is a walkthrough of a locally-run ZeroID in the
service's own repo, not an SDK quickstart, so pointing it at SaaS or the unified
client would be wrong.

Refs highflame-sdk#124.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KunalJavelin

Copy link
Copy Markdown
Contributor Author

Tried to test this locally, and hit a blocker that is not in this PR

There is a healthy highflame-zeroid:quickstart container on :8899, so I ran the notebook against it. It fails before reaching my added cells — on its first agents call:

NotFoundError: [404] Not Found: 404 page not found
SDK sent: GET /agents/registry?limit=20&offset=0

Cause: the SDK hard-codes the admin route prefix, and this container serves the other one.

Path local :8899 SaaS
POST /agents/register 404 422
POST /api/v1/agents/register 422 404

Both are legitimate — config.go makes it a deployment setting (DefaultAdminPathPrefix = "/api/v1" for standalone; highflame-authn sets ""). The SDK exposes no way to choose, so it currently reaches SaaS only. Filed as highflame-sdk#145.

What that means for this PR

The notebook cannot execute against a local ZeroID with SDK 0.3.22 at all, with or without my change. So my added cells remain unverified by execution, and I would rather say that than imply otherwise.

What is verified: build_actor_assertion() produces an assertion equivalent to the hand-rolled pyjwt.encode above it — same alg, iss == sub == wimse_uri, aud the issuer, plus a jti nonce the manual version omits. Confirmed by decoding both. And delegate_to() works end to end, which I exercised against SaaS while integrating ZeroID into a multi-agent system.

The added cells use only variables the surrounding cells already define — tool_agent, tool_agent_private_key, issuer_url, orchestrator_token — so nothing new is introduced that could fail independently.

Suggestion

Reasonable to merge on that basis, since the change is additive and the helpers are verified. But this notebook is unrunnable end to end for anyone following its own prerequisites until #145 is resolved — which is worth knowing regardless of this PR, and is arguably the more urgent of the two.

Found by executing the notebook against the quickstart container, which became
possible once highflame-sdk gained a configurable admin path prefix
(highflame-sdk#145).

`tokens.revoke(delegated_token)` in section 5 raises: RFC 7009 authenticates the
*client*, so revocation needs the OAuth client credentials — which section 3
already has in scope as `oauth_client.client_id` and `client_secret`. Without
them ZeroID answers `invalid_client`.

`delegate_to` in the cell added earlier on this branch needs an explicit
`audience`. It normally reads the issuer off the client's own token, and this
notebook's client is unauthenticated; `issuer_url` is already resolved by the
discovery cell.

All 60 cells now run clean against `highflame-zeroid:quickstart`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KunalJavelin

Copy link
Copy Markdown
Contributor Author

It runs now — all 60 cells, against the quickstart container

highflame-sdk#147 makes the admin path prefix configurable, which unblocked execution. Running the notebook then surfaced two more cells that could not work, both now fixed on this branch:

tokens.revoke(delegated_token) in section 5 raised:

revoke() requires client_id and client_secret — RFC 7009 authenticates the client, and ZeroID rejects an unauthenticated revocation with 'invalid_client'.

Section 3 already creates an OAuth client, so oauth_client.client_id and client_secret were in scope — just not passed. Now passed, with a comment on why, and a pointer to agents.deactivate() for the no-OAuth-client case (the SDK's own error message suggests it, and it is the better answer for revoking an agent rather than a token).

delegate_to in the cell I added needed an explicit audience. It resolves the issuer from the client's own token, and this notebook's client is unauthenticated. issuer_url was already resolved by the discovery cell. My bug, not the SDK's.

Verification

patched 1 client construction (admin_path_prefix="/api/v1", pending sdk#147)
executing 60 cells ...
RESULT: all 60 cells run clean against the local container

My added cells output:

assertion built:   eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...
delegated in one call, scope: data:read

So build_actor_assertion() and delegate_to() both work against a standalone ZeroID, which is what this PR set out to show.

Ordering

This PR is independent of sdk#147 for review, but the notebook is only runnable end to end once #147 ships — until then ZeroIDClient(base_url="http://localhost:8899") 404s on its first agents call, with or without these changes.

Two options: merge now and accept that the notebook needs #147 to run (it already did before this PR), or wait for #147 and add the admin_path_prefix="/api/v1" argument to the setup cell here so the notebook is self-contained. I would lean to the second — the setup cell currently tells a reader to run ZeroID locally and then hands them a client that cannot reach it. Happy to add that line whenever #147 lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant