diff --git a/README.md b/README.md index 44ca580..5f8099a 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ The adapter is normally onboarded by the **OpenMax workspace** ("Add Codex agent renders a prompt with the connection material inlined; pasting it into a Codex runs the two commands below with zero interactive input. You can also run them directly. +`init` accepts either of two mutually-exclusive credential shapes on stdin: + ```bash -# 1. Initialize — provisioned api_key + identity_id in a single JSON blob on stdin. +# 1a. Initialize with a provisioned api_key + identity_id (direct). codex-openmax init --stdin-json <<'ONBOARD' { "bff_url": "https://openmax.com", @@ -52,14 +54,30 @@ codex-openmax init --stdin-json <<'ONBOARD' } ONBOARD +# 1b. …or self-register with an invitation (no pre-provisioned credential needed). +codex-openmax init --stdin-json <<'ONBOARD' +{ + "bff_url": "https://openmax.com", + "ws_url": "wss://openmax.com/ws", + "org_id": "", + "invitation_id": "", + "invitation_token": "" +} +ONBOARD + # 2. Start — connect to CWS and run the adapter (foreground). codex-openmax start ``` -`init` exchanges the org JWT, hydrates the agent's own member info, and writes `config.json` -(mode `0600`; the api_key is never echoed). `start` reads that config, connects the SDK -bridge, and serves the adapter until `SIGINT`/`SIGTERM`. Requires the `codex` binary on -`PATH`. Full field contract + security notes: [`docs/onboarding-design.md`](docs/onboarding-design.md). +With the invitation shape, `init` self-registers a new agent identity +(`POST /auth/register/agent`), exchanges an identity-only JWT, accepts the invitation with it, +then exchanges an org-scoped JWT — the same self-register → identity-JWT → accept → org-JWT +pattern the platform's default "zylos" agent type already uses. Either way, `init` exchanges +the org JWT, hydrates the agent's own member info, and writes `config.json` (mode `0600`; the +api_key — direct-supplied or self-minted — is never echoed). `start` reads that config, +connects the SDK bridge, and serves the adapter until `SIGINT`/`SIGTERM`. Requires the `codex` +binary on `PATH`. Full field contract + security notes: +[`docs/onboarding-design.md`](docs/onboarding-design.md). ## Layout diff --git a/docs/onboarding-design.md b/docs/onboarding-design.md index d13b25a..fa93a0e 100644 --- a/docs/onboarding-design.md +++ b/docs/onboarding-design.md @@ -8,37 +8,53 @@ agent itself) installs this package, writes its config, starts the service and r the prompt drives; the workspace product owns rendering the prompt itself** (owner, 2026-07-20). Humans can also call the CLI directly. -**Credential form — corrected after live testing (2026-07-20).** The prompt embeds a -provisioned agent **`api_key` + `identity_id`**, NOT an invitation to redeem. Live testing -against the box org proved `/api/v1/invitations/{id}/accept` is a **human-only** flow: an -unauthenticated call 401s, and an authenticated agent JWT is rejected -`MEMBER_INVALID_AGENT_OWNER: new owner must be an active human member`. So a Codex agent can -never self-redeem an invitation. The platform side ("Add Codex agent", where the human is -already logged in) provisions the api_key and embeds it in the rendered prompt — matching the -SDK README's "dashboard/api-key provisioning" being platform-owned. The 07-17 "embed -invitation token → agent self-redeems" framing was the untested assumption this replaces; the -api_key path is verified working end-to-end (init → JWT via Bearer → /me hydration → config). +**Credential form — corrected again (2026-07-20), two supported shapes.** `init` now accepts +either of two mutually-exclusive credential shapes: + +- **(a) direct** — a platform-provisioned agent **`api_key` + `identity_id`**, embedded as-is + by the "Add Codex agent" flow (human already logged in, platform provisions the key). +- **(b) self-register** — an **`invitation_id` + `invitation_token`**. `init` redeems these + itself: `POST /auth/register/agent` (no auth) mints a fresh `identity_id` + `api_key` → + exchange an **identity-only** JWT (`POST /auth/agent/token` with the new api_key, `org_id` + omitted) → `POST /api/v1/invitations/{id}/accept` with that identity JWT and + `{"token": invitation_token}` → exchange an **org-scoped** JWT using the accept response's + `org_id` → hydrate `/api/v1/me`. + +An earlier version of this doc claimed step (b) was impossible — that reading a live +401/`MEMBER_INVALID_AGENT_OWNER` response as "agent self-accept is human-only" was a +misdiagnosis. cws-core's own tests (`TestAcceptInvitationSetsInviterAsAgentOwner`, +`TestAcceptInvitationUsesRequestedOwnerOverInviter` in `internal/app/org/service_test.go`) +confirm agent self-accept succeeds; `MEMBER_INVALID_AGENT_OWNER` fires only when the +invitation's *resolved owner* field is itself invalid — unrelated to whether the acceptor is +an agent. Live-tested end to end: `accept` returns 200 with `{member_id, org_id, role_slug}` +for an agent-held identity JWT. This is the same self-register → identity JWT → accept → org +JWT pattern the platform's default "zylos" agent type already uses to onboard itself +(cws-core's `zylosInstallSpec` prompt template) — codex-openmax was the outlier, not the norm. +Both shapes are verified working end-to-end and produce the identical `config.json`. ## Components | Piece | Where | Role | |-------|-------|------| -| `codex-openmax init` | `src/cli.ts` | Non-interactive: consume connection material (api_key + identity_id) → exchange JWT → hydrate self → write `config.json` → preflight `codex` binary | +| `codex-openmax init` | `src/cli.ts` | Non-interactive: consume connection material (direct api_key + identity_id, OR invitation_id + invitation_token to self-register) → exchange JWT → hydrate self → write `config.json` → preflight `codex` binary | | `codex-openmax start` | `src/cli.ts` | Productized `scripts/live-roundtrip.ts`: construct the real `CwsAgentBridge` from `config.json`, run `main()`, keep alive; `--daemon` mode later | **The onboarding prompt is rendered by the OpenMax workspace product, not this repo** (owner call, 2026-07-20). This repo owns only the CLI mechanical layer; the platform owns generating the paste-ready prompt. What the platform must embed in that prompt = exactly `init`'s -`--stdin-json` contract below (`bff_url`, `ws_url`, `org_id`, `api_key`, `identity_id`, optional -`local_http_port`), plus the security requirement that the api_key is a long-lived credential: -warn the user not to forward the prompt, and instruct the agent never to echo the key back. +`--stdin-json` contract below (`bff_url`, `ws_url`, `org_id`, plus EITHER `api_key` + +`identity_id` OR `invitation_id` + `invitation_token`, optional `local_http_port`), plus the +security requirement that the api_key (direct-supplied, or the one `init` mints for itself in +the self-register path) is a long-lived credential: warn the user not to forward the prompt, +and instruct the agent never to echo the key back. ## `init` contract (non-interactive, idempotent) -Reads one JSON blob on stdin (`--stdin-json`, what the prompt uses). Required: -`bff_url`, `ws_url`, `org_id`, `api_key`, `identity_id`. Optional: `local_http_port`. +Reads one JSON blob on stdin (`--stdin-json`, what the prompt uses). Always required: +`bff_url`, `ws_url`, `org_id`. Then EITHER `api_key` + `identity_id` (direct) OR +`invitation_id` + `invitation_token` (self-register). Optional: `local_http_port`. -Steps: +Steps — direct shape: 1. Validate all required fields present (before any network call). 2. Exchange the org JWT: `POST /auth/agent/token {org_id}` with `Authorization: Bearer ` (the shape the SDK's TokenManager sends and cws-core reads — verified live). @@ -48,6 +64,17 @@ Steps: 5. Preflight: `codex --version`; warn (not fail) if missing — `start` hard-fails. 6. Exit 0 with a one-line machine-readable summary (`{"ok":true,"org":"…","self":"…","codex":…}`). +Steps — self-register shape (invitation_id + invitation_token, no pre-provisioned credential): +1. Validate all required fields present (before any network call). +2. `POST /auth/register/agent` (no auth) → mint a fresh `identity_id` + `api_key`. +3. Exchange an **identity-only** JWT: `POST /auth/agent/token {}` (no `org_id`) with + `Authorization: Bearer `. +4. `POST /api/v1/invitations/{invitation_id}/accept` with that identity JWT and + `{"token": invitation_token}` → returns the authoritative `org_id` (+ member_id, role_slug). +5. Exchange an **org-scoped** JWT using the same api_key and the accept response's `org_id`. +6. Hydrate `self` from `GET /api/v1/me`, then write `config.json` exactly as in the direct + path (steps 4–6 above) — identical shape either way. + ## `start` contract 1. Load + validate `config.json` (fail fast with actionable message). @@ -59,10 +86,12 @@ Steps: ## Security posture -- The embedded `api_key` is a **long-lived credential** — the platform-rendered prompt must - warn the user not to forward/screenshot it, and instruct the agent never to echo it (report - success by org/display-name only). `init`'s success line and all error messages are already - key-free (errors carry endpoint labels, never raw URLs/values). +- The `api_key` — whether embedded directly or minted by `init` itself via self-register — is + a **long-lived credential**: the platform-rendered prompt must warn the user not to + forward/screenshot it, and the agent must never echo it (report success by org/display-name + only). `init`'s success line and all error messages are already key-free (errors carry + endpoint labels, never raw URLs/values), including errors from the self-register/accept + steps, which never leak the newly-minted api_key or the invitation_token. - `config.json` is written `0600` and gitignored (already is), guaranteed even when overwriting an existing file or when a loose temp file pre-exists (temp + chmod + atomic rename — see `writeConfigFile`). diff --git a/src/cli.ts b/src/cli.ts index ef25df7..37eb2a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node // codex-openmax CLI — the mechanical layer under the platform-rendered onboarding prompt // (docs/onboarding-design.md): -// init — non-interactive: connection material (stdin JSON: bff_url/ws_url/org_id + -// provisioned api_key + identity_id) -> exchange JWT -> hydrate self -> +// init — non-interactive: connection material (stdin JSON: bff_url/ws_url/org_id + EITHER +// a provisioned api_key + identity_id OR an invitation_id + invitation_token) -> +// exchange JWT (self-registering first if using an invitation) -> hydrate self -> // write config.json (0600). Never echoes secrets. // start — load config.json -> real SDK bridge -> main() (adapter server), foreground, // graceful SIGINT/SIGTERM. @@ -33,12 +34,18 @@ async function cmdInit(args: string[]): Promise { console.error("init: stdin is not valid JSON"); process.exit(1); } - for (const f of ["bff_url", "ws_url", "org_id", "api_key", "identity_id"] as const) { + for (const f of ["bff_url", "ws_url", "org_id"] as const) { if (typeof input[f] !== "string" || !input[f]) { console.error(`init: missing required field "${f}"`); process.exit(1); } } + const hasDirect = typeof input.api_key === "string" && !!input.api_key && typeof input.identity_id === "string" && !!input.identity_id; + const hasInvitation = typeof input.invitation_id === "string" && !!input.invitation_id && typeof input.invitation_token === "string" && !!input.invitation_token; + if (!hasDirect && !hasInvitation) { + console.error(`init: missing required fields — supply either ("api_key" + "identity_id") or ("invitation_id" + "invitation_token")`); + process.exit(1); + } try { const config = await buildConfig(globalThis.fetch as unknown as FetchLike, input); writeConfigFile(fs, "config.json", config); // 0600 guaranteed even on overwrite (temp + atomic rename) diff --git a/src/onboarding.ts b/src/onboarding.ts index 4842a25..ba9a062 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,22 +1,91 @@ // P2-① init plumbing: JWT exchange + org self-hydration (docs/onboarding-design.md). -// The onboarding credential is a platform-provisioned api_key + identity_id — there is NO -// agent-side invitation redemption (live-tested 2026-07-20: /invitations/accept is -// human-only). Header/endpoint shapes pinned from the SDK's TokenManager + a live check. +// Two supported onboarding credential shapes: (a) a platform-provisioned api_key + +// identity_id supplied directly, or (b) an invitation_id + invitation_token, which init +// redeems itself via self-register -> identity-only JWT -> accept invitation -> org JWT. +// (Corrected 2026-07-20: an earlier read of a live 401/MEMBER_INVALID_AGENT_OWNER response +// was misdiagnosed as "agents can't self-accept invitations" — cws-core's own tests +// (TestAcceptInvitationSetsInviterAsAgentOwner, TestAcceptInvitationUsesRequestedOwnerOverInviter) +// confirm agent self-accept works; that error fires only when the invitation's resolved +// owner field is itself invalid, unrelated to whether the acceptor is an agent. This is the +// same flow the platform's default "zylos" agent type already uses for self-onboarding.) +// Header/endpoint shapes pinned from the SDK's TokenManager + a live check. // fetch is injected for tests; node 20 global fetch in production. +// +// CF-Access: on a CF-gated deployment (e.g. cws-int), every request below needs +// CF-Access-Client-Id/Secret or Cloudflare Access rejects it before it ever reaches +// cws-core — unrelated to cws-core auth. Reused from the SDK's own cfAccessHeaders() +// (reads COCO_CF_ACCESS_CLIENT_ID/SECRET from env; {} when unset, so it's safe to +// spread unconditionally on unprotected deployments too). +import { cfAccessHeaders } from "@openmaxai/openmax-agent-sdk"; -export type FetchLike = (url: string, init?: { method?: string; headers?: Record; body?: string }) => Promise<{ +type FetchInit = { method?: string; headers?: Record; body?: string; redirect?: "manual" | "follow" }; +export type FetchLike = (url: string, init?: FetchInit) => Promise<{ ok: boolean; status: number; + headers?: { get(name: string): string | null }; text(): Promise; }>; +type FetchResponse = Awaited>; + +// ── redirect handling (credential-leak guard, mirrors the SDK's TokenManager/CwsHttpClient +// P1-C) ────────────────────────────────────────────────────────────────────────────────── +// 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 — for a +// cross-origin redirect that leaks the credential to a third party. We disable auto-follow +// and refuse any hop that leaves the origin we started from. +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECT_HOPS = 5; + +function safeOrigin(u: string): string { + try { + return new URL(u).origin; + } catch { + return u; + } +} + +// Matches native fetch's method/body semantics per the WHATWG Fetch spec: 301/302 downgrade +// POST->GET; 303 downgrades any non-GET/HEAD; 307/308 preserve method+body. +function rewriteInitForRedirect(init: FetchInit, status: number): FetchInit { + const method = (init.method ?? "GET").toUpperCase(); + const isGetOrHead = method === "GET" || method === "HEAD"; + const downgradeToGet = (status === 303 && !isGetOrHead) || ((status === 301 || status === 302) && method === "POST"); + if (!downgradeToGet) return init; + const { body: _body, ...rest } = init; + return { ...rest, method: "GET" }; +} + +async function fetchNoAutoFollow(fetchFn: FetchLike, url: string, init: FetchInit): Promise { + const startOrigin = safeOrigin(url); + let curUrl = url; + let curInit: FetchInit = { ...init, redirect: "manual" }; + for (let hop = 0; ; hop++) { + const res = await fetchFn(curUrl, curInit); + if (!REDIRECT_STATUSES.has(res.status)) return res; + const loc = res.headers?.get("location") ?? res.headers?.get("Location") ?? null; + if (!loc) return res; + if (hop >= MAX_REDIRECT_HOPS) throw new Error(`too many redirects (>${MAX_REDIRECT_HOPS}) from ${url}`); + let nextUrl: string; + try { + nextUrl = new URL(loc, curUrl).toString(); + } catch { + throw new Error(`invalid redirect Location "${loc}" from ${curUrl}`); + } + if (safeOrigin(nextUrl) !== startOrigin) { + throw new Error(`refusing cross-origin redirect to ${safeOrigin(nextUrl)} that would carry the auth credential (from ${startOrigin})`); + } + curUrl = nextUrl; + curInit = rewriteInitForRedirect(curInit, res.status); + } +} /** `label` is the REDACTED endpoint name used in every user-visible error — never the raw * URL. Defensive: no current endpoint embeds a secret in its URL, but the platform prompt * invites users to paste failures back, so error text must never carry credential material. */ async function postJson(fetchFn: FetchLike, url: string, label: string, body: unknown, headers: Record = {}): Promise> { - const res = await fetchFn(url, { + const res = await fetchNoAutoFollow(fetchFn, url, { method: "POST", - headers: { "content-type": "application/json", ...headers }, + headers: { "content-type": "application/json", ...cfAccessHeaders(), ...headers }, body: JSON.stringify(body), }); const text = await res.text(); @@ -27,8 +96,15 @@ async function postJson(fetchFn: FetchLike, url: string, label: string, body: un throw new Error(`${label} → HTTP ${res.status}, non-JSON body`); } if (!res.ok) { - const msg = (parsed as { message?: string; error?: string }).message ?? (parsed as { error?: string }).error ?? ""; - throw new Error(`${label} → HTTP ${res.status}${msg ? `: ${msg}` : ""}`); + // cws-core errors are RFC 9457 problem+json — `detail` is the human-readable message, + // `code` a stable identifier (e.g. MEMBER_INVALID_AGENT_OWNER); there is NO `message` or + // `error` key. Those two are kept as a defensive fallback (matches the SDK's own + // `detail || error || message` priority) in case a non-cws-core hop in front (e.g. an + // edge proxy) ever returns a differently-shaped error body. + const p = parsed as { detail?: string; code?: string; message?: string; error?: string }; + const msg = p.detail ?? p.error ?? p.message ?? ""; + const code = p.code ? ` (${p.code})` : ""; + throw new Error(`${label} → HTTP ${res.status}${msg ? `: ${msg}` : ""}${code}`); } // cws-core envelope: { $schema, data, request_id, ... } — unwrap when present. const o = parsed as Record; @@ -38,14 +114,64 @@ async function postJson(fetchFn: FetchLike, url: string, label: string, body: un /** POST /auth/agent/token {org_id} with `Authorization: Bearer ` — the shape the * SDK's own TokenManager sends (transport/token.js) and cws-core reads; pinned by 0t's R1 * against SDK contract auth-lifecycle.md (the live logs showed URL+body only, headers were - * TokenManager-internal — do not "re-derive" this header from logs again). */ -export async function exchangeAgentToken(fetchFn: FetchLike, bffUrl: string, apiKey: string, orgId: string): Promise { - const data = await postJson(fetchFn, `${bffUrl}/auth/agent/token`, "agent token exchange (/auth/agent/token)", { org_id: orgId }, { authorization: `Bearer ${apiKey}` }); + * TokenManager-internal — do not "re-derive" this header from logs again). + * + * `orgId` is optional: when omitted, the `org_id` key is left out of the body entirely (NOT + * sent as an empty string — cws-core's contract distinguishes "key absent" from "key present + * but empty") and the returned JWT is identity-only, not org-scoped. The self-register flow + * needs an identity-only JWT to call acceptInvitation before any org membership exists. */ +export async function exchangeAgentToken(fetchFn: FetchLike, bffUrl: string, apiKey: string, orgId?: string): Promise { + const body: Record = {}; + if (orgId !== undefined) body.org_id = orgId; + const data = await postJson(fetchFn, `${bffUrl}/auth/agent/token`, "agent token exchange (/auth/agent/token)", body, { authorization: `Bearer ${apiKey}` }); const jwt = data.access_token; if (typeof jwt !== "string") throw new Error(`agent token exchange returned no access_token (keys: ${Object.keys(data).join(", ")})`); return jwt; } +/** POST /auth/register/agent — self-registration, no auth required. Returns a fresh + * identity_id + api_key in the standard D8 envelope. This is the same first step the + * platform's default "zylos" agent type uses to onboard itself (cws-core's zylosInstallSpec + * prompt template): register -> identity-only JWT -> accept invitation -> org-scoped JWT. */ +export async function registerAgent(fetchFn: FetchLike, bffUrl: string): Promise<{ identityId: string; apiKey: string }> { + const data = await postJson(fetchFn, `${bffUrl}/auth/register/agent`, "agent self-registration (/auth/register/agent)", {}); + const identityId = data.identity_id; + const apiKey = data.api_key; + if (typeof identityId !== "string" || typeof apiKey !== "string") { + throw new Error(`agent self-registration returned no identity_id/api_key (keys: ${Object.keys(data).join(", ")})`); + } + return { identityId, apiKey }; +} + +/** POST /api/v1/invitations/{invitationId}/accept with `Authorization: Bearer ` (from exchangeAgentToken called WITHOUT orgId) and body `{token}`. Live-tested and + * confirmed to return 200 with `{member_id, org_id, role_slug}` for an agent-held identity + * JWT — this is not human-only (see the file-header note above). The returned `org_id` is + * authoritative: it's what the invitation actually resolved to, independent of whatever + * org_id the caller may have supplied for reference. */ +export async function acceptInvitation( + fetchFn: FetchLike, + bffUrl: string, + identityJwt: string, + invitationId: string, + token: string, +): Promise<{ memberId: string; orgId: string; roleSlug: string }> { + const data = await postJson( + fetchFn, + `${bffUrl}/api/v1/invitations/${invitationId}/accept`, + "invitation accept (/api/v1/invitations/{id}/accept)", + { token }, + { authorization: `Bearer ${identityJwt}` }, + ); + const memberId = data.member_id; + const orgId = data.org_id; + const roleSlug = data.role_slug; + if (typeof memberId !== "string" || typeof orgId !== "string" || typeof roleSlug !== "string") { + throw new Error(`invitation accept returned incomplete data (keys: ${Object.keys(data).join(", ")})`); + } + return { memberId, orgId, roleSlug }; +} + export interface SelfInfo { memberId: string; displayName: string; @@ -53,9 +179,21 @@ export interface SelfInfo { /** GET /api/v1/me with the org JWT — hydrates the config's org.self block. */ export async function fetchSelf(fetchFn: FetchLike, bffUrl: string, jwt: string): Promise { - const res = await fetchFn(`${bffUrl}/api/v1/me`, { method: "GET", headers: { authorization: `Bearer ${jwt}` } }); + const res = await fetchNoAutoFollow(fetchFn, `${bffUrl}/api/v1/me`, { method: "GET", headers: { authorization: `Bearer ${jwt}`, ...cfAccessHeaders() } }); const text = await res.text(); - if (!res.ok) throw new Error(`/api/v1/me → HTTP ${res.status}`); + if (!res.ok) { + // Same RFC 9457 problem+json shape as postJson's error path — see its comment for why + // `detail`/`code` (not `message`/`error`) are the real cws-core fields. + let p: { detail?: string; code?: string; message?: string; error?: string } = {}; + try { + p = JSON.parse(text); + } catch { + // non-JSON body — fall through with an empty detail + } + const msg = p.detail ?? p.error ?? p.message ?? ""; + const code = p.code ? ` (${p.code})` : ""; + throw new Error(`/api/v1/me → HTTP ${res.status}${msg ? `: ${msg}` : ""}${code}`); + } const o = JSON.parse(text) as { data?: Record }; const d = (o.data ?? o) as Record; const memberId = d.member_id ?? d.id; @@ -70,32 +208,67 @@ export interface OnboardInput { bff_url: string; ws_url: string; org_id: string; - // The onboarding credential is a provisioned agent api_key + identity_id. There is NO - // agent-driven invitation redemption: live testing (2026-07-20) confirmed - // /api/v1/invitations/{id}/accept is a HUMAN-only flow — an authenticated agent JWT is - // rejected `MEMBER_INVALID_AGENT_OWNER: new owner must be an active human member`, and an - // unauthenticated call 401s. The platform ("Add Codex agent", human already logged in) - // provisions the api_key and embeds it in the generated prompt. - api_key: string; - identity_id: string; + // Two credential shapes — direct takes priority when both are supplied: + // (a) direct — a platform-provisioned agent api_key + identity_id, supplied as-is (the + // "Add Codex agent" human-already-logged-in flow). Wins if any of api_key/identity_id + // is present, even alongside an invitation_id/invitation_token. + // (b) self-register — an invitation_id + invitation_token, used only when neither + // api_key nor identity_id is present. `buildConfig` self-registers a + // new agent identity (POST /auth/register/agent), exchanges an identity-only JWT, + // accepts the invitation with it, then exchanges an org-scoped JWT using the same + // api_key. Same self-register -> identity JWT -> accept -> org JWT pattern the + // platform's default "zylos" agent type already uses. + api_key?: string; + identity_id?: string; + invitation_id?: string; + invitation_token?: string; local_http_port?: number; } -/** Full init pipeline → the config.json object (same shape the P1 stack already runs on). */ +/** Full init pipeline → the config.json object (same shape the P1 stack already runs on). + * Dispatches on which credential shape `input` supplies — see OnboardInput. Validates fully + * before any network call either way (config.ts demands cws.identityId; a config missing it + * would be written only for `start` to reject it). */ export async function buildConfig(fetchFn: FetchLike, input: OnboardInput): Promise> { - const { api_key: apiKey, identity_id: identityId } = input; - // Validate before any network call (config.ts demands cws.identityId; a config missing it - // would be written only for `start` to reject it). - if (!apiKey) throw new Error("missing required field: api_key"); - if (!identityId) throw new Error("missing required field: identity_id"); - const jwt = await exchangeAgentToken(fetchFn, input.bff_url, apiKey, input.org_id); - const self = await fetchSelf(fetchFn, input.bff_url, jwt); + const { api_key: apiKey, identity_id: identityId, invitation_id: invitationId, invitation_token: invitationToken } = input; + const hasAnyDirect = Boolean(apiKey || identityId); + const hasAnyInvitation = Boolean(invitationId || invitationToken); + + if (hasAnyDirect || !hasAnyInvitation) { + // Direct-credential shape (also the fallback when neither shape is fully present, so + // the error names the primary field first). + if (!apiKey) throw new Error("missing required field: api_key"); + if (!identityId) throw new Error("missing required field: identity_id"); + const jwt = await exchangeAgentToken(fetchFn, input.bff_url, apiKey, input.org_id); + const self = await fetchSelf(fetchFn, input.bff_url, jwt); + return { + cws: { bffUrl: input.bff_url, wsUrl: input.ws_url, identityId, apiKey }, + codex: { bin: "codex", cwd: "." }, + bridge: { localHttpPort: input.local_http_port ?? 8787 }, + org: { + org_id: input.org_id, + self: { member_id: self.memberId, display_name: self.displayName }, + access: { dmPolicy: "open" }, + }, + }; + } + + // Self-register shape: register -> identity-only JWT -> accept invitation -> org JWT. + if (!invitationId) throw new Error("missing required field: invitation_id"); + if (!invitationToken) throw new Error("missing required field: invitation_token"); + const registered = await registerAgent(fetchFn, input.bff_url); + const identityJwt = await exchangeAgentToken(fetchFn, input.bff_url, registered.apiKey); + const accepted = await acceptInvitation(fetchFn, input.bff_url, identityJwt, invitationId, invitationToken); + // accepted.orgId is authoritative (what the invitation actually resolved to), not the + // caller-supplied input.org_id — trust the accept response over the reference value. + const orgJwt = await exchangeAgentToken(fetchFn, input.bff_url, registered.apiKey, accepted.orgId); + const self = await fetchSelf(fetchFn, input.bff_url, orgJwt); return { - cws: { bffUrl: input.bff_url, wsUrl: input.ws_url, identityId, apiKey }, + cws: { bffUrl: input.bff_url, wsUrl: input.ws_url, identityId: registered.identityId, apiKey: registered.apiKey }, codex: { bin: "codex", cwd: "." }, bridge: { localHttpPort: input.local_http_port ?? 8787 }, org: { - org_id: input.org_id, + org_id: accepted.orgId, self: { member_id: self.memberId, display_name: self.displayName }, access: { dmPolicy: "open" }, }, diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts index 4f61a99..2f1f3cc 100644 --- a/test/onboarding.test.ts +++ b/test/onboarding.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildConfig, exchangeAgentToken, fetchSelf, writeConfigFile, type FetchLike } from "../src/onboarding.js"; +import { buildConfig, exchangeAgentToken, fetchSelf, registerAgent, acceptInvitation, writeConfigFile, type FetchLike } from "../src/onboarding.js"; -/** Scripted fetch: match by URL substring, record calls (headers/body) for assertions. */ -function fakeFetch(routes: Array<{ match: string; status?: number; data?: unknown; raw?: string }>) { +/** Scripted fetch: match by URL substring (checked in array order, first match wins — routes + * for a redirect's target URL must come before a broader route it would also match), record + * calls (headers/body) for assertions. `location` scripts a redirect response. */ +function fakeFetch(routes: Array<{ match: string; status?: number; data?: unknown; raw?: string; location?: string }>) { const calls: Array<{ url: string; headers?: Record; body?: string }> = []; const fetchFn: FetchLike = async (url, init) => { calls.push({ url, headers: init?.headers, body: init?.body }); @@ -15,6 +17,7 @@ function fakeFetch(routes: Array<{ match: string; status?: number; data?: unknow return { ok: status < 300, status, + headers: { get: (name: string) => (name.toLowerCase() === "location" ? (r.location ?? null) : null) }, text: async () => r.raw ?? JSON.stringify({ $schema: "x", data: r.data, request_id: "r" }), }; }; @@ -30,6 +33,62 @@ describe("onboarding: token exchange + self hydration", () => { expect(JSON.parse(calls[0].body!)).toEqual({ org_id: "org_1" }); }); + it("KILLING: identity-only token exchange (orgId omitted) sends a body with NO org_id key at all — not an empty string, the key must be ABSENT (cws-core distinguishes the two)", async () => { + const { fetchFn, calls } = fakeFetch([{ match: "/auth/agent/token", data: { access_token: "jwt_identity_only" } }]); + expect(await exchangeAgentToken(fetchFn, "https://x.test", "sk")).toBe("jwt_identity_only"); + const body = JSON.parse(calls[0].body!) as Record; + expect(Object.keys(body)).toEqual([]); + expect("org_id" in body).toBe(false); + }); + + it("KILLING: CF-Access headers (COCO_CF_ACCESS_CLIENT_ID/SECRET) are attached to every onboarding request — CF-gated deployments (e.g. cws-int) 403 at the Access layer without them, before ever reaching cws-core", async () => { + const prevId = process.env.COCO_CF_ACCESS_CLIENT_ID; + const prevSecret = process.env.COCO_CF_ACCESS_CLIENT_SECRET; + process.env.COCO_CF_ACCESS_CLIENT_ID = "cf_id_1"; + process.env.COCO_CF_ACCESS_CLIENT_SECRET = "cf_secret_1"; + try { + const { fetchFn: postFetch, calls: postCalls } = fakeFetch([{ match: "/auth/agent/token", data: { access_token: "jwt_1" } }]); + await exchangeAgentToken(postFetch, "https://x.test", "sk", "org_1"); + expect(postCalls[0].headers?.["CF-Access-Client-Id"]).toBe("cf_id_1"); + expect(postCalls[0].headers?.["CF-Access-Client-Secret"]).toBe("cf_secret_1"); + + const { fetchFn: getFetch, calls: getCalls } = fakeFetch([{ match: "/api/v1/me", data: { member_id: "m_9", display_name: "codex-bot" } }]); + await fetchSelf(getFetch, "https://x.test", "jwt_1"); + expect(getCalls[0].headers?.["CF-Access-Client-Id"]).toBe("cf_id_1"); + expect(getCalls[0].headers?.["CF-Access-Client-Secret"]).toBe("cf_secret_1"); + } finally { + if (prevId === undefined) delete process.env.COCO_CF_ACCESS_CLIENT_ID; + else process.env.COCO_CF_ACCESS_CLIENT_ID = prevId; + if (prevSecret === undefined) delete process.env.COCO_CF_ACCESS_CLIENT_SECRET; + else process.env.COCO_CF_ACCESS_CLIENT_SECRET = prevSecret; + } + }); + + it("KILLING: no CF-Access env set -> no CF-Access-Client-Id/Secret headers at all (unprotected deployments unaffected)", async () => { + const { fetchFn, calls } = fakeFetch([{ match: "/auth/agent/token", data: { access_token: "jwt_1" } }]); + await exchangeAgentToken(fetchFn, "https://x.test", "sk", "org_1"); + expect(calls[0].headers?.["CF-Access-Client-Id"]).toBeUndefined(); + expect(calls[0].headers?.["CF-Access-Client-Secret"]).toBeUndefined(); + }); + + it("KILLING (credential-leak guard, mirrors SDK P1-C): a SAME-origin redirect is followed transparently", async () => { + const { fetchFn, calls } = fakeFetch([ + { match: "/auth/agent/token/v2", data: { access_token: "jwt_1" } }, + { match: "/auth/agent/token", status: 302, location: "https://x.test/auth/agent/token/v2" }, + ]); + expect(await exchangeAgentToken(fetchFn, "https://x.test", "sk", "org_1")).toBe("jwt_1"); + expect(calls).toHaveLength(2); + expect(calls[1].url).toBe("https://x.test/auth/agent/token/v2"); + }); + + it("KILLING (credential-leak guard, mirrors SDK P1-C): a CROSS-origin redirect is refused — the Bearer api_key must never reach a third-party host", async () => { + const { fetchFn, calls } = fakeFetch([{ match: "/auth/agent/token", status: 302, location: "https://evil.test/steal" }]); + const err = await exchangeAgentToken(fetchFn, "https://x.test", "sk", "org_1").catch((e: Error) => e); + expect((err as Error).message).toContain("cross-origin redirect"); + expect((err as Error).message).toContain("evil.test"); + expect(calls).toHaveLength(1); // never followed to the malicious host + }); + it("HTTP-error messages use the endpoint LABEL, not the raw URL (postJson redaction — defensive: keeps any future id-bearing URL out of user-visible errors)", async () => { const { fetchFn } = fakeFetch([{ match: "/auth/agent/token", status: 401, raw: JSON.stringify({ message: "bad key" }) }]); const err = await exchangeAgentToken(fetchFn, "https://secret-host.test", "sk", "org_1").catch((e: Error) => e); @@ -37,6 +96,26 @@ describe("onboarding: token exchange + self hydration", () => { expect((err as Error).message).not.toContain("https://secret-host.test"); }); + it("KILLING: cws-core errors are RFC 9457 problem+json (`detail`/`code`, NOT `message`/`error`) — the real error text must survive, not just a bare HTTP status", async () => { + const { fetchFn } = fakeFetch([ + { + match: "/invitations/inv_1/accept", + status: 409, + raw: JSON.stringify({ type: "https://git.coco.xyz/coco-workspace/cws-core/errors/MEMBER_INVALID_AGENT_OWNER", title: "Conflict", status: 409, detail: "new owner must be an active human member of the organization", code: "MEMBER_INVALID_AGENT_OWNER" }), + }, + ]); + const err = await acceptInvitation(fetchFn, "https://x.test", "identity_jwt", "inv_1", "tok").catch((e: Error) => e); + expect((err as Error).message).toContain("new owner must be an active human member"); + expect((err as Error).message).toContain("MEMBER_INVALID_AGENT_OWNER"); + }); + + it("KILLING: fetchSelf surfaces the real cws-core `detail` on error too, not just a bare HTTP status (it previously discarded the body entirely)", async () => { + const { fetchFn } = fakeFetch([{ match: "/api/v1/me", status: 403, raw: JSON.stringify({ detail: "org membership required", code: "ORG_MEMBERSHIP_REQUIRED" }) }]); + const err = await fetchSelf(fetchFn, "https://x.test", "jwt_1").catch((e: Error) => e); + expect((err as Error).message).toContain("org membership required"); + expect((err as Error).message).toContain("ORG_MEMBERSHIP_REQUIRED"); + }); + it("fetchSelf reads /api/v1/me with Bearer and maps member_id/display_name", async () => { const { fetchFn, calls } = fakeFetch([{ match: "/api/v1/me", data: { member_id: "m_9", display_name: "codex-bot" } }]); expect(await fetchSelf(fetchFn, "https://x.test", "jwt_1")).toEqual({ memberId: "m_9", displayName: "codex-bot" }); @@ -44,6 +123,37 @@ describe("onboarding: token exchange + self hydration", () => { }); }); +describe("onboarding: self-registration + invitation accept (contra the file's earlier wrong claim that self-accept is human-only)", () => { + it("KILLING: registerAgent POSTs /auth/register/agent with NO auth header and an empty body, unwraps identity_id/api_key from the D8 envelope", async () => { + const { fetchFn, calls } = fakeFetch([{ match: "/auth/register/agent", data: { identity_id: "id_new", api_key: "sk_new" } }]); + expect(await registerAgent(fetchFn, "https://x.test")).toEqual({ identityId: "id_new", apiKey: "sk_new" }); + expect(calls[0].url).toContain("/auth/register/agent"); + expect(calls[0].headers?.authorization).toBeUndefined(); + expect(JSON.parse(calls[0].body!)).toEqual({}); + }); + + it("KILLING: acceptInvitation POSTs /api/v1/invitations/{id}/accept with Authorization: Bearer and body {token} — this is NOT rejected for an agent-held JWT", async () => { + const { fetchFn, calls } = fakeFetch([ + { match: "/invitations/inv_123/accept", data: { member_id: "m_9", org_id: "org_from_accept", role_slug: "member" } }, + ]); + const result = await acceptInvitation(fetchFn, "https://x.test", "identity_jwt_1", "inv_123", "tok_abc"); + expect(result).toEqual({ memberId: "m_9", orgId: "org_from_accept", roleSlug: "member" }); + expect(calls[0].url).toContain("/api/v1/invitations/inv_123/accept"); + expect(calls[0].headers?.authorization).toBe("Bearer identity_jwt_1"); + expect(JSON.parse(calls[0].body!)).toEqual({ token: "tok_abc" }); + }); + + it("acceptInvitation propagates a rejection (e.g. MEMBER_INVALID_AGENT_OWNER for an invalid resolved owner) without leaking the bearer JWT or token in the error message", async () => { + const { fetchFn } = fakeFetch([ + { match: "/invitations/inv_bad/accept", status: 422, raw: JSON.stringify({ message: "MEMBER_INVALID_AGENT_OWNER: new owner must be an active human member" }) }, + ]); + const err = await acceptInvitation(fetchFn, "https://x.test", "identity_jwt_SECRET", "inv_bad", "tok_SECRET").catch((e: Error) => e); + expect((err as Error).message).toContain("invitation accept"); + expect((err as Error).message).not.toContain("identity_jwt_SECRET"); + expect((err as Error).message).not.toContain("tok_SECRET"); + }); +}); + describe("onboarding: buildConfig pipeline (api_key + identity_id)", () => { const ROUTES = [ { match: "/auth/agent/token", data: { access_token: "jwt_1" } }, @@ -92,6 +202,85 @@ describe("onboarding: buildConfig pipeline (api_key + identity_id)", () => { }); }); +describe("onboarding: buildConfig pipeline (invitation_id + invitation_token, self-register)", () => { + const ROUTES = [ + { match: "/auth/register/agent", data: { identity_id: "id_registered", api_key: "sk_registered" } }, + { match: "/invitations/inv_1/accept", data: { member_id: "m_accept", org_id: "org_from_accept", role_slug: "member" } }, + { match: "/auth/agent/token", data: { access_token: "jwt_whichever" } }, + { match: "/api/v1/me", data: { member_id: "m_9", display_name: "codex-bot" } }, + ]; + + it("KILLING: self-register -> identity JWT -> accept -> org JWT -> hydrate produces the IDENTICAL config.json shape as the direct-credential path", async () => { + const { fetchFn, calls } = fakeFetch(ROUTES); + const cfg = await buildConfig(fetchFn, { + bff_url: "https://x.test", + ws_url: "wss://x.test/ws", + org_id: "org_caller_supplied", // must be IGNORED in favor of the accept response's org_id + invitation_id: "inv_1", + invitation_token: "tok_1", + local_http_port: 9999, + }); + expect(cfg).toEqual({ + cws: { bffUrl: "https://x.test", wsUrl: "wss://x.test/ws", identityId: "id_registered", apiKey: "sk_registered" }, + codex: { bin: "codex", cwd: "." }, + bridge: { localHttpPort: 9999 }, + org: { org_id: "org_from_accept", self: { member_id: "m_9", display_name: "codex-bot" }, access: { dmPolicy: "open" } }, + }); + // full sequence: register, identity-only exchange, accept, org-scoped exchange, /me + expect(calls.filter((c) => c.url.includes("/auth/register/agent"))).toHaveLength(1); + expect(calls.filter((c) => c.url.includes("/accept"))).toHaveLength(1); + expect(calls.filter((c) => c.url.includes("/auth/agent/token"))).toHaveLength(2); // identity-only, then org-scoped + const tokenCallBodies = calls.filter((c) => c.url.includes("/auth/agent/token")).map((c) => JSON.parse(c.body!)); + expect(tokenCallBodies[0]).toEqual({}); // identity-only: no org_id key + expect(tokenCallBodies[1]).toEqual({ org_id: "org_from_accept" }); // org-scoped, using the ACCEPT response's org_id + }); + + it("default port when omitted (self-register path)", async () => { + const { fetchFn } = fakeFetch(ROUTES); + const cfg = await buildConfig(fetchFn, { bff_url: "https://x.test", ws_url: "wss://x.test/ws", org_id: "org_x", invitation_id: "inv_1", invitation_token: "tok_1" }); + expect((cfg.bridge as { localHttpPort: number }).localHttpPort).toBe(8787); + }); + + it("KILLING: missing invitation_token -> hard error before any network call (no api_key/identity_id supplied either)", async () => { + const { fetchFn, calls } = fakeFetch(ROUTES); + await expect( + buildConfig(fetchFn, { bff_url: "https://x.test", ws_url: "wss://x.test/ws", org_id: "org_x", invitation_id: "inv_1" } as never), + ).rejects.toThrow(/invitation_token/); + expect(calls).toHaveLength(0); + }); + + it("KILLING: missing invitation_id -> hard error before any network call", async () => { + const { fetchFn, calls } = fakeFetch(ROUTES); + await expect( + buildConfig(fetchFn, { bff_url: "https://x.test", ws_url: "wss://x.test/ws", org_id: "org_x", invitation_token: "tok_1" } as never), + ).rejects.toThrow(/invitation_id/); + expect(calls).toHaveLength(0); + }); + + it("KILLING: neither shape present at all -> hard error before any network call, and the error never echoes any (nonexistent) secret material", async () => { + const { fetchFn, calls } = fakeFetch(ROUTES); + const err = await buildConfig(fetchFn, { bff_url: "https://x.test", ws_url: "wss://x.test/ws", org_id: "org_x" } as never).catch((e: Error) => e); + expect((err as Error).message).toMatch(/api_key|identity_id|invitation_id|invitation_token/); + expect(calls).toHaveLength(0); + }); + + it("a failing accept (e.g. bad/expired invitation token) never leaks the api_key registerAgent just minted", async () => { + const { fetchFn } = fakeFetch([ + { match: "/auth/register/agent", data: { identity_id: "id_registered", api_key: "sk_SECRET_MINTED" } }, + { match: "/auth/agent/token", data: { access_token: "jwt_identity" } }, + { match: "/invitations/inv_1/accept", status: 404, raw: JSON.stringify({ message: "invitation not found" }) }, + ]); + const err = await buildConfig(fetchFn, { + bff_url: "https://x.test", + ws_url: "wss://x.test/ws", + org_id: "org_x", + invitation_id: "inv_1", + invitation_token: "tok_1", + }).catch((e: Error) => e); + expect((err as Error).message).not.toContain("sk_SECRET_MINTED"); + }); +}); + describe("writeConfigFile (0600 guarantee)", () => { it("KILLING (0t R1 P2): overwriting an EXISTING 0644 config still ends at 0600 (temp + atomic rename; writeFileSync mode only applies at creation)", () => { const dir = fs.mkdtempSync(join(tmpdir(), "cfg-"));