run402 3.0.0 → 3.2.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/core-dist/write-auth-session.js +174 -0
- package/lib/deploy-v2.mjs +13 -8
- package/lib/functions.mjs +6 -6
- package/lib/operator.mjs +234 -1
- package/lib/projects.mjs +9 -4
- package/lib/sdk.mjs +3 -1
- package/package.json +1 -1
- package/sdk/core-dist/write-auth-session.js +174 -0
- package/sdk/dist/credentials.d.ts +28 -1
- package/sdk/dist/credentials.d.ts.map +1 -1
- package/sdk/dist/errors.d.ts +43 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js +71 -0
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/index.d.ts +1 -1
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +1 -1
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/kernel.d.ts +3 -1
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +35 -4
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.js +20 -4
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +13 -0
- package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.types.js.map +1 -1
- package/sdk/dist/namespaces/functions.d.ts +28 -15
- package/sdk/dist/namespaces/functions.d.ts.map +1 -1
- package/sdk/dist/namespaces/functions.js +64 -28
- package/sdk/dist/namespaces/functions.js.map +1 -1
- package/sdk/dist/namespaces/operator.d.ts +86 -3
- package/sdk/dist/namespaces/operator.d.ts.map +1 -1
- package/sdk/dist/namespaces/operator.js +55 -0
- package/sdk/dist/namespaces/operator.js.map +1 -1
- package/sdk/dist/namespaces/projects.d.ts.map +1 -1
- package/sdk/dist/namespaces/projects.js +11 -0
- package/sdk/dist/namespaces/projects.js.map +1 -1
- package/sdk/dist/namespaces/transfers.d.ts +4 -0
- package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.js +16 -1
- package/sdk/dist/namespaces/transfers.js.map +1 -1
- package/sdk/dist/node/credentials.d.ts +32 -6
- package/sdk/dist/node/credentials.d.ts.map +1 -1
- package/sdk/dist/node/credentials.js +57 -4
- package/sdk/dist/node/credentials.js.map +1 -1
- package/sdk/dist/node/index.d.ts +11 -1
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +3 -1
- package/sdk/dist/node/index.js.map +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, statSync, rmSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
4
|
+
import { getConfigBaseDir } from "./config.js";
|
|
5
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Path to the approval cache: `{base}/write-auth-session.json`.
|
|
8
|
+
* `RUN402_WRITE_AUTH_SESSION_PATH` overrides for testing.
|
|
9
|
+
*/
|
|
10
|
+
export function getWriteAuthSessionPath() {
|
|
11
|
+
return (process.env.RUN402_WRITE_AUTH_SESSION_PATH ||
|
|
12
|
+
join(getConfigBaseDir(), "write-auth-session.json"));
|
|
13
|
+
}
|
|
14
|
+
/** Stable short hash binding an approval to a control-plane session token. */
|
|
15
|
+
export function hashControlPlaneSession(token) {
|
|
16
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 32);
|
|
17
|
+
}
|
|
18
|
+
/** Tighten 0600 if group/other-readable, warning once. Best-effort, POSIX-only. */
|
|
19
|
+
function selfHealPermissions(p) {
|
|
20
|
+
if (process.platform === "win32")
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const mode = statSync(p).mode & 0o777;
|
|
24
|
+
if ((mode & 0o077) !== 0) {
|
|
25
|
+
chmodSync(p, 0o600);
|
|
26
|
+
process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Best-effort; never block a read on a chmod/stat failure.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function isApproval(x) {
|
|
34
|
+
if (!x || typeof x !== "object")
|
|
35
|
+
return false;
|
|
36
|
+
const a = x;
|
|
37
|
+
return (typeof a.write_auth_token === "string" &&
|
|
38
|
+
a.write_auth_token.length > 0 &&
|
|
39
|
+
typeof a.action === "string" &&
|
|
40
|
+
typeof a.api_origin === "string" &&
|
|
41
|
+
typeof a.control_plane_session_hash === "string" &&
|
|
42
|
+
typeof a.expires_at === "number" &&
|
|
43
|
+
Number.isFinite(a.expires_at));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read all cached approvals. Returns `[]` for the "no cache" cases (absent,
|
|
47
|
+
* unreadable, unparseable). Throws when the file parses as JSON but the shape
|
|
48
|
+
* is wrong, so a corrupted cache surfaces a clear fix-it.
|
|
49
|
+
*/
|
|
50
|
+
export function readApprovals(path) {
|
|
51
|
+
const p = path ?? getWriteAuthSessionPath();
|
|
52
|
+
if (!existsSync(p))
|
|
53
|
+
return [];
|
|
54
|
+
selfHealPermissions(p);
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(p, "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
70
|
+
throw new Error("write-auth-session.json must contain a JSON object. Delete it and re-run 'run402 operator approve' to recreate it.");
|
|
71
|
+
}
|
|
72
|
+
const approvals = parsed.approvals;
|
|
73
|
+
if (!Array.isArray(approvals)) {
|
|
74
|
+
throw new Error("write-auth-session.json missing an 'approvals' array. Delete it and re-run 'run402 operator approve'.");
|
|
75
|
+
}
|
|
76
|
+
return approvals.filter(isApproval);
|
|
77
|
+
}
|
|
78
|
+
function writeApprovals(approvals, path) {
|
|
79
|
+
const p = path ?? getWriteAuthSessionPath();
|
|
80
|
+
const dir = dirname(p);
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
const tmp = join(dir, `.write-auth-session.${randomBytes(4).toString("hex")}.tmp`);
|
|
83
|
+
writeFileSync(tmp, JSON.stringify({ approvals }, null, 2), { mode: 0o600 });
|
|
84
|
+
renameSync(tmp, p);
|
|
85
|
+
chmodSync(p, 0o600);
|
|
86
|
+
}
|
|
87
|
+
function sameTarget(a, b) {
|
|
88
|
+
return (a.org_id ?? null) === (b.org_id ?? null) && (a.project_id ?? null) === (b.project_id ?? null);
|
|
89
|
+
}
|
|
90
|
+
function sameKey(a, b) {
|
|
91
|
+
return (a.api_origin === b.api_origin &&
|
|
92
|
+
a.control_plane_session_hash === b.control_plane_session_hash &&
|
|
93
|
+
a.action === b.action &&
|
|
94
|
+
sameTarget(a, b));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Persist an approval. Replaces any existing entry with the same
|
|
98
|
+
* `(api_origin, control_plane_session_hash, action, target)` key and leaves
|
|
99
|
+
* every other entry intact (multi-entry, non-thrashing). Atomic, mode 0600.
|
|
100
|
+
*/
|
|
101
|
+
export function saveApproval(approval, path) {
|
|
102
|
+
const existing = readApprovals(path).filter((a) => !sameKey(a, approval));
|
|
103
|
+
existing.push(approval);
|
|
104
|
+
writeApprovals(existing, path);
|
|
105
|
+
}
|
|
106
|
+
/** Delete the whole approval cache — local half of `operator logout`. Idempotent. */
|
|
107
|
+
export function clearApprovals(path) {
|
|
108
|
+
const p = path ?? getWriteAuthSessionPath();
|
|
109
|
+
try {
|
|
110
|
+
rmSync(p, { force: true });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Best-effort: a failed unlink should never crash logout.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Whether an approval is past its usable life (with a small skew buffer). */
|
|
117
|
+
export function isApprovalExpired(approval, nowMs = Date.now(), skewMs = 10_000) {
|
|
118
|
+
return nowMs + skewMs >= approval.expires_at;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Return the cached approval matching ALL of `(apiOrigin, cpSessionHash,
|
|
122
|
+
* capability, target)` and still live, or `null` if none matches or it is
|
|
123
|
+
* expired. This is the exact-match the gateway's target gate requires — a
|
|
124
|
+
* non-match (wrong action/target/origin/session) fails closed.
|
|
125
|
+
*/
|
|
126
|
+
export function loadLiveApproval(q, path, nowMs = Date.now()) {
|
|
127
|
+
for (const a of readApprovals(path)) {
|
|
128
|
+
if (a.api_origin === q.apiOrigin &&
|
|
129
|
+
a.control_plane_session_hash === q.cpSessionHash &&
|
|
130
|
+
a.action === q.capability &&
|
|
131
|
+
sameTarget(a, q.target) &&
|
|
132
|
+
!isApprovalExpired(a, nowMs)) {
|
|
133
|
+
return a;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** Parse a gateway-returned session expiry (ISO string or epoch) to epoch ms. */
|
|
139
|
+
function sessionExpiryMs(session, nowMs) {
|
|
140
|
+
const raw = session?.expires_at ?? session?.absolute_expires_at;
|
|
141
|
+
if (typeof raw === "number" && Number.isFinite(raw))
|
|
142
|
+
return raw > 1e12 ? raw : raw * 1000;
|
|
143
|
+
if (typeof raw === "string") {
|
|
144
|
+
const ms = Date.parse(raw);
|
|
145
|
+
if (Number.isFinite(ms))
|
|
146
|
+
return ms;
|
|
147
|
+
}
|
|
148
|
+
return nowMs + DEFAULT_TTL_MS;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build a cache entry from the gateway token response + the binding context
|
|
152
|
+
* (the cp-session it was minted under, the API origin, and the `(action,
|
|
153
|
+
* target)` it covers). Expiry is taken from the returned `session`.
|
|
154
|
+
*/
|
|
155
|
+
export function approvalFromTokenResponse(resp, binding, nowMs = Date.now()) {
|
|
156
|
+
const amr = Array.isArray(resp.session?.amr)
|
|
157
|
+
? resp.session.amr.filter((a) => typeof a === "string")
|
|
158
|
+
: undefined;
|
|
159
|
+
return {
|
|
160
|
+
write_auth_token: resp.write_auth_token,
|
|
161
|
+
token_type: typeof resp.token_type === "string" ? resp.token_type : "write_auth",
|
|
162
|
+
header: typeof resp.header === "string" ? resp.header : "X-Run402-Write-Auth",
|
|
163
|
+
action: binding.action,
|
|
164
|
+
...(binding.target.org_id ? { org_id: binding.target.org_id } : {}),
|
|
165
|
+
...(binding.target.project_id ? { project_id: binding.target.project_id } : {}),
|
|
166
|
+
expires_at: sessionExpiryMs(resp.session, nowMs),
|
|
167
|
+
control_plane_session_hash: binding.controlPlaneSessionHash,
|
|
168
|
+
control_plane_principal_id: binding.controlPlanePrincipalId,
|
|
169
|
+
api_origin: binding.apiOrigin,
|
|
170
|
+
...(amr ? { amr } : {}),
|
|
171
|
+
minted_at: nowMs,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=write-auth-session.js.map
|
package/lib/deploy-v2.mjs
CHANGED
|
@@ -34,6 +34,8 @@ import { getSdk } from "./sdk.mjs";
|
|
|
34
34
|
import { reportSdkError, fail } from "./sdk-errors.mjs";
|
|
35
35
|
import { API, allowanceAuthHeaders, getActiveProjectId, resolveProjectId } from "./config.mjs";
|
|
36
36
|
import { normalizeArgv } from "./argparse.mjs";
|
|
37
|
+
import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
|
|
38
|
+
import { withAutoApprove } from "./operator.mjs";
|
|
37
39
|
|
|
38
40
|
const APPLY_HELP = `run402 deploy apply — Unified deploy primitive (v1.34+)
|
|
39
41
|
|
|
@@ -810,18 +812,21 @@ async function applyCmd(args) {
|
|
|
810
812
|
credentials: githubActionsCredentials({ projectId: releaseSpec.project, apiBase: API }),
|
|
811
813
|
disablePaidFetch: true,
|
|
812
814
|
};
|
|
813
|
-
} else {
|
|
814
|
-
//
|
|
815
|
+
} else if (!loadLiveControlPlaneSession()) {
|
|
816
|
+
// Aggressive early exit when no allowance is configured — unless a
|
|
817
|
+
// wallet-less human is deploying via their operator (control-plane) session.
|
|
815
818
|
allowanceAuthHeaders("/apply/v1/plans");
|
|
816
819
|
}
|
|
817
820
|
|
|
818
821
|
try {
|
|
819
|
-
const result = await
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
822
|
+
const result = await withAutoApprove(() =>
|
|
823
|
+
getSdk(sdkOpts)._applyEngine.apply(releaseSpec, {
|
|
824
|
+
onEvent: makeStderrEventWriter(opts.quiet),
|
|
825
|
+
idempotencyKey,
|
|
826
|
+
allowWarnings: opts.allowWarnings,
|
|
827
|
+
allowWarningCodes: opts.allowWarningCodes,
|
|
828
|
+
}),
|
|
829
|
+
);
|
|
825
830
|
console.log(JSON.stringify(result, null, 2));
|
|
826
831
|
} catch (err) {
|
|
827
832
|
reportDeployApplyError(err, useGithubActionsOidc);
|
package/lib/functions.mjs
CHANGED
|
@@ -87,12 +87,12 @@ Notes:
|
|
|
87
87
|
export default async (req: Request) => Response
|
|
88
88
|
Deploy may require payment if the project lease has expired.
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
Deploy routes through the unified apply engine (functions.patch.set), not
|
|
91
|
+
a legacy admin route. The result sets runtime_version and deps_resolved to
|
|
92
|
+
null (apply returns release-level data, not per-function build metadata) -
|
|
93
|
+
read the resolved versions from 'run402 functions list', whose records
|
|
94
|
+
carry both. The result still includes an optional top-level warnings:
|
|
95
|
+
string[] for non-fatal notes such as bundle-size advisories.
|
|
96
96
|
|
|
97
97
|
Examples:
|
|
98
98
|
run402 functions deploy prj_abc123 stripe-webhook --file handler.ts
|
package/lib/operator.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { createServer } from "node:http";
|
|
|
23
23
|
import { randomBytes, createHash } from "node:crypto";
|
|
24
24
|
import { fail, reportSdkError } from "./sdk-errors.mjs";
|
|
25
25
|
import { getSdk } from "./sdk.mjs";
|
|
26
|
-
import { claimWalletOrg, isStepUpRequired } from "#sdk/node";
|
|
26
|
+
import { claimWalletOrg, isStepUpRequired, isOperatorApprovalRequired } from "#sdk/node";
|
|
27
27
|
import { normalizeArgv, hasHelp, assertKnownFlags, flagValue } from "./argparse.mjs";
|
|
28
28
|
import {
|
|
29
29
|
saveOperatorSession,
|
|
@@ -38,8 +38,33 @@ import {
|
|
|
38
38
|
clearControlPlaneSession,
|
|
39
39
|
readControlPlaneSession,
|
|
40
40
|
isControlPlaneSessionExpired,
|
|
41
|
+
loadLiveControlPlaneSession,
|
|
41
42
|
controlPlaneSessionFromTokenResponse,
|
|
42
43
|
} from "../core-dist/control-plane-session.js";
|
|
44
|
+
import {
|
|
45
|
+
saveApproval,
|
|
46
|
+
clearApprovals,
|
|
47
|
+
readApprovals,
|
|
48
|
+
hashControlPlaneSession,
|
|
49
|
+
approvalFromTokenResponse,
|
|
50
|
+
} from "../core-dist/write-auth-session.js";
|
|
51
|
+
import { getApiBase } from "../core-dist/config.js";
|
|
52
|
+
|
|
53
|
+
/** Gateway write-auth capabilities → required target scope. */
|
|
54
|
+
const APPROVAL_ACTIONS = {
|
|
55
|
+
"org.project.create": "org",
|
|
56
|
+
"project.deploy": "project",
|
|
57
|
+
"project.secret.write": "project",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** True when `url` is same-origin as the API base (confirm_url validation). */
|
|
61
|
+
function sameApiOrigin(url) {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(url).origin === new URL(getApiBase()).origin;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
43
68
|
|
|
44
69
|
const CLIENT_NAME = "run402 CLI";
|
|
45
70
|
|
|
@@ -57,6 +82,8 @@ Usage:
|
|
|
57
82
|
run402 operator whoami
|
|
58
83
|
run402 operator logout
|
|
59
84
|
run402 operator claim-wallet-org [--org <id>] [--name <label>]
|
|
85
|
+
run402 operator approve --action <cap> (--org <id> | --project <id>) [--no-open]
|
|
86
|
+
run402 operator status
|
|
60
87
|
|
|
61
88
|
Subcommands:
|
|
62
89
|
login Sign in via the browser. Default = device-flow READ session (powers
|
|
@@ -68,6 +95,12 @@ Subcommands:
|
|
|
68
95
|
claim-wallet-org Claim an org owned by your wallet's agent into your human identity
|
|
69
96
|
(ownership transfer). Dual proof: your write-capable session
|
|
70
97
|
('login --loopback' first) + a fresh signature from the active wallet.
|
|
98
|
+
approve Mint a passkey operator-approval scoped to one (action, target) so a
|
|
99
|
+
wallet-less human can provision/deploy. --action is one of
|
|
100
|
+
org.project.create (--org), project.deploy (--project),
|
|
101
|
+
project.secret.write (--project). Requires 'login --loopback' first.
|
|
102
|
+
status Show the control-plane login state + each cached approval (action,
|
|
103
|
+
target, expiry). Local, no network.
|
|
71
104
|
|
|
72
105
|
Options:
|
|
73
106
|
--no-open (login) Do not auto-open the browser; just print the URL.
|
|
@@ -275,6 +308,8 @@ async function loopbackLogin(args, { stepUp }) {
|
|
|
275
308
|
close();
|
|
276
309
|
|
|
277
310
|
const cached = controlPlaneSessionFromTokenResponse(session);
|
|
311
|
+
// A fresh control-plane session invalidates any approvals bound to the old one.
|
|
312
|
+
clearApprovals();
|
|
278
313
|
saveControlPlaneSession(cached);
|
|
279
314
|
process.stderr.write(`\nSigned in (write-capable, provenance=${cached.provenance}).\n`);
|
|
280
315
|
|
|
@@ -394,6 +429,7 @@ async function logout(args) {
|
|
|
394
429
|
}
|
|
395
430
|
clearOperatorSession();
|
|
396
431
|
clearControlPlaneSession();
|
|
432
|
+
clearApprovals();
|
|
397
433
|
console.log(JSON.stringify({ revoked, cleared: true }));
|
|
398
434
|
}
|
|
399
435
|
|
|
@@ -510,12 +546,203 @@ async function claimWalletOrgCmd(args) {
|
|
|
510
546
|
}
|
|
511
547
|
}
|
|
512
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Run the scoped operator-approval ceremony (loopback + passkey) and cache the
|
|
551
|
+
* minted approval. Requires a live control-plane session. Returns the cached
|
|
552
|
+
* approval (or exits non-zero via fail/reportSdkError). Reused by the
|
|
553
|
+
* `operator approve` verb AND the TTY auto-approve path in provision/deploy.
|
|
554
|
+
*/
|
|
555
|
+
export async function mintApproval({ action, orgId, projectId, noOpen = false }) {
|
|
556
|
+
const cp = loadLiveControlPlaneSession();
|
|
557
|
+
if (!cp) {
|
|
558
|
+
return fail({
|
|
559
|
+
code: "OPERATOR_LOGIN_REQUIRED",
|
|
560
|
+
message: "No control-plane session. Run 'run402 operator login --loopback' first, then approve.",
|
|
561
|
+
hint: "operator approval is minted on top of your write-capable control-plane session.",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const sdk = getSdk();
|
|
565
|
+
const { codeVerifier, codeChallenge, state } = pkce();
|
|
566
|
+
const { ready, codePromise, close } = startLoopbackServer({ expectedState: state, timeoutMs: 300_000 });
|
|
567
|
+
|
|
568
|
+
let port;
|
|
569
|
+
try {
|
|
570
|
+
port = await ready;
|
|
571
|
+
} catch (err) {
|
|
572
|
+
close();
|
|
573
|
+
return fail({ code: "OPERATOR_APPROVE_FAILED", message: `Could not start the loopback server: ${err.message}` });
|
|
574
|
+
}
|
|
575
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
576
|
+
|
|
577
|
+
let challenge;
|
|
578
|
+
try {
|
|
579
|
+
challenge = await sdk.operator.approval.requestChallenge({
|
|
580
|
+
action,
|
|
581
|
+
orgId: orgId ?? undefined,
|
|
582
|
+
projectId: projectId ?? undefined,
|
|
583
|
+
cliRedirectUri: redirectUri,
|
|
584
|
+
codeChallenge,
|
|
585
|
+
state,
|
|
586
|
+
token: cp.control_plane_session_token,
|
|
587
|
+
});
|
|
588
|
+
} catch (err) {
|
|
589
|
+
close();
|
|
590
|
+
return reportSdkError(err);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const confirmUrl = challenge?.confirm_url;
|
|
594
|
+
if (!confirmUrl || !sameApiOrigin(confirmUrl)) {
|
|
595
|
+
close();
|
|
596
|
+
return fail({
|
|
597
|
+
code: "OPERATOR_APPROVE_BAD_CONFIRM_URL",
|
|
598
|
+
message: "The gateway returned a confirm_url that is not same-origin as the API base; aborting.",
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const targetLabel = orgId ? `org ${orgId}` : projectId ? `project ${projectId}` : "(no target)";
|
|
602
|
+
process.stderr.write(
|
|
603
|
+
`\nTo approve '${action}' for ${targetLabel}, open and approve with your passkey:\n ${confirmUrl}\n\n`,
|
|
604
|
+
);
|
|
605
|
+
if (!noOpen && process.stderr.isTTY) {
|
|
606
|
+
openBrowser(confirmUrl);
|
|
607
|
+
process.stderr.write("(opening your browser…)\n\n");
|
|
608
|
+
}
|
|
609
|
+
process.stderr.write("Waiting for passkey approval…\n");
|
|
610
|
+
|
|
611
|
+
let code;
|
|
612
|
+
try {
|
|
613
|
+
code = await codePromise;
|
|
614
|
+
} catch (err) {
|
|
615
|
+
close();
|
|
616
|
+
return fail({ code: "OPERATOR_APPROVE_FAILED", message: err.message, hint: "Run 'run402 operator approve' to try again." });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let token;
|
|
620
|
+
try {
|
|
621
|
+
token = await sdk.operator.approval.exchangeClaimCode({ code, codeVerifier, state });
|
|
622
|
+
} catch (err) {
|
|
623
|
+
close();
|
|
624
|
+
return reportSdkError(err);
|
|
625
|
+
}
|
|
626
|
+
close();
|
|
627
|
+
|
|
628
|
+
const approval = approvalFromTokenResponse(token, {
|
|
629
|
+
action,
|
|
630
|
+
target: { org_id: orgId ?? undefined, project_id: projectId ?? undefined },
|
|
631
|
+
apiOrigin: new URL(getApiBase()).origin,
|
|
632
|
+
controlPlaneSessionHash: hashControlPlaneSession(cp.control_plane_session_token),
|
|
633
|
+
controlPlanePrincipalId: cp.principal_id || "",
|
|
634
|
+
});
|
|
635
|
+
saveApproval(approval);
|
|
636
|
+
process.stderr.write(`\nApproved '${action}' for ${targetLabel}.\n`);
|
|
637
|
+
return approval;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Run a write action; on `OperatorApprovalRequiredError` AND an interactive
|
|
642
|
+
* TTY (CLI), run the scoped approval ceremony and retry once. In MCP / CI /
|
|
643
|
+
* non-TTY the typed error is rethrown unchanged — the agent relays the resolved
|
|
644
|
+
* `operator approve …` command rather than a browser opening unexpectedly.
|
|
645
|
+
*/
|
|
646
|
+
export async function withAutoApprove(fn) {
|
|
647
|
+
try {
|
|
648
|
+
return await fn();
|
|
649
|
+
} catch (err) {
|
|
650
|
+
if (!isOperatorApprovalRequired(err) || !process.stderr.isTTY) throw err;
|
|
651
|
+
const action = err.capability;
|
|
652
|
+
const target = err.target || {};
|
|
653
|
+
if (!action) throw err;
|
|
654
|
+
process.stderr.write(`\nThis '${action}' needs a one-time operator approval. Opening the browser to approve…\n`);
|
|
655
|
+
await mintApproval({ action, orgId: target.org_id ?? undefined, projectId: target.project_id ?? undefined });
|
|
656
|
+
return await fn(); // retry once with the now-cached approval
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* `operator approve --action <cap> (--org <id> | --project <id>)` — mint a
|
|
662
|
+
* passkey approval scoped to one (action, target). Hidden alias: `write-auth`.
|
|
663
|
+
*/
|
|
664
|
+
async function approve(args) {
|
|
665
|
+
assertKnownFlags(
|
|
666
|
+
args,
|
|
667
|
+
["--help", "-h", "--no-open", "--action", "--org", "--project"],
|
|
668
|
+
["--action", "--org", "--project"],
|
|
669
|
+
);
|
|
670
|
+
const action = flagValue(args, "--action");
|
|
671
|
+
const org = flagValue(args, "--org");
|
|
672
|
+
const project = flagValue(args, "--project");
|
|
673
|
+
|
|
674
|
+
if (!action || !APPROVAL_ACTIONS[action]) {
|
|
675
|
+
fail({
|
|
676
|
+
code: "BAD_FLAG",
|
|
677
|
+
message: `--action must be one of: ${Object.keys(APPROVAL_ACTIONS).join(", ")}`,
|
|
678
|
+
details: { flag: "--action", value: action, allowed: Object.keys(APPROVAL_ACTIONS) },
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const scope = APPROVAL_ACTIONS[action];
|
|
682
|
+
if (scope === "org" && !org) fail({ code: "BAD_FLAG", message: `--org <id> is required for --action ${action}` });
|
|
683
|
+
if (scope === "project" && !project) fail({ code: "BAD_FLAG", message: `--project <id> is required for --action ${action}` });
|
|
684
|
+
if (scope === "org" && project) fail({ code: "BAD_FLAG", message: `--project is not valid for org-scoped --action ${action}; use --org` });
|
|
685
|
+
if (scope === "project" && org) fail({ code: "BAD_FLAG", message: `--org is not valid for project-scoped --action ${action}; use --project` });
|
|
686
|
+
|
|
687
|
+
const approval = await mintApproval({
|
|
688
|
+
action,
|
|
689
|
+
orgId: org ?? undefined,
|
|
690
|
+
projectId: project ?? undefined,
|
|
691
|
+
noOpen: args.includes("--no-open"),
|
|
692
|
+
});
|
|
693
|
+
console.log(
|
|
694
|
+
JSON.stringify({
|
|
695
|
+
approved: true,
|
|
696
|
+
action,
|
|
697
|
+
...(org ? { org_id: org } : {}),
|
|
698
|
+
...(project ? { project_id: project } : {}),
|
|
699
|
+
expires_at: new Date(approval.expires_at).toISOString(),
|
|
700
|
+
}),
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** `operator status` — operator-login state + each cached approval's (action, target, expiry). */
|
|
705
|
+
async function status(args) {
|
|
706
|
+
assertKnownFlags(args, ["--help", "-h"]);
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
const cp = readControlPlaneSession();
|
|
709
|
+
const liveCp = cp && !isControlPlaneSessionExpired(cp, now) ? cp : null;
|
|
710
|
+
const approvals = readApprovals()
|
|
711
|
+
.filter((a) => a.expires_at > now)
|
|
712
|
+
.map((a) => ({
|
|
713
|
+
action: a.action,
|
|
714
|
+
org_id: a.org_id ?? null,
|
|
715
|
+
project_id: a.project_id ?? null,
|
|
716
|
+
expires_at: new Date(a.expires_at).toISOString(),
|
|
717
|
+
}));
|
|
718
|
+
const out = {
|
|
719
|
+
operator_login: liveCp
|
|
720
|
+
? { active: true, provenance: liveCp.provenance, amr: liveCp.amr, expires_at: new Date(liveCp.expires_at).toISOString() }
|
|
721
|
+
: { active: false },
|
|
722
|
+
approvals,
|
|
723
|
+
};
|
|
724
|
+
if (liveCp) {
|
|
725
|
+
process.stderr.write(`Operator login: active (${liveCp.provenance})\n`);
|
|
726
|
+
process.stderr.write(
|
|
727
|
+
approvals.length
|
|
728
|
+
? `Approvals (${approvals.length}):\n` +
|
|
729
|
+
approvals.map((a) => ` - ${a.action} ${a.org_id || a.project_id || ""} (expires ${a.expires_at})`).join("\n") +
|
|
730
|
+
"\n"
|
|
731
|
+
: "Approvals: none. Run 'run402 operator approve --action <cap> --org|--project <id>'.\n",
|
|
732
|
+
);
|
|
733
|
+
} else {
|
|
734
|
+
process.stderr.write("Operator login: none. Run 'run402 operator login --loopback'.\n");
|
|
735
|
+
}
|
|
736
|
+
console.log(JSON.stringify(out, null, 2));
|
|
737
|
+
}
|
|
738
|
+
|
|
513
739
|
export async function run(sub, args = []) {
|
|
514
740
|
args = normalizeArgv(args);
|
|
515
741
|
if (!sub || sub === "--help" || sub === "-h" || hasHelp(args)) {
|
|
516
742
|
console.log(HELP);
|
|
517
743
|
process.exit(0);
|
|
518
744
|
}
|
|
745
|
+
if (sub === "write-auth") sub = "approve"; // hidden alias (transport name)
|
|
519
746
|
switch (sub) {
|
|
520
747
|
case "login":
|
|
521
748
|
await login(args);
|
|
@@ -532,6 +759,12 @@ export async function run(sub, args = []) {
|
|
|
532
759
|
case "claim-wallet-org":
|
|
533
760
|
await claimWalletOrgCmd(args);
|
|
534
761
|
break;
|
|
762
|
+
case "approve":
|
|
763
|
+
await approve(args);
|
|
764
|
+
break;
|
|
765
|
+
case "status":
|
|
766
|
+
await status(args);
|
|
767
|
+
break;
|
|
535
768
|
default:
|
|
536
769
|
fail({
|
|
537
770
|
code: "BAD_USAGE",
|
package/lib/projects.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { loadKeyStore, API, allowanceAuthHeaders, resolveProjectId, getActiveProjectId } from "./config.mjs";
|
|
3
3
|
import { loadLiveOperatorSession } from "../core-dist/operator-session.js";
|
|
4
|
+
import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
|
|
5
|
+
import { withAutoApprove } from "./operator.mjs";
|
|
4
6
|
import { getSdk } from "./sdk.mjs";
|
|
5
7
|
import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
|
|
6
8
|
import { assertKnownFlags, failBadProjectId, flagValue, hasHelp, normalizeArgv, positionalArgs, resolvePositionalProject, validateRegularFile } from "./argparse.mjs";
|
|
@@ -302,13 +304,16 @@ async function provision(args) {
|
|
|
302
304
|
});
|
|
303
305
|
}
|
|
304
306
|
}
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
307
|
+
// Aggressive early exit when no agent allowance is configured — but only when
|
|
308
|
+
// there's also no operator (control-plane) session, since a wallet-less human
|
|
309
|
+
// provisions into an org via their operator approval instead of a wallet.
|
|
310
|
+
if (!loadLiveControlPlaneSession()) allowanceAuthHeaders("/projects/v1");
|
|
308
311
|
|
|
309
312
|
const activeBefore = getActiveProjectId();
|
|
310
313
|
try {
|
|
311
|
-
const data = await
|
|
314
|
+
const data = await withAutoApprove(() =>
|
|
315
|
+
getSdk().projects.provision({ tier: opts.tier, name: opts.name, orgId: opts.orgId }),
|
|
316
|
+
);
|
|
312
317
|
const activeAfter = getActiveProjectId();
|
|
313
318
|
const out = { ...data };
|
|
314
319
|
if (activeBefore && activeAfter && activeBefore !== activeAfter) {
|
package/lib/sdk.mjs
CHANGED
|
@@ -10,5 +10,7 @@
|
|
|
10
10
|
import { run402 } from "#sdk/node";
|
|
11
11
|
|
|
12
12
|
export function getSdk(opts = {}) {
|
|
13
|
-
|
|
13
|
+
// surface: "cli" opts the default credential resolution into `auto` — wallet
|
|
14
|
+
// if present, else the operator (control-plane) session + matched approval.
|
|
15
|
+
return run402({ surface: "cli", ...opts });
|
|
14
16
|
}
|
package/package.json
CHANGED