Skip to content

Commit 11f6fc9

Browse files
MajorTalclaude
andauthored
feat(operator): operator (human/email) session — login, overview, whoami, logout (#419)
Add the `operator` noun: the human/email principal, distinct from the agent (wallet/SIWX). Browser-delegated device-authorization (RFC 8628) brokers an operator session that spans every wallet verifying the email; `operator overview` returns the cross-wallet union. The wallet's own view stays `run402 status` (there is no `operator status`). - core: operator-session cache (base config dir, 0600, email-scoped) + tests - sdk: r.operator namespace (deviceStart/devicePoll/overview/revoke) + tests - cli: operator login/logout/overview/whoami + dispatch + help - openclaw: re-export (CLI parity); sync SURFACE (MCP-null) + SDK mapping - docs: cli/llms-cli.txt operator section Network paths (login/overview/logout) are gated on the gateway device-auth bridge (kychee-com/run402-private#443); whoami works locally today. Not exposed as MCP tools by design — MCP authenticates as the agent, not the human. Refs: #417 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 67942a1 commit 11f6fc9

11 files changed

Lines changed: 1124 additions & 2 deletions

File tree

cli-help.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const MATRIX = {
119119
],
120120
},
121121
agent: { shared: [], specific: ["contact"] },
122+
operator: { shared: ["login", "logout", "overview", "whoami"], specific: [] },
122123
service: { shared: [], specific: ["status", "health"] },
123124
};
124125

