run402 2.39.4 → 2.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/cli.mjs +1 -1
  2. package/core-dist/control-plane-session.js +114 -0
  3. package/lib/operator.mjs +203 -12
  4. package/lib/org.mjs +205 -103
  5. package/lib/transfer.mjs +94 -27
  6. package/package.json +1 -1
  7. package/sdk/core-dist/control-plane-session.js +114 -0
  8. package/sdk/dist/control-plane-credentials.d.ts +45 -0
  9. package/sdk/dist/control-plane-credentials.d.ts.map +1 -0
  10. package/sdk/dist/control-plane-credentials.js +57 -0
  11. package/sdk/dist/control-plane-credentials.js.map +1 -0
  12. package/sdk/dist/errors.d.ts +31 -1
  13. package/sdk/dist/errors.d.ts.map +1 -1
  14. package/sdk/dist/errors.js +59 -0
  15. package/sdk/dist/errors.js.map +1 -1
  16. package/sdk/dist/index.d.ts +6 -2
  17. package/sdk/dist/index.d.ts.map +1 -1
  18. package/sdk/dist/index.js +4 -2
  19. package/sdk/dist/index.js.map +1 -1
  20. package/sdk/dist/kernel.d.ts.map +1 -1
  21. package/sdk/dist/kernel.js +4 -1
  22. package/sdk/dist/kernel.js.map +1 -1
  23. package/sdk/dist/namespaces/operator-session.d.ts +223 -0
  24. package/sdk/dist/namespaces/operator-session.d.ts.map +1 -0
  25. package/sdk/dist/namespaces/operator-session.js +230 -0
  26. package/sdk/dist/namespaces/operator-session.js.map +1 -0
  27. package/sdk/dist/namespaces/operator.d.ts +63 -0
  28. package/sdk/dist/namespaces/operator.d.ts.map +1 -1
  29. package/sdk/dist/namespaces/operator.js +51 -0
  30. package/sdk/dist/namespaces/operator.js.map +1 -1
  31. package/sdk/dist/namespaces/org.d.ts +55 -23
  32. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  33. package/sdk/dist/namespaces/org.js +117 -52
  34. package/sdk/dist/namespaces/org.js.map +1 -1
  35. package/sdk/dist/namespaces/org.types.d.ts +37 -1
  36. package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
  37. package/sdk/dist/namespaces/transfers.d.ts +58 -0
  38. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  39. package/sdk/dist/namespaces/transfers.js +40 -0
  40. package/sdk/dist/namespaces/transfers.js.map +1 -1
package/cli.mjs CHANGED
@@ -30,7 +30,7 @@ Commands:
30
30
  deploy Unified deploy operations (requires active tier)
31
31
  ci Link GitHub Actions OIDC deploy bindings
32
32
  transfer Two-party project transfer (init, preview, list, accept, cancel)
33
- org Org membership + roles (whoami, list, members, add-member, set-role, remove-member)
33
+ org Org membership, invites & audit (whoami, list, member, invite, audit)
34
34
  grants Per-project capability grants for agent/CI principals (create, revoke)
35
35
  jobs Submit and inspect fixed platform-managed jobs
