metergraph-cli 0.2.1 → 0.2.2
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/README.md +12 -7
- package/assets/skill/SKILL.md +52 -3
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +13 -13
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/package.json +1 -1
- package/src/agent-key.js +157 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +373 -14
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +20 -3
- package/src/callback-page.js +58 -0
- package/src/cli.js +94 -37
- package/src/constants.js +32 -3
- package/src/environment.js +169 -0
- package/src/origin.js +6 -0
- package/src/output.js +325 -130
- package/src/read-contract.js +953 -13
- package/src/read-output.js +449 -30
- package/src/read.js +297 -59
- package/src/setup-deployment.js +4 -3
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +8 -4
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// What the --json --no-browser background waiter is doing with an approval
|
|
6
|
+
// it received, so the agent's rerun can report it instead of asking the
|
|
7
|
+
// person for another approval. The waiter is the only writer: a foreground
|
|
8
|
+
// run reports a refusal directly and never records one.
|
|
9
|
+
//
|
|
10
|
+
// redeeming the waiter holds an approval and is redeeming it (possibly
|
|
11
|
+
// waiting out a short Retry-After). A rerun waits for it.
|
|
12
|
+
// refused the service refused the redemption: the rate budget until
|
|
13
|
+
// retry_at, or the workspace's key cap. A rerun reports it.
|
|
14
|
+
//
|
|
15
|
+
// The record names the setup family, the waiter's process and the approval
|
|
16
|
+
// it came from. It holds no credential. It is a separate file, not a field of
|
|
17
|
+
// setup.json, so an older CLI that validates setup.json strictly keeps
|
|
18
|
+
// reading the project. Any read problem is treated as no record.
|
|
19
|
+
//
|
|
20
|
+
// The file lives in the project, so its content is untrusted: a record can
|
|
21
|
+
// only delay setup for as long as this CLI would ever write it for. A
|
|
22
|
+
// redeeming record expiring, or a refusal retrying, further out than that is
|
|
23
|
+
// ignored and removed, so a crafted or committed record cannot block setup.
|
|
24
|
+
const FILE = "setup-refusal.json";
|
|
25
|
+
const MAX_BYTES = 1024;
|
|
26
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
27
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
28
|
+
const KEYS = "approval_id,expires_at,family_id,pid,reason,retry_at,schema_version,state";
|
|
29
|
+
export const REFUSAL_REASONS = Object.freeze(["setup_rate_limited", "setup_key_limit"]);
|
|
30
|
+
// A redeeming record outlives no waiter: redemption plus two Retry-After
|
|
31
|
+
// waits takes well under this. A rate refusal is reported for at most this.
|
|
32
|
+
const MAX_RECORD_MS = 10 * 60 * 1000;
|
|
33
|
+
const MAX_RATE_WAIT_MS = 5 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
const fileFor = (root) => path.join(root, ".metergraph", FILE);
|
|
36
|
+
const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
37
|
+
|
|
38
|
+
function valid(value) {
|
|
39
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) ||
|
|
40
|
+
Object.keys(value).sort().join() !== KEYS || value.schema_version !== 2 ||
|
|
41
|
+
!UUID.test(value.family_id) || !UUID.test(value.approval_id) ||
|
|
42
|
+
!Number.isInteger(value.pid) || value.pid < 1 || !isTime(value.expires_at)) return false;
|
|
43
|
+
if (value.state === "redeeming") return value.reason === null && value.retry_at === null;
|
|
44
|
+
if (value.state === "refused") {
|
|
45
|
+
return REFUSAL_REASONS.includes(value.reason) &&
|
|
46
|
+
(value.reason === "setup_key_limit" ? value.retry_at === null : isTime(value.retry_at));
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function read(root) {
|
|
52
|
+
const file = fileFor(root);
|
|
53
|
+
let fd;
|
|
54
|
+
try {
|
|
55
|
+
const stat = fs.lstatSync(file);
|
|
56
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_BYTES) return null;
|
|
57
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
58
|
+
const opened = fs.fstatSync(fd);
|
|
59
|
+
if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size > MAX_BYTES) return null;
|
|
60
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(fd)));
|
|
61
|
+
return valid(value) ? value : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
} finally {
|
|
65
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Best effort: a record that cannot be written only means a rerun asks for a
|
|
70
|
+
// new approval, as it did before records existed.
|
|
71
|
+
function write(root, value) {
|
|
72
|
+
if (!valid(value)) return false;
|
|
73
|
+
const dir = path.join(root, ".metergraph");
|
|
74
|
+
const temp = path.join(dir, `.refusal.${randomBytes(8).toString("hex")}.tmp`);
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.lstatSync(dir).isDirectory()) return false;
|
|
77
|
+
const fd = fs.openSync(temp, "wx", 0o600);
|
|
78
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(value)}\n`); }
|
|
79
|
+
finally { fs.closeSync(fd); }
|
|
80
|
+
fs.renameSync(temp, fileFor(root));
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
try { fs.unlinkSync(temp); } catch { /* no temp */ }
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clearSetupRefusal(root) {
|
|
89
|
+
try {
|
|
90
|
+
const stat = fs.lstatSync(fileFor(root));
|
|
91
|
+
if (stat.isFile() || stat.isSymbolicLink()) fs.unlinkSync(fileFor(root));
|
|
92
|
+
} catch { /* nothing to clear */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function alive(pid) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return error.code === "EPERM";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The waiter's handle on one approval it received. Every method is best
|
|
105
|
+
// effort and only ever touches this approval's own record.
|
|
106
|
+
export function waiterRecord(root, familyId, now = Date.now) {
|
|
107
|
+
const approvalId = randomUUID();
|
|
108
|
+
const base = () => ({ schema_version: 2, family_id: familyId, approval_id: approvalId, pid: process.pid,
|
|
109
|
+
expires_at: new Date(now() + MAX_RECORD_MS).toISOString() });
|
|
110
|
+
return {
|
|
111
|
+
// Early, as soon as an approval arrives: never replaces another waiter
|
|
112
|
+
// that is still alive. With force, once this waiter has won the setup
|
|
113
|
+
// state for its redemption, it takes the record over.
|
|
114
|
+
redeeming: ({ force = false } = {}) => {
|
|
115
|
+
const current = read(root);
|
|
116
|
+
if (!force && current?.state === "redeeming" && current.approval_id !== approvalId &&
|
|
117
|
+
current.pid !== process.pid && alive(current.pid) && Date.parse(current.expires_at) > now()) return false;
|
|
118
|
+
return write(root, { ...base(), state: "redeeming", reason: null, retry_at: null });
|
|
119
|
+
},
|
|
120
|
+
refused: (reason, retryAfterSeconds = null) => write(root, { ...base(), state: "refused", reason,
|
|
121
|
+
retry_at: reason === "setup_rate_limited"
|
|
122
|
+
? new Date(now() + Math.min((retryAfterSeconds ?? 60) * 1000, MAX_RATE_WAIT_MS)).toISOString()
|
|
123
|
+
: null }),
|
|
124
|
+
// Any other outcome: drop a redeeming record this waiter left behind.
|
|
125
|
+
settle: () => {
|
|
126
|
+
const record = read(root);
|
|
127
|
+
if (record?.approval_id === approvalId && record.state === "redeeming") clearSetupRefusal(root);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// What a run should report before asking for an approval, or null to
|
|
133
|
+
// proceed. familyId and delivered describe the saved setup state; a refusal
|
|
134
|
+
// for another family, or for one already delivered, no longer applies.
|
|
135
|
+
export function pendingRefusal(root, { familyId, delivered }, now = Date.now()) {
|
|
136
|
+
const record = read(root);
|
|
137
|
+
if (record === null) return null;
|
|
138
|
+
const expires = Date.parse(record.expires_at);
|
|
139
|
+
const retry = record.retry_at === null ? null : Date.parse(record.retry_at);
|
|
140
|
+
if (expires <= now || expires > now + MAX_RECORD_MS || (retry !== null && retry > now + MAX_RATE_WAIT_MS)) {
|
|
141
|
+
clearSetupRefusal(root);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
// A delivered family has nothing pending, whatever the record says.
|
|
145
|
+
if (delivered) {
|
|
146
|
+
clearSetupRefusal(root);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (record.state === "redeeming") {
|
|
150
|
+
if (record.pid === process.pid || !alive(record.pid)) {
|
|
151
|
+
clearSetupRefusal(root);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// A live waiter matters only for the saved family, or before any state
|
|
155
|
+
// is saved (a combined sign in writes it after the approval). A record
|
|
156
|
+
// for another family does not block this setup.
|
|
157
|
+
if (familyId !== null && record.family_id !== familyId) return null;
|
|
158
|
+
return { reason: "setup_in_progress", retryAfter: 5 };
|
|
159
|
+
}
|
|
160
|
+
if (record.family_id !== familyId) {
|
|
161
|
+
clearSetupRefusal(root);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (record.reason === "setup_rate_limited") {
|
|
165
|
+
const remaining = Math.ceil((Date.parse(record.retry_at) - now) / 1000);
|
|
166
|
+
if (remaining > 0) return { reason: record.reason, retryAfter: remaining };
|
|
167
|
+
clearSetupRefusal(root);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
clearSetupRefusal(root);
|
|
171
|
+
return { reason: record.reason, retryAfter: null };
|
|
172
|
+
}
|
package/src/setup-state.js
CHANGED
|
@@ -101,10 +101,12 @@ export function readSetupState(root) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
// familyId is given when the family was already named in an approval, as a
|
|
105
|
+
// combined sign-in and ingest approval does; otherwise a new one is chosen.
|
|
106
|
+
export function newSetupState(origin, workspaceId, profile, client, familyId = randomUUID()) {
|
|
105
107
|
return withSetupState({ schema_version: 1, origin, workspace_id: workspaceId,
|
|
106
108
|
deployment_profile: profile, selected_client: client, skill_status: client === null ? "skipped" : "pending",
|
|
107
|
-
family_id:
|
|
109
|
+
family_id: familyId, key_id: null, phase: "unsubmitted" });
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// Compare-and-swap under a project-local exclusive lock. A concurrent setup
|
package/src/setup.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { openBrowser } from "./auth-browser.js";
|
|
4
|
+
import { classifyEnvironment, remoteSetupAction } from "./environment.js";
|
|
2
5
|
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
6
|
import { startCallbackListener } from "./auth-callback.js";
|
|
4
7
|
import { openUrl, runLogin } from "./auth-login.js";
|
|
@@ -9,6 +12,9 @@ import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
|
9
12
|
import { preflightEnv, currentEnvValues, commitEnv, isAppToken } from "./setup-env.js";
|
|
10
13
|
import { newSetupState, readSetupState, setupReceipt, withSetupState, writeSetupState } from "./setup-state.js";
|
|
11
14
|
import { runSkill } from "./skill.js";
|
|
15
|
+
import { handoffPages } from "./environment.js";
|
|
16
|
+
import { REFUSAL_REASONS, clearSetupRefusal, pendingRefusal, waiterRecord } from "./setup-refusal.js";
|
|
17
|
+
import { isApprovalWaiter } from "./approval-handoff.js";
|
|
12
18
|
import { ensureRepositoryIdentity, envRepository } from "./repository-identity.js";
|
|
13
19
|
import { parseJsonObject } from "./http.js";
|
|
14
20
|
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
@@ -37,8 +43,20 @@ const fail = (outcome, reason, status = "not_ready", receipt = null, next = null
|
|
|
37
43
|
// keeps waiting; stdout has room for one line, so a second approval in the
|
|
38
44
|
// same run is not started. The person approves, the run saves what it
|
|
39
45
|
// finished, and the agent reruns setup, which resumes from the saved state.
|
|
46
|
+
//
|
|
47
|
+
// A first setup that has to sign in asks the sign-in consent to approve the
|
|
48
|
+
// first ingest key as well, when the service advertises it, so the person
|
|
49
|
+
// approves once. The sign-in grant and the ingest key stay separate
|
|
50
|
+
// credentials: the receipt is redeemed here with its own PKCE verifier,
|
|
51
|
+
// exactly as one from the ingest-only approval is. A service without that
|
|
52
|
+
// support, a repair, a replacement or a project already signed in keeps the
|
|
53
|
+
// separate ingest-only approval.
|
|
40
54
|
export async function runSetup(options, progress = () => {}) {
|
|
41
|
-
|
|
55
|
+
const environment = options.environment ?? classifyEnvironment();
|
|
56
|
+
if (options.runtime !== "local" || environment.remote) {
|
|
57
|
+
const where = options.runtime !== "local" ? { kind: "declared_cloud" } : environment;
|
|
58
|
+
return fail("unsupported", "run_on_local_machine", "not_ready", null, remoteSetupAction(where, options.origin, options));
|
|
59
|
+
}
|
|
42
60
|
const trap = trapSignals();
|
|
43
61
|
let receipt = null;
|
|
44
62
|
let loginVerified = false;
|
|
@@ -54,8 +72,14 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
54
72
|
}
|
|
55
73
|
return current;
|
|
56
74
|
};
|
|
57
|
-
const stop = (outcome, reason, status = "not_ready"
|
|
75
|
+
const stop = (outcome, reason, status = "not_ready", next = null) =>
|
|
76
|
+
fail(outcome, reason, status, currentReceipt(), next);
|
|
58
77
|
let announced = false;
|
|
78
|
+
// Only the --json --no-browser background waiter records what it does with
|
|
79
|
+
// an approval; a foreground run reports directly.
|
|
80
|
+
const waiter = Boolean(options.announce) && isApprovalWaiter();
|
|
81
|
+
let record = null;
|
|
82
|
+
let refusalRecorded = false;
|
|
59
83
|
const announce = options.announce ? (status, next) => {
|
|
60
84
|
announced = true;
|
|
61
85
|
options.announce(fail("action_required", "browser_approval_required", status, currentReceipt(), next).data);
|
|
@@ -73,6 +97,18 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
73
97
|
(bound !== null && (bound.origin !== saved.value.origin || bound.workspace_id !== saved.value.workspace_id)))) {
|
|
74
98
|
return stop("conflict", "setup_binding_changed");
|
|
75
99
|
}
|
|
100
|
+
// What an earlier --json --no-browser waiter is doing with an approval it
|
|
101
|
+
// received: still redeeming it, or refused by the service's budget. The
|
|
102
|
+
// rerun reports that instead of asking the person for another approval.
|
|
103
|
+
// It runs after the binding guard, so a rerun aimed at another deployment
|
|
104
|
+
// or workspace is refused as such and leaves the record for a correct
|
|
105
|
+
// rerun; the next action names the saved family's own deployment.
|
|
106
|
+
const pending = pendingRefusal(root, { familyId: saved?.value.family_id ?? null,
|
|
107
|
+
delivered: saved?.value.phase === "delivered" });
|
|
108
|
+
if (pending !== null) {
|
|
109
|
+
return stop(pending.reason === "setup_rate_limited" ? "rate_limited" : "conflict", pending.reason,
|
|
110
|
+
"delivery_pending", redeemNext(pending, saved?.value.origin ?? intendedOrigin));
|
|
111
|
+
}
|
|
76
112
|
// An env key this setup does not own stops before any sign in, so the
|
|
77
113
|
// refusal costs no approval and leaves the binding untouched. The check
|
|
78
114
|
// after sign in stays, against a fresh read.
|
|
@@ -85,11 +121,27 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
85
121
|
if (bindingDiffers && !options.reconnect) return stop("conflict", "bound_to_other_workspace");
|
|
86
122
|
let signedIn = bindingDiffers || options.reconnect ? { ok: false, outcome: "login_required" } :
|
|
87
123
|
await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
124
|
+
// An approved, unredeemed receipt for a new family, from a combined sign in.
|
|
125
|
+
let combined = null;
|
|
88
126
|
if (!signedIn.ok && signedIn.outcome === "login_required") {
|
|
127
|
+
const familyId = randomUUID();
|
|
128
|
+
const ingestSetup = saved || options.repair ? null : { familyId,
|
|
129
|
+
// In the waiter, record the approval before anything else happens,
|
|
130
|
+
// so an immediate rerun waits instead of asking for another one.
|
|
131
|
+
onApproved: () => { if (waiter) record = waiterRecord(root, familyId); record?.redeeming(); },
|
|
132
|
+
supported: async (origin, profile, cancel) => {
|
|
133
|
+
const discovered = await discoverSetup(origin, profile, cancel);
|
|
134
|
+
return discovered.ok && discovered.loginSetup;
|
|
135
|
+
} };
|
|
89
136
|
const login = await runLogin({ ...options, origin: intendedOrigin, workspace: intendedWorkspace,
|
|
90
|
-
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null,
|
|
137
|
+
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null, ingestSetup,
|
|
91
138
|
announce: announce && ((data) => announce("login_pending", data.next_action)) }, progress);
|
|
92
|
-
|
|
139
|
+
// Login's own next action (for example no_browser with a Metadata key
|
|
140
|
+
// alternative in a local container) is passed through.
|
|
141
|
+
if (login.outcome !== "ok") return stop(login.outcome, login.reason, "login_pending", login.data?.next_action ?? null);
|
|
142
|
+
if (login.ingestSetup && login.ingestSetup.familyId === familyId && RECEIPT.test(login.ingestSetup.code)) {
|
|
143
|
+
combined = login.ingestSetup;
|
|
144
|
+
}
|
|
93
145
|
signedIn = await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
94
146
|
}
|
|
95
147
|
if (!signedIn.ok) return stop(signedIn.outcome, signedIn.reason, "login_pending");
|
|
@@ -159,15 +211,21 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
159
211
|
return stop("conflict", "repair_requires_saved_key");
|
|
160
212
|
}
|
|
161
213
|
|
|
214
|
+
// A combined sign in already approved a key for a new family. Anything
|
|
215
|
+
// that makes this anything but a first create drops the receipt unused;
|
|
216
|
+
// it expires without issuing a key.
|
|
217
|
+
if (previous !== null || options.repair) combined = null;
|
|
162
218
|
// A verified rerun returned above without approval. From here a person
|
|
163
|
-
// must open an approval URL
|
|
164
|
-
//
|
|
165
|
-
|
|
219
|
+
// must open an approval URL, unless the sign in approved the key too. A
|
|
220
|
+
// run that already announced its sign in URL has used its one JSON line,
|
|
221
|
+
// so it stops here and the rerun continues.
|
|
222
|
+
if (announced && combined === null) return stop("action_required", "browser_approval_required", "approval_pending");
|
|
166
223
|
const setup = await discoverSetup(origin, profile, trap.signal);
|
|
167
224
|
if (!setup.ok) return stop(setup.outcome, setup.reason);
|
|
168
225
|
|
|
169
226
|
if (previous === null) {
|
|
170
|
-
writeSetupState(root, newSetupState(origin, workspaceId, profile, options.client)
|
|
227
|
+
writeSetupState(root, combined === null ? newSetupState(origin, workspaceId, profile, options.client)
|
|
228
|
+
: newSetupState(origin, workspaceId, profile, options.client, combined.familyId), null);
|
|
171
229
|
previous = readSetupState(root);
|
|
172
230
|
receipt = setupReceipt(previous.value);
|
|
173
231
|
}
|
|
@@ -177,40 +235,19 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
177
235
|
// An acknowledged family is never implicitly replaced. Repair requires
|
|
178
236
|
// the explicit flag and the key ID saved from its original issuance.
|
|
179
237
|
if (intent === "replace_pending" && state.phase === "delivered") return stop("conflict", "repair_required");
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const url = new URL(`${origin}${SETUP_PATHS.authorize}`);
|
|
193
|
-
const params = {
|
|
194
|
-
client_id: clientId, redirect_uri: listener.redirectUri, state: pkce.state,
|
|
195
|
-
code_challenge: pkce.challenge, code_challenge_method: "S256",
|
|
196
|
-
workspace_id: workspaceId, family_id: state.family_id, intent,
|
|
197
|
-
};
|
|
198
|
-
if (intent === "repair") params.expected_key_id = state.key_id;
|
|
199
|
-
url.search = new URLSearchParams(params).toString();
|
|
200
|
-
if (trap.signal.aborted) return stop("authorization_failed", "cancelled");
|
|
201
|
-
if (options.noBrowser && announce) {
|
|
202
|
-
announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
|
|
203
|
-
} else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
|
|
204
|
-
else if (!(await openBrowser(url.href))) return stop("authorization_failed", "browser_unavailable");
|
|
205
|
-
else progress("Opened your browser for ingest-only setup approval.");
|
|
206
|
-
callback = await pending;
|
|
207
|
-
} finally {
|
|
208
|
-
await listener.close();
|
|
238
|
+
let approval;
|
|
239
|
+
if (combined !== null && intent === "create" && state.family_id === combined.familyId) {
|
|
240
|
+
approval = combined;
|
|
241
|
+
progress("Your sign-in approval also approved this project's ingest key.");
|
|
242
|
+
} else {
|
|
243
|
+
approval = await approveInBrowser({ origin, setup, workspaceId, state, intent, options, announce, progress,
|
|
244
|
+
cancel: trap.signal });
|
|
245
|
+
if (!approval.ok) return stop(approval.outcome, approval.reason);
|
|
246
|
+
if (waiter) {
|
|
247
|
+
record = waiterRecord(root, state.family_id);
|
|
248
|
+
record.redeeming();
|
|
249
|
+
}
|
|
209
250
|
}
|
|
210
|
-
if (callback.kind === "denied") return stop("authorization_failed", "access_denied");
|
|
211
|
-
if (callback.kind === "timeout") return stop("authorization_failed", "timeout");
|
|
212
|
-
if (callback.kind === "cancelled") return stop("authorization_failed", "cancelled");
|
|
213
|
-
if (callback.kind !== "code" || !RECEIPT.test(callback.code)) return stop("authorization_failed", "callback_invalid");
|
|
214
251
|
|
|
215
252
|
// Persist the ambiguity boundary before a single-use receipt is sent.
|
|
216
253
|
// If the response is lost, a later run asks the browser to replace only
|
|
@@ -220,11 +257,22 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
220
257
|
return stop("conflict", "setup_state_changed");
|
|
221
258
|
}
|
|
222
259
|
writeSetupState(root, withSetupState(previous.value, { phase: "redeem_attempted" }), previous);
|
|
260
|
+
// This waiter won the state for its redemption: the record is now its own,
|
|
261
|
+
// even if another waiter's approval arrived first and wrote it.
|
|
262
|
+
if (waiter) {
|
|
263
|
+
record ??= waiterRecord(root, state.family_id);
|
|
264
|
+
record.redeeming({ force: true });
|
|
265
|
+
}
|
|
223
266
|
previous = readSetupState(root);
|
|
224
|
-
const redeemed = await redeem(origin, { code:
|
|
225
|
-
redirect_uri:
|
|
226
|
-
family_id: state.family_id, workspace_id: workspaceId }, profile, trap.signal);
|
|
227
|
-
if (!redeemed.ok)
|
|
267
|
+
const redeemed = await redeem(origin, { code: approval.code, client_id: approval.clientId,
|
|
268
|
+
redirect_uri: approval.redirectUri, code_verifier: approval.verifier,
|
|
269
|
+
family_id: state.family_id, workspace_id: workspaceId }, profile, trap.signal, progress);
|
|
270
|
+
if (!redeemed.ok) {
|
|
271
|
+
if (record !== null && REFUSAL_REASONS.includes(redeemed.reason)) {
|
|
272
|
+
refusalRecorded = record.refused(redeemed.reason, redeemed.retryAfter ?? null);
|
|
273
|
+
}
|
|
274
|
+
return stop(redeemed.outcome, redeemed.reason, "delivery_pending", redeemNext(redeemed, origin));
|
|
275
|
+
}
|
|
228
276
|
const written = await commitEnv(plan, { token: redeemed.token, ingestUrl: origin, signal: trap.signal });
|
|
229
277
|
const checked = await checkCredential(origin, redeemed.token, state, profile, trap.signal);
|
|
230
278
|
if (!checked.ok || checked.keyId !== redeemed.keyId) return stop("verification_failed", "issued_key_unverified", "delivery_pending");
|
|
@@ -236,10 +284,59 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
236
284
|
} catch (error) {
|
|
237
285
|
if (error instanceof Stop) return stop(error.outcome, error.reason);
|
|
238
286
|
throw error;
|
|
239
|
-
} finally {
|
|
287
|
+
} finally {
|
|
288
|
+
if (record !== null && !refusalRecorded) record.settle();
|
|
289
|
+
trap.release();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The ingest-only approval at the setup authorization endpoint: the only
|
|
294
|
+
// browser step for a repair or replacement, and the second one for a service
|
|
295
|
+
// without the combined sign-in approval. Returns the receipt with the client,
|
|
296
|
+
// redirect and verifier it must be redeemed with, or a stop reason.
|
|
297
|
+
async function approveInBrowser({ origin, setup, workspaceId, state, intent, options, announce, progress, cancel }) {
|
|
298
|
+
const failed = (reason, outcome = "authorization_failed") => ({ ok: false, outcome, reason });
|
|
299
|
+
const listener = await startCallbackListener();
|
|
300
|
+
let callback;
|
|
301
|
+
let clientId;
|
|
302
|
+
let pkce;
|
|
303
|
+
try {
|
|
304
|
+
const client = await clientFor(endpointsFor(origin), setup.offeredClientId, listener.redirectUri,
|
|
305
|
+
deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal);
|
|
306
|
+
if (!client.ok) return failed(client.reason, client.outcome);
|
|
307
|
+
clientId = client.clientId;
|
|
308
|
+
pkce = newPkce();
|
|
309
|
+
const pending = listener.wait({ state: pkce.state, issuer: `${origin}/v1/oauth`, requireIss: false,
|
|
310
|
+
timeoutMs: options.timeoutMs, cancel });
|
|
311
|
+
const url = new URL(`${origin}${SETUP_PATHS.authorize}`);
|
|
312
|
+
const params = {
|
|
313
|
+
client_id: clientId, redirect_uri: listener.redirectUri, state: pkce.state,
|
|
314
|
+
code_challenge: pkce.challenge, code_challenge_method: "S256",
|
|
315
|
+
workspace_id: workspaceId, family_id: state.family_id, intent,
|
|
316
|
+
};
|
|
317
|
+
if (intent === "repair") params.expected_key_id = state.key_id;
|
|
318
|
+
url.search = new URLSearchParams(params).toString();
|
|
319
|
+
if (cancel.aborted) return failed("cancelled");
|
|
320
|
+
if (options.noBrowser && announce) {
|
|
321
|
+
announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
|
|
322
|
+
} else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
|
|
323
|
+
else if (!(await openBrowser(url.href))) return failed("browser_unavailable");
|
|
324
|
+
else progress("Opened your browser for ingest-only setup approval.");
|
|
325
|
+
callback = await pending;
|
|
326
|
+
} finally {
|
|
327
|
+
await listener.close();
|
|
328
|
+
}
|
|
329
|
+
if (callback.kind === "denied") return failed("access_denied");
|
|
330
|
+
if (callback.kind === "timeout") return failed("timeout");
|
|
331
|
+
if (callback.kind === "cancelled") return failed("cancelled");
|
|
332
|
+
if (callback.kind !== "code" || !RECEIPT.test(callback.code)) return failed("callback_invalid");
|
|
333
|
+
return { ok: true, code: callback.code, clientId, redirectUri: listener.redirectUri, verifier: pkce.verifier };
|
|
240
334
|
}
|
|
241
335
|
|
|
242
336
|
function finishSkill(root, options, envStatus) {
|
|
337
|
+
// Reached only with a verified, delivered credential, whether just issued
|
|
338
|
+
// or reused: no earlier refusal or redemption still applies.
|
|
339
|
+
clearSetupRefusal(root);
|
|
243
340
|
let previous = readSetupState(root);
|
|
244
341
|
if (previous === null || previous.value.phase !== "delivered") return fail("conflict", "setup_state_changed");
|
|
245
342
|
if (previous.value.selected_client !== options.client) {
|
|
@@ -313,14 +410,91 @@ async function discoverSetup(origin, profile, cancel) {
|
|
|
313
410
|
}
|
|
314
411
|
const offered = offeredClientId(doc);
|
|
315
412
|
if (offered === undefined) return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
316
|
-
|
|
413
|
+
// Optional: a service that names it lets the sign-in consent approve the
|
|
414
|
+
// first ingest key too. Anything but true means the separate approval.
|
|
415
|
+
return { ok: true, offeredClientId: offered, loginSetup: doc.login_ingest_setup_supported === true };
|
|
317
416
|
}
|
|
318
417
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
418
|
+
// The service budgets how many ingest keys setup may issue per person and per
|
|
419
|
+
// workspace each minute. A refusal leaves the receipt unredeemed, and only
|
|
420
|
+
// this process holds its PKCE verifier, so setup waits out a short
|
|
421
|
+
// Retry-After and redeems the same receipt rather than asking for another
|
|
422
|
+
// approval. It gives up after REDEEM_RETRIES waits.
|
|
423
|
+
const REDEEM_RETRIES = 2;
|
|
424
|
+
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
425
|
+
|
|
426
|
+
async function redeem(origin, form, profile, cancel, progress = () => {}) {
|
|
427
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
428
|
+
const response = await send(origin, SETUP_PATHS.redeem, { method: "POST", form,
|
|
429
|
+
signal: deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal, maxBytes: 32768 });
|
|
430
|
+
const failed = failureOf(response);
|
|
431
|
+
if (failed) return { ok: false, ...failed };
|
|
432
|
+
if (response.status === 429) {
|
|
433
|
+
const wait = retryAfterSeconds(response);
|
|
434
|
+
if (wait === null || attempt >= REDEEM_RETRIES) {
|
|
435
|
+
return { ok: false, outcome: "rate_limited", reason: "setup_rate_limited", retryAfter: wait };
|
|
436
|
+
}
|
|
437
|
+
progress(`The service is pacing new ingest keys. Retrying in ${wait} seconds.`);
|
|
438
|
+
if (!(await pause(wait * 1000, cancel))) return { ok: false, outcome: "authorization_failed", reason: "cancelled" };
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (response.status === 409 && parseJsonObject(response)?.error === "setup_key_limit") {
|
|
442
|
+
return { ok: false, outcome: "conflict", reason: "setup_key_limit" };
|
|
443
|
+
}
|
|
444
|
+
return redeemResult(response, origin, form, profile);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Seconds from Retry-After (or the body's retry_after_seconds), 1 to 60, or
|
|
449
|
+
// null when the service gave no usable delay. The header must be whole
|
|
450
|
+
// digits: "60junk" or "1.5" is unusable, as on the read path.
|
|
451
|
+
const RETRY_AFTER = /^[0-9]{1,6}$/;
|
|
452
|
+
|
|
453
|
+
function retryAfterSeconds(response) {
|
|
454
|
+
const raw = response.headers?.["retry-after"];
|
|
455
|
+
const header = typeof raw === "string" && RETRY_AFTER.test(raw.trim()) ? Number(raw.trim()) : null;
|
|
456
|
+
if (raw !== undefined && header === null) return null;
|
|
457
|
+
const body = parseJsonObject(response)?.retry_after_seconds;
|
|
458
|
+
const value = header !== null ? header : Number.isInteger(body) ? body : null;
|
|
459
|
+
return value !== null && value >= 1 && value <= MAX_RETRY_AFTER_SECONDS ? value : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function pause(ms, cancel) {
|
|
463
|
+
return new Promise((resolve) => {
|
|
464
|
+
if (cancel.aborted) return resolve(false);
|
|
465
|
+
const done = (value) => {
|
|
466
|
+
clearTimeout(timer);
|
|
467
|
+
cancel.removeEventListener("abort", onAbort);
|
|
468
|
+
resolve(value);
|
|
469
|
+
};
|
|
470
|
+
const onAbort = () => done(false);
|
|
471
|
+
const timer = setTimeout(() => done(true), ms);
|
|
472
|
+
cancel.addEventListener("abort", onAbort, { once: true });
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The next action for a budget refusal. Nothing was issued, and a rerun asks
|
|
477
|
+
// for a new approval.
|
|
478
|
+
function redeemNext(result, origin) {
|
|
479
|
+
if (result.reason === "setup_in_progress") {
|
|
480
|
+
return { kind: "retry_later", retry_after_seconds: result.retryAfter ?? null,
|
|
481
|
+
message: "An earlier setup run for this project is still finishing an approval it received. Wait a " +
|
|
482
|
+
"few seconds, then run setup again; do not ask for another approval." };
|
|
483
|
+
}
|
|
484
|
+
if (result.reason === "setup_rate_limited") {
|
|
485
|
+
return { kind: "retry_later", retry_after_seconds: result.retryAfter ?? null,
|
|
486
|
+
message: "The service limits how many ingest keys setup can issue each minute for one person and one " +
|
|
487
|
+
"workspace. Nothing was issued. Wait a minute, then run setup again; it asks for a new approval." };
|
|
488
|
+
}
|
|
489
|
+
if (result.reason === "setup_key_limit") {
|
|
490
|
+
return { kind: "revoke_keys", url: handoffPages(origin).keys_url,
|
|
491
|
+
message: "This workspace has reached its limit of active CLI setup keys. Nothing was issued. Revoke " +
|
|
492
|
+
"unused CLI setup keys on the Keys page, then run setup again." };
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function redeemResult(response, origin, form, profile) {
|
|
324
498
|
if (response.status !== 200) return { ok: false, outcome: "authorization_failed", reason: "setup_redeem_rejected" };
|
|
325
499
|
const body = parseJsonObject(response);
|
|
326
500
|
if (body?.schema_version !== SCHEMA || body.token_type !== "ingest" || body.scope !== "ingest" ||
|
package/src/skill-bundle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
// public skill at the manifest's source_url. The source has no version of its
|
|
6
6
|
// own, so the revision is derived from the content hash. The hash is pinned
|
|
7
7
|
// here as well as in the manifest, so editing either file alone is detected.
|
|
8
|
-
const PINNED_SHA256 = "
|
|
8
|
+
const PINNED_SHA256 = "ebf784673eff0280d85974504f93c683a1dabfb54156c9d27e091131462e9d5f";
|
|
9
9
|
|
|
10
10
|
const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
|
|
11
11
|
const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
|
|
@@ -55,7 +55,7 @@ export function loadBundledSkill() {
|
|
|
55
55
|
// The workflow skills that "skills install" writes, vendored from the public
|
|
56
56
|
// skills repository at one commit. The manifest's own hash is pinned here, and
|
|
57
57
|
// the manifest pins each skill's hash, so changing any file alone is detected.
|
|
58
|
-
const PINNED_PACK_SHA256 = "
|
|
58
|
+
const PINNED_PACK_SHA256 = "11ab983875911c4c2ad03d2a3b7b9e9213c410e77f4ff2d27b4059354b00fe0c";
|
|
59
59
|
|
|
60
60
|
const PACK_DIR = new URL("../assets/skills/", import.meta.url);
|
|
61
61
|
const PACK_KEYS = ["commit", "manifest_version", "repository", "skills"];
|