cli/cli.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Commands:
4848
billing Email billing accounts, Stripe tier checkout, email packs
4949
contracts KMS contract wallets ($0.04/day rental + $0.000005/sign)
5050
agent Manage agent identity (contact info)
51+
operator Operator (human/email) session — login, then overview across your wallets
5152
service Run402 service health and availability (status, health)
5253
cache Inspect and invalidate the SSR origin cache (inspect, invalidate)
5354
doctor Health and config diagnostics (machine-readable with --json)
@@ -244,6 +245,11 @@ switch (cmd) {
244245
await run(sub, rest);
245246
break;
246247
}
248+
case "operator": {
249+
const { run } = await import("./lib/operator.mjs");
250+
await run(sub, rest);
251+
break;
252+
}
247253
case "auth": {
248254
const { run } = await import("./lib/auth.mjs");
249255
await run(sub, rest);

cli/lib/operator.mjs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* run402 operator — the operator (human / email) session.
3+
*
4+
* The operator is YOU, the human, identified by email — distinct from the
5+
* AGENT (your wallet / SIWX identity). One browser login spans every wallet
6+
* that verified your email, so `operator overview` returns the cross-wallet
7+
* union. For a single wallet's account state, use `run402 status`.
8+
*
9+
* Auth: browser-delegated device-authorization grant (RFC 8628, the
10+
* `aws sso login` model). The CLI never performs WebAuthn — the browser does,
11+
* via the existing magic-link / passkey flows — and the CLI brokers the
12+
* resulting operator-session token, cached at the BASE config dir (shared
13+
* across named wallets, since the session is email-scoped).
14+
*
15+
* Agent-first: JSON to stdout. `login` additionally prints the verification URL
16+
* + user code to stderr (human-in-the-loop) and degrades gracefully when not a
17+
* TTY. Gated on the gateway device-auth bridge (kychee-com/run402-private#443).
18+
*/
19+
20+
import { setTimeout as sleep } from "node:timers/promises";
21+
import { spawn } from "node:child_process";
22+
import { fail, reportSdkError } from "./sdk-errors.mjs";
23+
import { getSdk } from "./sdk.mjs";
24+
import { normalizeArgv, hasHelp, assertKnownFlags } from "./argparse.mjs";
25+
import {
26+
saveOperatorSession,
27+
clearOperatorSession,
28+
loadLiveOperatorSession,
29+
readOperatorSession,
30+
isOperatorSessionExpired,
31+
operatorSessionFromTokenResponse,
32+
} from "../core-dist/operator-session.js";
33+
34+
const CLIENT_NAME = "run402 CLI";
35+
36+
const HELP = `run402 operator — operator (human / email) session
37+
38+
The operator is YOU, the human, identified by email — distinct from the agent
39+
(your wallet). One browser login spans every wallet that verified your email.
40+
For a single wallet's account state, use 'run402 status'.
41+
42+
Usage:
43+
run402 operator login [--no-open]
44+
run402 operator overview
45+
run402 operator whoami
46+
run402 operator logout
47+
48+
Subcommands:
49+
login Sign in via the browser (device-authorization, like 'aws sso login')
50+
overview Account view across ALL wallets controlling your email (requires login)
51+
whoami Show the cached session (email, wallets, expiry) — local, no network
52+
logout Revoke the session server-side and clear the local cache
53+
54+
Options:
55+
--no-open (login) Do not auto-open the browser; just print the URL + code.
56+
57+
Notes:
58+
- The session is cached at the base config dir, shared across named wallets.
59+
- 'overview' requires 'login' and never falls back to a single wallet.
60+
- JSON to stdout; 'login' prints the URL + code to stderr (human-in-the-loop).
61+
`;
62+
63+
/** Shared output shape for `whoami` and the `login` success result. */
64+
function sessionView(session, nowMs = Date.now()) {
65+
return {
66+
logged_in: true,
67+
email: session.email,
68+
wallets: session.wallets,
69+
wallet_count: session.wallets.length,
70+
expires_at: new Date(session.expires_at).toISOString(),
71+
absolute_expires_at: session.absolute_expires_at || null,
72+
expires_in_seconds: Math.max(0, Math.round((session.expires_at - nowMs) / 1000)),
73+
};
74+
}
75+
76+
/** Best-effort, cross-platform browser open. Never throws. */
77+
function openBrowser(url) {
78+
try {
79+
let cmd;
80+
let cmdArgs;
81+
if (process.platform === "darwin") {
82+
cmd = "open";
83+
cmdArgs = [url];
84+
} else if (process.platform === "win32") {
85+
cmd = "cmd";
86+
cmdArgs = ["/c", "start", "", url];
87+
} else {
88+
cmd = "xdg-open";
89+
cmdArgs = [url];
90+
}
91+
const child = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
92+
child.on("error", () => {}); // ignore: the URL is also printed to stderr
93+
child.unref();
94+
} catch {
95+
// Best-effort only — the human can always copy the printed URL.
96+
}
97+
}
98+
99+
async function login(args) {
100+
assertKnownFlags(args, ["--help", "-h", "--no-open"]);
101+
const noOpen = args.includes("--no-open");
102+
const sdk = getSdk();
103+
104+
let start;
105+
try {
106+
start = await sdk.operator.deviceStart({ clientName: CLIENT_NAME });
107+
} catch (err) {
108+
return reportSdkError(err);
109+
}
110+
111+
// Human-in-the-loop prompt → stderr, so stdout stays clean for the final JSON.
112+
const target = start.verification_uri_complete || start.verification_uri;
113+
process.stderr.write(
114+
`\nTo authorize the ${CLIENT_NAME}, open:\n ${start.verification_uri}\n` +
115+
`and enter the code: ${start.user_code}\n\n`,
116+
);
117+
if (!noOpen && process.stderr.isTTY) {
118+
openBrowser(target);
119+
process.stderr.write("(opening your browser…)\n\n");
120+
}
121+
process.stderr.write("Waiting for approval…\n");
122+
123+
// Poll loop — honor the server interval, back off on slow_down, and stop at
124+
// the device-code deadline. if/else (not switch) so the sync scanner doesn't
125+
// mistake the poll states for CLI subcommands.
126+
let intervalMs = Math.max(1, Number(start.interval) || 5) * 1000;
127+
const deadline = Date.now() + Math.max(1, Number(start.expires_in) || 600) * 1000;
128+
129+
while (Date.now() < deadline) {
130+
await sleep(intervalMs);
131+
let result;
132+
try {
133+
result = await sdk.operator.devicePoll(start.device_code);
134+
} catch (err) {
135+
return reportSdkError(err);
136+
}
137+
if (result.kind === "approved") {
138+
const session = operatorSessionFromTokenResponse(result.session);
139+
saveOperatorSession(session);
140+
process.stderr.write(`\nSigned in as ${session.email}.\n`);
141+
console.log(JSON.stringify(sessionView(session)));
142+
return;
143+
}
144+
if (result.kind === "authorization_pending") continue;
145+
if (result.kind === "slow_down") {
146+
intervalMs += 5000;
147+
continue;
148+
}
149+
if (result.kind === "access_denied") {
150+
fail({
151+
code: "OPERATOR_LOGIN_DENIED",
152+
message: "Authorization was denied in the browser.",
153+
hint: "Run 'run402 operator login' to try again.",
154+
});
155+
}
156+
if (result.kind === "expired_token") {
157+
fail({
158+
code: "OPERATOR_LOGIN_EXPIRED",
159+
message: "The device code expired before approval.",
160+
hint: "Run 'run402 operator login' to get a fresh code.",
161+
});
162+
}
163+
fail({ code: "OPERATOR_LOGIN_FAILED", message: `Unexpected device poll result: ${result.kind}` });
164+
}
165+
fail({
166+
code: "OPERATOR_LOGIN_TIMEOUT",
167+
message: "Timed out waiting for browser approval.",
168+
hint: "Run 'run402 operator login' to try again.",
169+
});
170+
}
171+
172+
async function logout(args) {
173+
assertKnownFlags(args, ["--help", "-h"]);
174+
const session = loadLiveOperatorSession();
175+
let revoked = false;
176+
if (session) {
177+
try {
178+
await getSdk().operator.revoke({ token: session.operator_session_token });
179+
revoked = true;
180+
} catch {
181+
// Best-effort: a failed server revoke (expired token, offline) must not
182+
// block clearing the local cache. The local token is removed regardless.
183+
revoked = false;
184+
}
185+
}
186+
clearOperatorSession();
187+
console.log(JSON.stringify({ revoked, cleared: true }));
188+
}
189+
190+
async function overview(args) {
191+
assertKnownFlags(args, ["--help", "-h"]);
192+
const session = loadLiveOperatorSession();
193+
if (!session) {
194+
fail({
195+
code: "OPERATOR_LOGIN_REQUIRED",
196+
message: "No operator session. Run 'run402 operator login' to sign in.",
197+
hint: "operator overview shows the union across all wallets controlling your email; for a single wallet use 'run402 status'.",
198+
});
199+
}
200+
try {
201+
const result = await getSdk().operator.overview({ token: session.operator_session_token });
202+
console.log(JSON.stringify(result, null, 2));
203+
} catch (err) {
204+
// 401/403 means the session was revoked or expired server-side. Clear the
205+
// stale cache and point at re-login instead of leaving a dead token behind.
206+
if (err && (err.status === 401 || err.status === 403)) {
207+
clearOperatorSession();
208+
fail({
209+
code: "OPERATOR_SESSION_INVALID",
210+
message: "Operator session is no longer valid (revoked or expired).",
211+
hint: "Run 'run402 operator login' to sign in again.",
212+
});
213+
}
214+
reportSdkError(err);
215+
}
216+
}
217+
218+
async function whoami(args) {
219+
assertKnownFlags(args, ["--help", "-h"]);
220+
const now = Date.now();
221+
const session = readOperatorSession();
222+
if (!session) {
223+
console.log(JSON.stringify({ logged_in: false, reason: "no_session", hint: "Run 'run402 operator login' to sign in." }));
224+
process.exitCode = 1;
225+
return;
226+
}
227+
if (isOperatorSessionExpired(session, now)) {
228+
console.log(JSON.stringify({ logged_in: false, reason: "expired", email: session.email, hint: "Run 'run402 operator login' to sign in again." }));
229+
process.exitCode = 1;
230+
return;
231+
}
232+
console.log(JSON.stringify(sessionView(session, now)));
233+
}
234+
235+
export async function run(sub, args = []) {
236+
args = normalizeArgv(args);
237+
if (!sub || sub === "--help" || sub === "-h" || hasHelp(args)) {
238+
console.log(HELP);
239+
process.exit(0);
240+
}
241+
switch (sub) {
242+
case "login":
243+
await login(args);
244+
break;
245+
case "logout":
246+
await logout(args);
247+
break;
248+
case "overview":
249+
await overview(args);
250+
break;
251+
case "whoami":
252+
await whoami(args);
253+
break;
254+
default:
255+
fail({
256+
code: "BAD_USAGE",
257+
message: `Unknown subcommand: operator ${sub}`,
258+
hint: "Run 'run402 operator --help' for usage.",
259+
});
260+
}
261+
}

cli/llms-cli.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,16 @@ KMS contract wallets — provision AWS KMS-backed Ethereum wallets per project f
13451345

13461346
`contact` returns `email_verification_status`, `passkey_binding_status`, and `assurance_level`. New or changed emails start a reply challenge and stay `email_pending` until the mailbox owner replies. `passkey enroll` requires `email_verified` and emails the enrollment link to the verified contact email; the token is not printed.
13471347

1348+
### operator
1349+
The **operator** is YOU, the human, identified by email — distinct from the **agent** (your wallet / SIWX identity). One browser login spans every wallet that verified your email, so `operator overview` returns the cross-wallet union. For a single wallet's account state, use `run402 status` (that IS the wallet's view; there is no `operator status`).
1350+
1351+
- `run402 operator login [--no-open]` — browser-delegated sign-in (device-authorization, RFC 8628, the `aws sso login` model). Prints a verification URL + short user code to stderr and (on a TTY) opens the browser; you approve via the existing magic-link OR passkey web flow. Polls until approved, then caches the session at `{base}/operator-session.json` (0600, base config dir — shared across named wallets). Stdout JSON on success: `{ logged_in, email, wallets, wallet_count, expires_at, absolute_expires_at, expires_in_seconds }`.
1352+
- `run402 operator overview` — account view across ALL wallets controlling your email. Sends the cached operator-session bearer to `GET /agent/v1/operator/overview`. **Requires login** — returns `OPERATOR_LOGIN_REQUIRED` (no SIWX fallback) when there is no live session, and clears the cache + returns `OPERATOR_SESSION_INVALID` on a 401/403 (revoked or expired).
1353+
- `run402 operator whoami` — local, no network. Prints the cached session (`logged_in`, email, wallets, expiry) or `{ logged_in: false, reason: "no_session" | "expired" }` with a non-zero exit.
1354+
- `run402 operator logout` — revokes the session server-side (`POST /agent/v1/operator/session/revoke`) then clears the local cache. Idempotent; best-effort revoke (always clears locally). Stdout: `{ revoked, cleared }`.
1355+
1356+
The session is email-scoped (~30m access TTL, ~12h absolute cap), so it is cached once at the base dir and reused regardless of the active `--wallet`. Login/overview/logout require the gateway device-authorization bridge (kychee-com/run402-private#443); `whoami` is local and works today. Not exposed as MCP tools by design — MCP authenticates as the agent (wallet), and the human session must not be handed to the agent.
1357+
13481358
### service
13491359
Public service-level status. No allowance, no auth, no keystore required — works on a fresh install. Reports on the Run402 service (uptime, capabilities, operator); for your account state use `run402 status`.
13501360
- `run402 service status` — public availability report (24h/7d/30d uptime per capability, operator, deployment, schema `run402-status-v1`)

0 commit comments

Comments
 (0)