36
36
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
@@ -0,0 +1,114 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, statSync, rmSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { getConfigBaseDir } from "./config.js";
5
+ /**
6
+ * Path to the cached control-plane session: `{base}/control-plane-session.json`.
7
+ * `RUN402_CONTROL_PLANE_SESSION_PATH` overrides for testing.
8
+ */
9
+ export function getControlPlaneSessionPath() {
10
+ return (process.env.RUN402_CONTROL_PLANE_SESSION_PATH ||
11
+ join(getConfigBaseDir(), "control-plane-session.json"));
12
+ }
13
+ /** Tighten 0600 if group/other-readable, warning once. Best-effort, POSIX-only. */
14
+ function selfHealPermissions(p) {
15
+ if (process.platform === "win32")
16
+ return;
17
+ try {
18
+ const mode = statSync(p).mode & 0o777;
19
+ if ((mode & 0o077) !== 0) {
20
+ chmodSync(p, 0o600);
21
+ process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
22
+ }
23
+ }
24
+ catch {
25
+ // Best-effort; never block a read on a chmod/stat failure.
26
+ }
27
+ }
28
+ /**
29
+ * Load the cached control-plane session. Returns `null` for the "no session"
30
+ * cases (absent, unreadable, unparseable). Throws when the file parses as JSON
31
+ * but the shape is wrong, so a corrupted cache surfaces a clear fix-it.
32
+ */
33
+ export function readControlPlaneSession(path) {
34
+ const p = path ?? getControlPlaneSessionPath();
35
+ if (!existsSync(p))
36
+ return null;
37
+ selfHealPermissions(p);
38
+ let raw;
39
+ try {
40
+ raw = readFileSync(p, "utf-8");
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ throw new Error("control-plane-session.json must contain a JSON object. Delete it and run 'run402 operator login --loopback' to recreate it.");
54
+ }
55
+ const data = parsed;
56
+ if (typeof data.control_plane_session_token !== "string" ||
57
+ data.control_plane_session_token.length === 0) {
58
+ throw new Error("control-plane-session.json missing valid 'control_plane_session_token'. Run 'run402 operator login --loopback' to refresh it.");
59
+ }
60
+ if (typeof data.expires_at !== "number" || !Number.isFinite(data.expires_at)) {
61
+ throw new Error("control-plane-session.json missing valid 'expires_at'. Run 'run402 operator login --loopback' to refresh it.");
62
+ }
63
+ return {
64
+ control_plane_session_token: data.control_plane_session_token,
65
+ token_type: typeof data.token_type === "string" ? data.token_type : "Bearer",
66
+ provenance: typeof data.provenance === "string" ? data.provenance : "loopback_pkce",
67
+ principal_id: typeof data.principal_id === "string" ? data.principal_id : "",
68
+ amr: Array.isArray(data.amr) ? data.amr.filter((a) => typeof a === "string") : [],
69
+ expires_at: data.expires_at,
70
+ };
71
+ }
72
+ /** Persist a control-plane session atomically (temp-file + rename), mode 0600. */
73
+ export function saveControlPlaneSession(data, path) {
74
+ const p = path ?? getControlPlaneSessionPath();
75
+ const dir = dirname(p);
76
+ mkdirSync(dir, { recursive: true });
77
+ const tmp = join(dir, `.control-plane-session.${randomBytes(4).toString("hex")}.tmp`);
78
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
79
+ renameSync(tmp, p);
80
+ chmodSync(p, 0o600);
81
+ }
82
+ /** Delete the cached control-plane session — local half of `operator logout`. Idempotent. */
83
+ export function clearControlPlaneSession(path) {
84
+ const p = path ?? getControlPlaneSessionPath();
85
+ try {
86
+ rmSync(p, { force: true });
87
+ }
88
+ catch {
89
+ // Best-effort: a failed unlink should never crash logout.
90
+ }
91
+ }
92
+ /** Whether a cached session is past its usable life (with a small skew buffer). */
93
+ export function isControlPlaneSessionExpired(session, nowMs = Date.now(), skewMs = 10_000) {
94
+ return nowMs + skewMs >= session.expires_at;
95
+ }
96
+ /** Read the cached session and return it only if still usable; `null` if absent or expired. */
97
+ export function loadLiveControlPlaneSession(path, nowMs = Date.now()) {
98
+ const s = readControlPlaneSession(path);
99
+ if (!s)
100
+ return null;
101
+ return isControlPlaneSessionExpired(s, nowMs) ? null : s;
102
+ }
103
+ /** Map a gateway token payload (relative `expires_in`) into the cached shape (absolute `expires_at`). */
104
+ export function controlPlaneSessionFromTokenResponse(resp, nowMs = Date.now()) {
105
+ return {
106
+ control_plane_session_token: resp.control_plane_session_token,
107
+ token_type: resp.token_type ?? "Bearer",
108
+ provenance: resp.provenance ?? "loopback_pkce",
109
+ principal_id: resp.principal_id ?? "",
110
+ amr: Array.isArray(resp.amr) ? resp.amr.filter((a) => typeof a === "string") : [],
111
+ expires_at: nowMs + (typeof resp.expires_in === "number" ? resp.expires_in : 0) * 1000,
112
+ };
113
+ }
114
+ //# sourceMappingURL=control-plane-session.js.map
package/lib/operator.mjs CHANGED
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { setTimeout as sleep } from "node:timers/promises";
21
21
  import { spawn } from "node:child_process";
