Skip to content

feat(onboarding): support self-register invitation flow as alternativ…#9

Merged
Noah-Bytes merged 5 commits into
mainfrom
feat/self-register-onboarding
Jul 20, 2026
Merged

feat(onboarding): support self-register invitation flow as alternativ…#9
Noah-Bytes merged 5 commits into
mainfrom
feat/self-register-onboarding

Conversation

@Noah-Bytes

Copy link
Copy Markdown
Collaborator

…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 test passes locally
  • npm run build passes locally

Checklist

  • No secrets / credentials committed
  • Semgrep / Gitleaks findings addressed, or a // nosemgrep: <rule-id> suppression is justified inline and approved by the project lead

…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 gavin09527 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 → /me hydrate) mirrors cws-core's own zylosInstallSpec self-onboarding — codex was the outlier, and this aligns it.
  • exchangeAgentToken's orgId is correctly made optional by omitting the key (not sending org_id:"") — this matches cws-core's auth.go contract (OrgID *string ...omitempty), where key-absent ⇒ identity-only JWT. The KILLING test asserting Object.keys(body) === [] pins exactly the right thing.
  • Trusting the accept response's org_id over the caller-supplied input.org_id is correct (the invitation resolves the authoritative org), and tested.
  • Secret hygiene holds on the new paths: the minted api_key and the invitation_token never appear in error messages (dedicated tests), config stays 0600.
  • 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

  1. CF-Access headers (P2, pre-existing & orthogonal — flag for follow-up, not this PR). postJson (used by registerAgent / exchangeAgentToken / acceptInvitation / fetchSelf) sends only content-type + optional authorization. On a CF-Access-gated deployment (e.g. cws-int.coco.xyz) every one of these init calls 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 reads COCO_CF_ACCESS_CLIENT_ID/SECRET from env; init's bespoke postJson doesn't. Suggest a follow-up threading CF-Access (env first, mirroring the SDK's cfAccessHeaders) into these onboarding fetches so init works on CF-gated servers. Out of scope for this PR (its target is the self-register flow, which is correct on non-CF servers).

  2. "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, buildConfig silently 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.

Noah-Bytes added 4 commits July 20, 2026 22:29
…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 gavin09527 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 both postJson (so registerAgent / exchangeAgentToken / acceptInvitation all carry it) and fetchSelf. So init now 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)fetchNoAutoFollow sets redirect:"manual", follows only same-origin redirects (bounded by MAX_REDIRECT_HOPS), and throws on a cross-origin Location so the Authorization: Bearer never 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 are problem+json {type,title,status,detail,code} with no message/error, so detail ?? 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 /me error paths.

No new issues. LGTM — nice turnaround on the CF-Access gap.

@Noah-Bytes Noah-Bytes self-assigned this Jul 20, 2026

@gavin09527 gavin09527 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 👍

@Noah-Bytes
Noah-Bytes merged commit 2e9221e into main Jul 20, 2026
3 checks passed
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.

2 participants