feat(onboarding): support self-register invitation flow as alternativ…#9
Conversation
…e to direct credentials init previously hard-required a platform-provisioned api_key + identity_id, on the mistaken belief (from a misdiagnosed live 401/MEMBER_INVALID_AGENT_OWNER response) that agents can never self-accept an invitation. cws-core's own tests confirm agent self-accept works, and it's already the standard path for the platform's default "zylos" agent type. buildConfig now also accepts invitation_id + invitation_token: self-register (POST /auth/register/agent) -> identity-only JWT -> accept invitation -> org-scoped JWT -> hydrate self, producing the identical config.json shape as the direct-credential path. exchangeAgentToken's org_id is now optional (omitted from the body, not sent empty) to support the identity-only exchange without touching its existing call sites/tests.
gavin09527
left a comment
There was a problem hiding this comment.
Review — CLEAN, no P1/P2 blockers ✅
Reviewed the self-register onboarding flow. It's well-designed and matches the platform's canonical pattern.
What's right
- The self-register sequence (
POST /auth/register/agent→ identity-only JWT →accept→ org-scoped JWT →/mehydrate) mirrors cws-core's ownzylosInstallSpecself-onboarding — codex was the outlier, and this aligns it. exchangeAgentToken'sorgIdis correctly made optional by omitting the key (not sendingorg_id:"") — this matches cws-core'sauth.gocontract (OrgID *string ...omitempty), where key-absent ⇒ identity-only JWT. TheKILLINGtest assertingObject.keys(body) === []pins exactly the right thing.- Trusting the accept response's
org_idover the caller-suppliedinput.org_idis correct (the invitation resolves the authoritative org), and tested. - Secret hygiene holds on the new paths: the minted
api_keyand theinvitation_tokennever appear in error messages (dedicated tests), config stays0600. - Validation happens before any network call for every missing-field permutation (tests assert
calls.length === 0). - The earlier
MEMBER_INVALID_AGENT_OWNER"agents can't self-accept" conclusion was a misdiagnosis; the corrected reading (it's about the resolved owner field, not the acceptor) is right and now documented with the cws-core test references.
Non-blocking notes
-
CF-Access headers (P2, pre-existing & orthogonal — flag for follow-up, not this PR).
postJson(used byregisterAgent/exchangeAgentToken/acceptInvitation/fetchSelf) sends onlycontent-type+ optionalauthorization. On a CF-Access-gated deployment (e.g.cws-int.coco.xyz) every one of theseinitcalls returns 403 — the direct path already had this gap, and the self-register path adds two more gated calls (register,accept) that inherit it. The runtime (start) path is fine because the openmax-agent-sdk transport readsCOCO_CF_ACCESS_CLIENT_ID/SECRETfrom env;init's bespokepostJsondoesn't. Suggest a follow-up threading CF-Access (env first, mirroring the SDK'scfAccessHeaders) into these onboarding fetches soinitworks on CF-gated servers. Out of scope for this PR (its target is the self-register flow, which is correct on non-CF servers). -
"Mutually-exclusive" not enforced (P3). Docs say the two credential shapes are mutually-exclusive, but if a caller supplies both a full direct pair and a full invitation pair,
buildConfigsilently prefers direct (hasAnyDirect || !hasAnyInvitation). Fine as a precedence rule — just either reject both-supplied explicitly or soften the doc wording to "direct takes precedence".
Nice work — approving in spirit; the two notes are follow-ups, neither blocks merge.
…ation The two OnboardInput credential shapes aren't strictly mutually exclusive at the code level (buildConfig falls through to direct whenever any api_key/identity_id is present, even alongside an invitation_id/token) — the comment previously implied exclusivity. Noah flagged this in review; wording now matches the actual priority behavior instead.
CF-gated deployments (e.g. cws-int) reject every onboarding request
(register/token/accept/self) at the Cloudflare Access layer without
CF-Access-Client-Id/Secret — before it ever reaches cws-core, for both
credential shapes. Reuses the SDK's own cfAccessHeaders() (env-first,
{} when unset) instead of reimplementing it.
Noah flagged this in review (P2, non-blocking) as a pre-existing gap
made more visible by self-register adding two more gated calls.
register/token/accept/me all carry a Bearer credential (api_key or JWT) to cws-core. Native fetch auto-follows 3xx and re-sends those headers to the redirect target — a cross-origin redirect would leak the credential to a third party. Mirrors the SDK's existing TokenManager/CwsHttpClient P1-C guard: disable auto-follow, refuse any hop that leaves the origin, downgrade method/body on 301/302/303 per the Fetch spec. Found while auditing the SDK for other onboarding gaps after wiring CF-Access headers (this same PR) — the SDK already solved this exact problem for its own calls, but onboarding.ts's hand-rolled fetch never had it.
…+json)
postJson/fetchSelf's error extraction checked message/error, but
cws-core's HTTP errors are RFC 9457 problem+json (verified against
internal/transport/http/errors.go: {type, title, status, detail,
code, field}) — there is no message/error key at all. Every real
onboarding failure was silently reduced to a bare "HTTP 401" with no
explanation, undermining the "paste the failure back into chat"
support path the file's own header comment describes. Now extracts
detail (+ code) first, matching the SDK's own priority order
(detail || error || message); message/error kept as a defensive
fallback for any non-cws-core hop in front.
Found during the SDK-gap audit prompted by Noah's review.
gavin09527
left a comment
There was a problem hiding this comment.
Re-review (3 new commits since my first pass) — still CLEAN ✅
All three follow-ups are solid, and one directly resolves the P2 I raised earlier:
attach CF-Access headers to init requests(3176dc2) — resolves my P2.cfAccessHeaders()(reused from the SDK, env-first) is now merged into bothpostJson(soregisterAgent/exchangeAgentToken/acceptInvitationall carry it) andfetchSelf. Soinitnow works on a CF-Access-gated deployment (cws-int). Coverage is complete — every onboarding hop is CF-aware. 👍refuse cross-origin redirects (credential-leak guard)(73dffe1) —fetchNoAutoFollowsetsredirect:"manual", follows only same-origin redirects (bounded byMAX_REDIRECT_HOPS), and throws on a cross-originLocationso theAuthorization: Bearernever gets replayed to another host. Correct threat model (redirect-based credential exfil), sound implementation (relative-URL resolution + origin compare).extract real cws-core error detail (RFC 9457)(daa1f19) — correct: cws-core errors areproblem+json{type,title,status,detail,code}with nomessage/error, sodetail ?? error ?? message+(code)is the right priority (matches the SDK), and keeping error/message as a defensive fallback for a fronting proxy is reasonable. Secret hygiene still holds (detail/code are non-sensitive; no JWT/token/api_key in the surfaced text; endpoint-label redaction preserved). Good KILLING tests for both the accept and/meerror paths.
No new issues. LGTM — nice turnaround on the CF-Access gap.
gavin09527
left a comment
There was a problem hiding this comment.
Approving — reviewed across both rounds, CLEAN. Self-register flow matches cws-core's contract, CF-Access now attached to all onboarding requests (resolves my earlier P2), cross-origin redirect guard + RFC 9457 error extraction are correct, tests are thorough. 👍
…e to direct credentials
init previously hard-required a platform-provisioned api_key + identity_id, on the mistaken belief (from a misdiagnosed live 401/MEMBER_INVALID_AGENT_OWNER response) that agents can never self-accept an invitation. cws-core's own tests confirm agent self-accept works, and it's already the standard path for the platform's default "zylos" agent type. buildConfig now also accepts invitation_id + invitation_token: self-register (POST /auth/register/agent) -> identity-only JWT -> accept invitation -> org-scoped JWT -> hydrate self, producing the identical config.json shape as the direct-credential path. exchangeAgentToken's org_id is now optional (omitted from the body, not sent empty) to support the identity-only exchange without touching its existing call sites/tests.
What
Why
Test plan
npm testpasses locallynpm run buildpasses locallyChecklist
// nosemgrep: <rule-id>suppression is justified inline and approved by the project lead