22
+ import { createServer } from "node:http";
23
+ import { randomBytes, createHash } from "node:crypto";
22
24
  import { fail, reportSdkError } from "./sdk-errors.mjs";
23
25
  import { getSdk } from "./sdk.mjs";
24
26
  import { normalizeArgv, hasHelp, assertKnownFlags } from "./argparse.mjs";
@@ -30,6 +32,13 @@ import {
30
32
  isOperatorSessionExpired,
31
33
  operatorSessionFromTokenResponse,
32
34
  } from "../core-dist/operator-session.js";
35
+ import {
36
+ saveControlPlaneSession,
37
+ clearControlPlaneSession,
38
+ readControlPlaneSession,
39
+ isControlPlaneSessionExpired,
40
+ controlPlaneSessionFromTokenResponse,
41
+ } from "../core-dist/control-plane-session.js";
33
42
 
34
43
  const CLIENT_NAME = "run402 CLI";
35
44
 
@@ -40,19 +49,25 @@ The operator is YOU, the human, identified by email — distinct from the agent
40
49
  For a single wallet's account state, use 'run402 status'.
41
50
 
42
51
  Usage:
43
- run402 operator login [--no-open]
52
+ run402 operator login [--no-open] (read session, device-flow)
53
+ run402 operator login --loopback [--no-open] (write session, loopback-PKCE)
54
+ run402 operator login --step-up (fresh write session for high-stakes ops)
44
55
  run402 operator overview
45
56
  run402 operator whoami
46
57
  run402 operator logout
47
58
 
48
59
  Subcommands:
49
- login Sign in via the browser (device-authorization, like 'aws sso login')
60
+ login Sign in via the browser. Default = device-flow READ session (powers
61
+ 'overview'). --loopback = write-capable control-plane session
62
+ (aws-sso-style, passkey-fresh). --step-up = re-mint a fresh write session.
50
63
  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
64
+ whoami Show the cached session(s) — local, no network
65
+ logout Revoke the session server-side and clear the local cache(s)
53
66
 
54
67
  Options:
55
- --no-open (login) Do not auto-open the browser; just print the URL + code.
68
+ --no-open (login) Do not auto-open the browser; just print the URL.
69
+ --loopback (login) Use the loopback-PKCE write login instead of the device flow.
70
+ --step-up (login) Re-mint a fresh write session (implies --loopback).
56
71
 
57
72
  Notes:
58
73
  - The session is cached at the base config dir, shared across named wallets.
@@ -96,8 +111,173 @@ function openBrowser(url) {
96
111
  }
97
112
  }
98
113
 
