run402 2.30.0 → 2.32.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.
- package/cli.mjs +6 -0
- package/core-dist/operator-session.js +154 -0
- package/lib/jobs.mjs +124 -5
- package/lib/operator.mjs +261 -0
- package/package.json +1 -1
- package/sdk/core-dist/operator-session.js +154 -0
- package/sdk/dist/index.d.ts +7 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +7 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/jobs.d.ts +46 -1
- package/sdk/dist/namespaces/jobs.d.ts.map +1 -1
- package/sdk/dist/namespaces/jobs.js +28 -1
- package/sdk/dist/namespaces/jobs.js.map +1 -1
- package/sdk/dist/namespaces/operator.d.ts +112 -0
- package/sdk/dist/namespaces/operator.d.ts.map +1 -0
- package/sdk/dist/namespaces/operator.js +107 -0
- package/sdk/dist/namespaces/operator.js.map +1 -0
- package/sdk/dist/scoped.d.ts +1 -0
- package/sdk/dist/scoped.d.ts.map +1 -1
- package/sdk/dist/scoped.js +3 -0
- package/sdk/dist/scoped.js.map +1 -1
package/cli.mjs
CHANGED
|
@@ -48,6 +48,7 @@ Commands:
|
|
|
48
48
|
billing Email billing accounts, Stripe tier checkout, email packs
|
|
49
49
|
contracts KMS contract wallets ($0.04/day rental + $0.000005/sign)
|
|
50
50
|
agent Manage agent identity (contact info)
|
|
51
|
+
operator Operator (human/email) session — login, then overview across your wallets
|
|
51
52
|
service Run402 service health and availability (status, health)
|
|
52
53
|
cache Inspect and invalidate the SSR origin cache (inspect, invalidate)
|
|
53
54
|
doctor Health and config diagnostics (machine-readable with --json)
|
|
@@ -244,6 +245,11 @@ switch (cmd) {
|
|
|
244
245
|
await run(sub, rest);
|
|
245
246
|
break;
|
|
246
247
|
}
|
|
248
|
+
case "operator": {
|
|
249
|
+
const { run } = await import("./lib/operator.mjs");
|
|
250
|
+
await run(sub, rest);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
247
253
|
case "auth": {
|
|
248
254
|
const { run } = await import("./lib/auth.mjs");
|
|
249
255
|
await run(sub, rest);
|
|
@@ -0,0 +1,154 @@
|
|
|
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 operator session: `{base}/operator-session.json`, at the
|
|
7
|
+
* BASE config dir — NOT the per-profile dir, because the session is email-
|
|
8
|
+
* scoped and shared across all local named wallets. `RUN402_OPERATOR_SESSION_PATH`
|
|
9
|
+
* overrides for testing, mirroring `RUN402_ALLOWANCE_PATH`.
|
|
10
|
+
*/
|
|
11
|
+
export function getOperatorSessionPath() {
|
|
12
|
+
return process.env.RUN402_OPERATOR_SESSION_PATH || join(getConfigBaseDir(), "operator-session.json");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* If the session file is readable by group or other (any low 0o077 bit set),
|
|
16
|
+
* tighten it to 0600 and warn once on stderr — the bearer token is as sensitive
|
|
17
|
+
* as the allowance private key. Best-effort: POSIX-only, silent elsewhere.
|
|
18
|
+
* Mirrors the self-heal in `allowance.ts`.
|
|
19
|
+
*/
|
|
20
|
+
function selfHealPermissions(p) {
|
|
21
|
+
if (process.platform === "win32")
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
const mode = statSync(p).mode & 0o777;
|
|
25
|
+
if ((mode & 0o077) !== 0) {
|
|
26
|
+
chmodSync(p, 0o600);
|
|
27
|
+
process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Best-effort; never block a read on a chmod/stat failure.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Load the cached operator session from disk.
|
|
36
|
+
*
|
|
37
|
+
* Returns `null` for the "no session cached" cases (file absent, unreadable, or
|
|
38
|
+
* unparseable JSON) — callers treat that as "not logged in" and point at
|
|
39
|
+
* `run402 operator login`. Throws a structured `Error` when the file parses as
|
|
40
|
+
* JSON but the shape is wrong, so a corrupted cache surfaces a clear fix-it
|
|
41
|
+
* instead of a downstream `TypeError`.
|
|
42
|
+
*/
|
|
43
|
+
export function readOperatorSession(path) {
|
|
44
|
+
const p = path ?? getOperatorSessionPath();
|
|
45
|
+
if (!existsSync(p))
|
|
46
|
+
return null;
|
|
47
|
+
selfHealPermissions(p);
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(p, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Unparseable input reads as "no session" rather than an error — consumers
|
|
61
|
+
// already handle null with a friendly "run 'run402 operator login'".
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
65
|
+
throw new Error(`operator-session.json must contain a JSON object (got ${Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed}). Delete the file and run 'run402 operator login' to recreate it.`);
|
|
66
|
+
}
|
|
67
|
+
const data = parsed;
|
|
68
|
+
if (typeof data.operator_session_token !== "string" || data.operator_session_token.length === 0) {
|
|
69
|
+
throw new Error("operator-session.json missing valid 'operator_session_token'. Run 'run402 operator login' to refresh it.");
|
|
70
|
+
}
|
|
71
|
+
if (typeof data.email !== "string" || data.email.length === 0) {
|
|
72
|
+
throw new Error("operator-session.json missing valid 'email'. Run 'run402 operator login' to refresh it.");
|
|
73
|
+
}
|
|
74
|
+
if (typeof data.expires_at !== "number" || !Number.isFinite(data.expires_at)) {
|
|
75
|
+
throw new Error("operator-session.json missing valid 'expires_at'. Run 'run402 operator login' to refresh it.");
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(data.wallets) || data.wallets.some((w) => typeof w !== "string")) {
|
|
78
|
+
throw new Error("operator-session.json has an invalid 'wallets' list. Run 'run402 operator login' to refresh it.");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
operator_session_token: data.operator_session_token,
|
|
82
|
+
token_type: typeof data.token_type === "string" ? data.token_type : "Bearer",
|
|
83
|
+
email: data.email,
|
|
84
|
+
wallets: data.wallets,
|
|
85
|
+
expires_at: data.expires_at,
|
|
86
|
+
absolute_expires_at: typeof data.absolute_expires_at === "string" ? data.absolute_expires_at : "",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Persist an operator session atomically (temp-file + rename), mode 0600. */
|
|
90
|
+
export function saveOperatorSession(data, path) {
|
|
91
|
+
const p = path ?? getOperatorSessionPath();
|
|
92
|
+
const dir = dirname(p);
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
const tmp = join(dir, `.operator-session.${randomBytes(4).toString("hex")}.tmp`);
|
|
95
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
96
|
+
renameSync(tmp, p);
|
|
97
|
+
chmodSync(p, 0o600);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Delete the cached operator session — the local half of `operator logout`.
|
|
101
|
+
* Best-effort and idempotent: a missing file is a no-op.
|
|
102
|
+
*/
|
|
103
|
+
export function clearOperatorSession(path) {
|
|
104
|
+
const p = path ?? getOperatorSessionPath();
|
|
105
|
+
try {
|
|
106
|
+
rmSync(p, { force: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort: a failed unlink should never crash logout.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether a cached session is past its usable life. The access token
|
|
114
|
+
* (`expires_at`, ~30m) always expires before the absolute cap (~12h), so
|
|
115
|
+
* checking it is sufficient; the absolute cap is honored defensively. A small
|
|
116
|
+
* skew buffer treats a session expiring within `skewMs` as already expired, so
|
|
117
|
+
* we never send a token that dies mid-flight.
|
|
118
|
+
*/
|
|
119
|
+
export function isOperatorSessionExpired(session, nowMs = Date.now(), skewMs = 10_000) {
|
|
120
|
+
if (nowMs + skewMs >= session.expires_at)
|
|
121
|
+
return true;
|
|
122
|
+
if (session.absolute_expires_at) {
|
|
123
|
+
const cap = Date.parse(session.absolute_expires_at);
|
|
124
|
+
if (Number.isFinite(cap) && nowMs + skewMs >= cap)
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read the cached session and return it only if still usable; `null` if absent
|
|
131
|
+
* or expired. The bearer fetch path and `operator overview` use this so an
|
|
132
|
+
* expired cache surfaces as "not logged in" instead of a server 401.
|
|
133
|
+
*/
|
|
134
|
+
export function loadLiveOperatorSession(path, nowMs = Date.now()) {
|
|
135
|
+
const s = readOperatorSession(path);
|
|
136
|
+
if (!s)
|
|
137
|
+
return null;
|
|
138
|
+
return isOperatorSessionExpired(s, nowMs) ? null : s;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Map a gateway token payload (relative `expires_in`) into the cached shape
|
|
142
|
+
* (absolute `expires_at`). `nowMs` is injectable for deterministic tests.
|
|
143
|
+
*/
|
|
144
|
+
export function operatorSessionFromTokenResponse(resp, nowMs = Date.now()) {
|
|
145
|
+
return {
|
|
146
|
+
operator_session_token: resp.operator_session_token,
|
|
147
|
+
token_type: resp.token_type ?? "Bearer",
|
|
148
|
+
email: resp.email ?? "",
|
|
149
|
+
wallets: Array.isArray(resp.wallets) ? resp.wallets.filter((w) => typeof w === "string") : [],
|
|
150
|
+
expires_at: nowMs + (typeof resp.expires_in === "number" ? resp.expires_in : 0) * 1000,
|
|
151
|
+
absolute_expires_at: resp.absolute_expires_at ?? "",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=operator-session.js.map
|
package/lib/jobs.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
2
5
|
|
|
3
6
|
import { getSdk } from "./sdk.mjs";
|
|
4
7
|
import { resolveProjectId } from "./config.mjs";
|
|
@@ -8,6 +11,7 @@ import {
|
|
|
8
11
|
flagValue,
|
|
9
12
|
normalizeArgv,
|
|
10
13
|
parseIntegerFlag,
|
|
14
|
+
positionalArgs,
|
|
11
15
|
requirePositionalCount,
|
|
12
16
|
validateRegularFile,
|
|
13
17
|
} from "./argparse.mjs";
|
|
@@ -18,10 +22,11 @@ Usage:
|
|
|
18
22
|
run402 jobs <subcommand> [args...] [options]
|
|
19
23
|
|
|
20
24
|
Subcommands:
|
|
21
|
-
submit
|
|
22
|
-
get
|
|
23
|
-
logs
|
|
24
|
-
cancel
|
|
25
|
+
submit --file <path>|--stdin Submit a managed job request
|
|
26
|
+
get <job_id> Get a job run
|
|
27
|
+
logs <job_id> Read job logs
|
|
28
|
+
cancel <job_id> Cancel a queued or running job
|
|
29
|
+
artifacts get <job_id> <file> Download a completed job's artifact
|
|
25
30
|
|
|
26
31
|
Examples:
|
|
27
32
|
run402 jobs submit --file job.json
|
|
@@ -29,6 +34,7 @@ Examples:
|
|
|
29
34
|
run402 jobs get job_abc123
|
|
30
35
|
run402 jobs logs job_abc123 --tail 100
|
|
31
36
|
run402 jobs cancel job_abc123
|
|
37
|
+
run402 jobs artifacts get job_abc123 proof.json --output ./proof.json
|
|
32
38
|
|
|
33
39
|
Notes:
|
|
34
40
|
- --project defaults to the active project from 'run402 projects use'
|
|
@@ -85,6 +91,35 @@ Usage:
|
|
|
85
91
|
|
|
86
92
|
Options:
|
|
87
93
|
--project <id> Project ID (defaults to the active project)
|
|
94
|
+
`,
|
|
95
|
+
artifacts: `run402 jobs artifacts — Download outputs from a completed managed job
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
run402 jobs artifacts get <job_id> <file> --output <path> [--project <id>]
|
|
99
|
+
|
|
100
|
+
Actions:
|
|
101
|
+
get <job_id> <file> Download the named artifact to a local file
|
|
102
|
+
|
|
103
|
+
Recorded artifact filenames (per run): proof.json, public.json,
|
|
104
|
+
prove-output.log, prove-time.log, verify-output.log. Use 'run402 jobs get
|
|
105
|
+
<job_id>' and read the 'artifacts' map for the exact set on a given run.
|
|
106
|
+
`,
|
|
107
|
+
"artifacts get": `run402 jobs artifacts get — Download a completed job's artifact
|
|
108
|
+
|
|
109
|
+
Usage:
|
|
110
|
+
run402 jobs artifacts get <job_id> <file> --output <path> [--project <id>]
|
|
111
|
+
|
|
112
|
+
Options:
|
|
113
|
+
--output, -o <path> Local destination path (required)
|
|
114
|
+
--project <id> Project ID (defaults to the active project)
|
|
115
|
+
|
|
116
|
+
The job must be completed and the filename must be in its recorded artifact
|
|
117
|
+
set (see the 'artifacts' map from 'run402 jobs get <job_id>'); otherwise the
|
|
118
|
+
gateway returns 404. Prints a JSON envelope { job_id, filename, project_id,
|
|
119
|
+
output, ... } on success.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
run402 jobs artifacts get job_abc123 proof.json --output ./proof.json
|
|
88
123
|
`,
|
|
89
124
|
};
|
|
90
125
|
|
|
@@ -257,6 +292,83 @@ async function cancel(jobId, args = []) {
|
|
|
257
292
|
}
|
|
258
293
|
}
|
|
259
294
|
|
|
295
|
+
async function artifactsGet(args = []) {
|
|
296
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
297
|
+
console.log(SUB_HELP["artifacts get"]);
|
|
298
|
+
process.exit(0);
|
|
299
|
+
}
|
|
300
|
+
const parsed = normalizeArgv(args);
|
|
301
|
+
const valueFlags = ["--project", "--output", "-o"];
|
|
302
|
+
assertKnownFlags(parsed, ["--project", "--output", "-o", "--help", "-h"], valueFlags);
|
|
303
|
+
const positionals = positionalArgs(parsed, valueFlags);
|
|
304
|
+
if (positionals.length < 2) {
|
|
305
|
+
fail({
|
|
306
|
+
code: "BAD_USAGE",
|
|
307
|
+
message: "Missing job_id and/or artifact filename.",
|
|
308
|
+
hint: "Use `run402 jobs artifacts get <job_id> <file> --output <path>`.",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (positionals.length > 2) {
|
|
312
|
+
fail({
|
|
313
|
+
code: "BAD_USAGE",
|
|
314
|
+
message: `Unexpected argument: ${positionals[2]}`,
|
|
315
|
+
hint: "Use `run402 jobs artifacts get <job_id> <file> --output <path>`.",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const [jobId, filename] = positionals;
|
|
319
|
+
const output = flagValue(parsed, "--output") ?? flagValue(parsed, "-o");
|
|
320
|
+
if (!output) {
|
|
321
|
+
fail({
|
|
322
|
+
code: "BAD_USAGE",
|
|
323
|
+
message: "--output <file> required",
|
|
324
|
+
hint: "Use `run402 jobs artifacts get <job_id> <file> --output <path>`.",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const projectId = resolveProjectId(flagValue(parsed, "--project"));
|
|
328
|
+
|
|
329
|
+
let res;
|
|
330
|
+
try {
|
|
331
|
+
res = await getSdk().jobs.downloadArtifact(projectId, jobId, filename);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
reportSdkError(err);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!res.body) {
|
|
337
|
+
fail({ code: "EMPTY_BODY", message: "Empty response body" });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const outPath = resolve(output);
|
|
341
|
+
const contentType = res.headers.get("content-type");
|
|
342
|
+
const contentLength = Number(res.headers.get("content-length") ?? 0);
|
|
343
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
344
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(outPath));
|
|
345
|
+
console.log(
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
job_id: jobId,
|
|
348
|
+
filename,
|
|
349
|
+
project_id: projectId,
|
|
350
|
+
output: outPath,
|
|
351
|
+
...(contentType ? { content_type: contentType } : {}),
|
|
352
|
+
...(contentLength > 0 ? { size_bytes: contentLength } : {}),
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function artifacts(args = []) {
|
|
358
|
+
const action = args[0];
|
|
359
|
+
if (!action || action === "--help" || action === "-h") {
|
|
360
|
+
console.log(SUB_HELP.artifacts);
|
|
361
|
+
process.exit(0);
|
|
362
|
+
}
|
|
363
|
+
if (action === "get") {
|
|
364
|
+
await artifactsGet(args.slice(1));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
console.error(`Unknown jobs artifacts action: ${action}\n`);
|
|
368
|
+
console.log(SUB_HELP.artifacts);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
|
|
260
372
|
function splitJobIdArg(args = [], valueFlags = []) {
|
|
261
373
|
const flagsWithValues = new Set(valueFlags);
|
|
262
374
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -279,6 +391,13 @@ export async function run(sub, args = []) {
|
|
|
279
391
|
console.log(HELP);
|
|
280
392
|
process.exit(0);
|
|
281
393
|
}
|
|
394
|
+
// Nested group: route before the flat-help interceptor so per-action --help
|
|
395
|
+
// (e.g. `jobs artifacts get --help`) resolves to the action's own help,
|
|
396
|
+
// mirroring `deploy release`.
|
|
397
|
+
if (sub === "artifacts") {
|
|
398
|
+
await artifacts(Array.isArray(args) ? args : []);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
282
401
|
if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) {
|
|
283
402
|
console.log(SUB_HELP[sub] || HELP);
|
|
284
403
|
process.exit(0);
|
package/lib/operator.mjs
ADDED
|
@@ -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
|
+
}
|
package/package.json
CHANGED