Summary
Give the CLI/SDK a second auth principal - the operator, i.e. the human (email identity) - alongside the existing agent (the coding-agent-with-a-wallet, SIWX). The operator noun is human-only: operator login, operator logout, operator overview, operator whoami. The human authenticates in the browser via a device-authorization grant (RFC 8628, the aws sso login model), so the CLI never implements WebAuthn or reads a magic-link fragment - the browser does the auth (magic-link or passkey) and the CLI just brokers the resulting operator-session token.
This is one coherent feature - no phased split. The wallet's own account view is not part of it: that already exists as run402 status. Gated on the gateway device-auth bridge: kychee-com/run402-private#443.
Two-actor model
| Actor |
Principal |
Auths via |
"who am I" |
account view |
| Operator = the human |
email |
browser-delegated session (operator login) |
operator whoami |
operator overview (email-union) |
| Agent = coding-agent-with-a-wallet |
wallet (SIWX) |
local allowance signature, headless |
run402 status / r.whoami() |
run402 status (active wallet) |
The agent keeps doing everything per-wallet with no browser; the human logs in once to see the union across all wallets that verified their email.
Why wallet auth can't return the union
GET /agent/v1/operator/overview scopes by principal:
| Principal |
Proves |
Returns |
| SIWX (wallet signature) |
"I hold this key" |
that wallet's slice |
| operator session (email) |
"I'm the human operating these wallets" |
union across ALL wallets controlling the email |
A wallet signature proves control of one key, so SIWX returns that wallet's slice - never the union (deliberate anti-enumeration: a wallet pooled into an account shouldn't enumerate the others just by signing). The union needs email proof, and the CLI has no email identity today. The browser-delegated device flow is what gives the CLI that email identity. (Surfaced live: run402 billing balance <email> returns 403 ADMIN_REQUIRED; run402 wallets list is local-only.)
The operator noun (human only)
operator login - RFC 8628 device flow. POST .../session/device -> print verification_uri + user_code (auto-open verification_uri_complete when stdout is a TTY; --no-open to suppress) -> poll POST .../session/device/token at interval (honoring slow_down) until approved / access_denied / expired_token -> cache the email-scoped session at {base}/operator-session.json (0600). Prints email + wallet count + TTL on success.
operator logout - POST .../session/revoke (operator-session bearer; instant server-side revoke) then delete the local cache. Idempotent: no session -> no-op, exit 0; always clears locally even if the revoke call fails.
operator overview - GET /agent/v1/operator/overview with the operator-session bearer -> the email-union (scope, rollup, billing_accounts[], wallets[], advisories[]). Requires login; no session -> structured error pointing at operator login. Human only - no SIWX fallback.
operator whoami - local cache read: { logged_in, email, wallets[], expires_in, absolute_expires_at }, or { logged_in: false } (nonzero exit). No network.
Not built here (already covered / deliberately excluded)
- Wallet/agent account view -> use existing
run402 status - the active wallet's one-shot state (tier, billing, on-chain balance, projects, label). Loop --wallet a/b/c for the per-wallet sweep. The SIWX path of /operator/overview is not wired into the CLI.
- No
operator status command - GET /agent/v1/operator/status is wallet-authed (a single-wallet health/assurance snapshot that overlaps agent status and run402 status); it is not a human concern. Stays MCP-only (get_operator_status). (Aside: its operator/ path is misleading given it's wallet-authed - a gateway rename/re-scope candidate, out of scope here.)
Contract both sides build to (RFC 8628)
POST /agent/v1/operator/session/device -> { device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval }
- Browser: user opens
verification_uri, authenticates via the existing magic-link/passkey web flows, approves user_code.
POST /agent/v1/operator/session/device/token { device_code } -> on approval { operator_session_token, token_type:"Bearer", expires_in, absolute_expires_at, email, wallets[] }; otherwise authorization_pending | slow_down | access_denied | expired_token.
POST /agent/v1/operator/session/revoke (operator-session bearer) -> 204, idempotent; operator logout calls this then clears the local cache. Revocation is instant (gateway re-checks revoked_at each request; the session token is the only credential - nothing downstream dangles), so unlike aws sso logout no lingering creds outlive it.
GET /agent/v1/operator/overview (operator-session bearer) -> the email-union. Already exists and accepts the bearer - no new server work beyond the device + revoke endpoints in #443.
CLI/SDK scope
- CLI: new
case "operator" in cli/cli.mjs -> cli/lib/operator.mjs with login / logout / overview / whoami. Dispatch + help + e2e help snapshot.
- Cache:
core/src/operator-session.ts (mirrors allowance.ts) - read / save / clear / isExpired for {base}/operator-session.json (0600). Email-scoped at the base config dir, so it is shared across all local named wallets (log in once; every wallet's shell sees the union). Cleared by logout.
- SDK: new
r.operator namespace - overview(), deviceStart(), devicePoll(), revoke() - plus an operator-session-bearer fetch path alongside SIWX. (whoami is a pure local-cache read at the CLI edge; no SDK network method.)
- MCP: the
operator verbs are MCP-null by design - MCP authenticates as the agent (wallet/SIWX), and the human device-login flow must not place the human's email-union session in the agent's hands. get_operator_status stays an MCP tool in r.admin (the agent's wallet-health view) and does not move into r.operator. A future read-only operator_overview MCP tool could consume a CLI-minted session (never performing login itself), but is out of scope here. CLI <-> OpenClaw parity is structural, so all four verbs land in OpenClaw regardless.
sync.test.ts SURFACE + OpenClaw parity; docs (llms-cli.txt, SKILL.md, READMEs).
- Agent-first:
operator overview --json / operator whoami --json are clean; operator login is human-in-the-loop by nature (prints URL + code), degrades gracefully headless.
Acceptance criteria
Client side shipped in run402 / @run402/sdk / run402-mcp v2.31.0 (#419). Network paths verified live against the deployed gateway bridge (#443).
Related
🤖 Generated with Claude Code
Summary
Give the CLI/SDK a second auth principal - the operator, i.e. the human (email identity) - alongside the existing agent (the coding-agent-with-a-wallet, SIWX). The
operatornoun is human-only:operator login,operator logout,operator overview,operator whoami. The human authenticates in the browser via a device-authorization grant (RFC 8628, theaws sso loginmodel), so the CLI never implements WebAuthn or reads a magic-link fragment - the browser does the auth (magic-link or passkey) and the CLI just brokers the resulting operator-session token.This is one coherent feature - no phased split. The wallet's own account view is not part of it: that already exists as
run402 status. Gated on the gateway device-auth bridge: kychee-com/run402-private#443.Two-actor model
operator login)operator whoamioperator overview(email-union)run402 status/r.whoami()run402 status(active wallet)The agent keeps doing everything per-wallet with no browser; the human logs in once to see the union across all wallets that verified their email.
Why wallet auth can't return the union
GET /agent/v1/operator/overviewscopes by principal:A wallet signature proves control of one key, so SIWX returns that wallet's slice - never the union (deliberate anti-enumeration: a wallet pooled into an account shouldn't enumerate the others just by signing). The union needs email proof, and the CLI has no email identity today. The browser-delegated device flow is what gives the CLI that email identity. (Surfaced live:
run402 billing balance <email>returns403 ADMIN_REQUIRED;run402 wallets listis local-only.)The
operatornoun (human only)operator login- RFC 8628 device flow.POST .../session/device-> printverification_uri+user_code(auto-openverification_uri_completewhen stdout is a TTY;--no-opento suppress) -> pollPOST .../session/device/tokenatinterval(honoringslow_down) untilapproved/access_denied/expired_token-> cache the email-scoped session at{base}/operator-session.json(0600). Prints email + wallet count + TTL on success.operator logout-POST .../session/revoke(operator-session bearer; instant server-side revoke) then delete the local cache. Idempotent: no session -> no-op, exit 0; always clears locally even if the revoke call fails.operator overview-GET /agent/v1/operator/overviewwith the operator-session bearer -> the email-union (scope,rollup,billing_accounts[],wallets[],advisories[]). Requires login; no session -> structured error pointing atoperator login. Human only - no SIWX fallback.operator whoami- local cache read:{ logged_in, email, wallets[], expires_in, absolute_expires_at }, or{ logged_in: false }(nonzero exit). No network.Not built here (already covered / deliberately excluded)
run402 status- the active wallet's one-shot state (tier, billing, on-chain balance, projects, label). Loop--wallet a/b/cfor the per-wallet sweep. The SIWX path of/operator/overviewis not wired into the CLI.operator statuscommand -GET /agent/v1/operator/statusis wallet-authed (a single-wallet health/assurance snapshot that overlapsagent statusandrun402 status); it is not a human concern. Stays MCP-only (get_operator_status). (Aside: itsoperator/path is misleading given it's wallet-authed - a gateway rename/re-scope candidate, out of scope here.)Contract both sides build to (RFC 8628)
POST /agent/v1/operator/session/device->{ device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval }verification_uri, authenticates via the existing magic-link/passkey web flows, approvesuser_code.POST /agent/v1/operator/session/device/token{ device_code }-> on approval{ operator_session_token, token_type:"Bearer", expires_in, absolute_expires_at, email, wallets[] }; otherwiseauthorization_pending|slow_down|access_denied|expired_token.POST /agent/v1/operator/session/revoke(operator-session bearer) ->204, idempotent;operator logoutcalls this then clears the local cache. Revocation is instant (gateway re-checksrevoked_ateach request; the session token is the only credential - nothing downstream dangles), so unlikeaws sso logoutno lingering creds outlive it.GET /agent/v1/operator/overview(operator-session bearer) -> the email-union. Already exists and accepts the bearer - no new server work beyond the device + revoke endpoints in #443.CLI/SDK scope
case "operator"incli/cli.mjs->cli/lib/operator.mjswithlogin/logout/overview/whoami. Dispatch + help + e2e help snapshot.core/src/operator-session.ts(mirrorsallowance.ts) - read / save / clear / isExpired for{base}/operator-session.json(0600). Email-scoped at the base config dir, so it is shared across all local named wallets (log in once; every wallet's shell sees the union). Cleared bylogout.r.operatornamespace -overview(),deviceStart(),devicePoll(),revoke()- plus an operator-session-bearer fetch path alongside SIWX. (whoamiis a pure local-cache read at the CLI edge; no SDK network method.)operatorverbs are MCP-nullby design - MCP authenticates as the agent (wallet/SIWX), and the human device-login flow must not place the human's email-union session in the agent's hands.get_operator_statusstays an MCP tool inr.admin(the agent's wallet-health view) and does not move intor.operator. A future read-onlyoperator_overviewMCP tool could consume a CLI-minted session (never performing login itself), but is out of scope here. CLI <-> OpenClaw parity is structural, so all four verbs land in OpenClaw regardless.sync.test.tsSURFACE + OpenClaw parity; docs (llms-cli.txt,SKILL.md, READMEs).operator overview --json/operator whoami --jsonare clean;operator loginis human-in-the-loop by nature (prints URL + code), degrades gracefully headless.Acceptance criteria
Client side shipped in
run402/@run402/sdk/run402-mcpv2.31.0 (#419). Network paths verified live against the deployed gateway bridge (#443).operatornoun (login/logout/overview/whoami) - newcase "operator"incli/cli.mjs+cli/lib/operator.mjs.operator loginimplements the RFC 8628 device flow (start + poll), printsverification_uri+user_code, auto-opensverification_uri_completeon a TTY, caches{base}/operator-session.json(0600).operator overviewsends the operator-session bearer -> email-union; requires login (clear error ->operator loginwhen no session); no SIWX fallback.operator logoutcallsPOST .../session/revokethen clears the cache; idempotent.operator whoamireads the local cache (email, wallets[], expiry); nonzero exit when not logged in; no network.r.operatornamespace (overview/deviceStart/devicePoll/revoke) + operator-session-bearer fetch path.core/src/operator-session.tscache primitive (read / save / clear / isExpired), base-config-dir, 0600.cli/llms-cli.txt; SKILL.md / READMEs deferred until #443 makes the flow live).operator statusCLI command; wallet account view staysrun402 status.Related
run402 status, so there is no separate ships-now phase - the wholeoperatornoun lands together when #443 is ready.🤖 Generated with Claude Code