114
+ /** Output shape for the write (control-plane) session. NEVER includes the token. */
115
+ function controlPlaneView(session, nowMs = Date.now()) {
116
+ return {
117
+ logged_in: true,
118
+ kind: "control_plane_session",
119
+ provenance: session.provenance,
120
+ principal_id: session.principal_id || null,
121
+ amr: session.amr,
122
+ expires_at: new Date(session.expires_at).toISOString(),
123
+ expires_in_seconds: Math.max(0, Math.round((session.expires_at - nowMs) / 1000)),
124
+ write_capable: true,
125
+ };
126
+ }
127
+
128
+ const base64url = (buf) => buf.toString("base64url");
129
+
130
+ /** Generate PKCE (S256) + CSRF state + replay nonce for the loopback flow. */
131
+ function pkce() {
132
+ const codeVerifier = base64url(randomBytes(32));
133
+ const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
134
+ return { codeVerifier, codeChallenge, state: base64url(randomBytes(16)), nonce: base64url(randomBytes(16)) };
135
+ }
136
+
137
+ /**
138
+ * Start a 127.0.0.1 loopback server (RFC 8252) that captures exactly one
139
+ * redirect. Returns the bound port (via `ready`), a promise for the auth code,
140
+ * and a `close()`. State is validated here to reject CSRF before exchange.
141
+ */
142
+ function startLoopbackServer({ expectedState, timeoutMs }) {
143
+ let resolveCode;
144
+ let rejectCode;
145
+ const codePromise = new Promise((res, rej) => {
146
+ resolveCode = res;
147
+ rejectCode = rej;
148
+ });
149
+ let timer;
150
+ const server = createServer((req, res) => {
151
+ let u;
152
+ try {
153
+ u = new URL(req.url, "http://127.0.0.1");
154
+ } catch {
155
+ res.writeHead(400).end("bad request");
156
+ return;
157
+ }
158
+ if (u.pathname !== "/callback") {
159
+ res.writeHead(404).end("not found");
160
+ return;
161
+ }
162
+ const code = u.searchParams.get("code");
163
+ const gotState = u.searchParams.get("state");
164
+ const errParam = u.searchParams.get("error");
165
+ res.writeHead(200, { "content-type": "text/html" });
166
+ res.end(
167
+ "<!doctype html><html><body style=\"font-family:system-ui;padding:3rem\">" +
168
+ "<h2>run402 - you're signed in.</h2><p>You can close this window and return to your terminal.</p></body></html>",
169
+ );
170
+ cleanup();
171
+ if (errParam) rejectCode(new Error(`authorization error: ${errParam}`));
172
+ else if (!code) rejectCode(new Error("no authorization code on the loopback redirect"));
173
+ else if (gotState !== expectedState) rejectCode(new Error("state mismatch on the loopback redirect (possible CSRF) - aborted"));
174
+ else resolveCode(code);
175
+ });
176
+ function cleanup() {
177
+ clearTimeout(timer);
178
+ try {
179
+ server.close();
180
+ } catch {
181
+ // already closing
182
+ }
183
+ }
184
+ timer = setTimeout(() => {
185
+ cleanup();
186
+ rejectCode(new Error("timed out waiting for browser approval"));
187
+ }, timeoutMs);
188
+ server.on("error", (e) => {
189
+ cleanup();
190
+ rejectCode(e);
191
+ });
192
+ const ready = new Promise((res, rej) => {
193
+ server.once("error", rej);
194
+ server.listen(0, "127.0.0.1", () => res(server.address().port));
195
+ });
196
+ return { ready, codePromise, close: cleanup };
197
+ }
198
+
199
+ /**
200
+ * Loopback-PKCE write-login (RFC 8252, the aws-sso-style flow). Mints a
201
+ * write-capable control-plane session via the browser passkey ceremony, caches
202
+ * it (mode 0600), and prints metadata only - never the token.
203
+ */
204
+ async function loopbackLogin(args, { stepUp }) {
205
+ const noOpen = args.includes("--no-open");
206
+ const sdk = getSdk();
207
+ const { codeVerifier, codeChallenge, state, nonce } = pkce();
208
+ const { ready, codePromise, close } = startLoopbackServer({ expectedState: state, timeoutMs: 300_000 });
209
+
210
+ let port;
211
+ try {
212
+ port = await ready;
213
+ } catch (err) {
214
+ close();
215
+ return fail({ code: "OPERATOR_LOOPBACK_FAILED", message: `Could not start the loopback server: ${err.message}` });
216
+ }
217
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
218
+ const authorizeUrl = sdk.operator.buildCliAuthorizeUrl({ redirectUri, codeChallenge, state, nonce });
219
+
220
+ process.stderr.write(
221
+ `\nTo ${stepUp ? "re-authenticate (step-up)" : "sign in (write-capable)"}, open:\n ${authorizeUrl}\n\n`,
222
+ );
223
+ if (!noOpen && process.stderr.isTTY) {
224
+ openBrowser(authorizeUrl);
225
+ process.stderr.write("(opening your browser…)\n\n");
226
+ }
227
+ process.stderr.write("Waiting for approval…\n");
228
+
229
+ let code;
230
+ try {
231
+ code = await codePromise;
232
+ } catch (err) {
233
+ close();
234
+ return fail({
235
+ code: "OPERATOR_LOGIN_FAILED",
236
+ message: err.message,
237
+ hint: "Run 'run402 operator login --loopback' to try again.",
238
+ });
239
+ }
240
+
241
+ let session;
242
+ try {
243
+ session = await sdk.operator.exchangeCliToken({ code, codeVerifier, redirectUri, state });
244
+ } catch (err) {
245
+ return reportSdkError(err);
246
+ }
247
+
248
+ const cached = controlPlaneSessionFromTokenResponse(session);
249
+ saveControlPlaneSession(cached);
250
+ process.stderr.write(`\nSigned in (write-capable, provenance=${cached.provenance}).\n`);
251
+
252
+ // Surface org memberships — newly-active rows are invites auto-claimed at this
253
+ // login (owner/admin invites only claim once a passkey is enrolled). Best-effort:
254
+ // login already succeeded, so a whoami hiccup must not fail the command.
255
+ const view = controlPlaneView(cached);
256
+ try {
257
+ const who = await sdk.operator.session.whoami({ token: session.control_plane_session_token });
258
+ const memberships = Array.isArray(who?.memberships) ? who.memberships : [];
259
+ view.memberships = memberships;
260
+ if (memberships.length) {
261
+ process.stderr.write(
262
+ `Member of ${memberships.length} org(s):\n` +
263
+ memberships.map((m) => ` - ${m.billing_account_id} (${m.role}, ${m.status})`).join("\n") +
264
+ "\n",
265
+ );
266
+ }
267
+ } catch {
268
+ /* best-effort — the session is valid regardless of the whoami result */
269
+ }
270
+ console.log(JSON.stringify(view));
271
+ }
272
+
99
273
  async function login(args) {
100
- assertKnownFlags(args, ["--help", "-h", "--no-open"]);
274
+ assertKnownFlags(args, ["--help", "-h", "--no-open", "--loopback", "--device", "--step-up"]);
275
+ // Loopback-PKCE = the write-capable control-plane login (--loopback/--step-up).
276
+ // The default stays the device-flow READ session (which powers 'overview');
277
+ // --device forces it explicitly.
278
+ if (args.includes("--loopback") || args.includes("--step-up")) {
279
+ return loopbackLogin(args, { stepUp: args.includes("--step-up") });
280
+ }
101
281
  const noOpen = args.includes("--no-open");
102
282
  const sdk = getSdk();
103
283
 
@@ -184,6 +364,7 @@ async function logout(args) {
184
364
  }
185
365
  }
