Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": "<org id>",
"invitation_id": "<invitation id>",
"invitation_token": "<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

Expand Down
71 changes: 50 additions & 21 deletions docs/onboarding-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <api_key>`
(the shape the SDK's TokenManager sends and cws-core reads — verified live).
Expand All @@ -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 <new api_key>`.
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).
Expand All @@ -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`).
Expand Down
13 changes: 10 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -33,12 +34,18 @@ async function cmdInit(args: string[]): Promise<void> {
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)
Expand Down
Loading
Loading