186
366
  clearOperatorSession();
367
+ clearControlPlaneSession();
187
368
  console.log(JSON.stringify({ revoked, cleared: true }));
188
369
  }
189
370
 
@@ -219,17 +400,27 @@ async function whoami(args) {
219
400
  assertKnownFlags(args, ["--help", "-h"]);
220
401
  const now = Date.now();
221
402
  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;
403
+ const cp = readControlPlaneSession();
404
+ const liveCp = cp && !isControlPlaneSessionExpired(cp, now) ? cp : null;
405
+
406
+ if (session && !isOperatorSessionExpired(session, now)) {
407
+ const view = sessionView(session, now);
408
+ if (liveCp) view.control_plane = controlPlaneView(liveCp, now);
409
+ console.log(JSON.stringify(view));
225
410
  return;
226
411
  }
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." }));
412
+ // No live device-flow READ session fall back to the write session if present.
413
+ if (liveCp) {
414
+ console.log(JSON.stringify(controlPlaneView(liveCp, now)));
415
+ return;
416
+ }
417
+ if (!session) {
418
+ console.log(JSON.stringify({ logged_in: false, reason: "no_session", hint: "Run 'run402 operator login' to sign in." }));
229
419
  process.exitCode = 1;
230
420
  return;
231
421
  }
232
- console.log(JSON.stringify(sessionView(session, now)));
422
+ console.log(JSON.stringify({ logged_in: false, reason: "expired", email: session.email, hint: "Run 'run402 operator login' to sign in again." }));
423
+ process.exitCode = 1;
233
424
  }
234
425
 
235
426
  export async function run(sub, args = []) {