@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.1
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/CHANGELOG.md +42 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +10 -2
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +41 -6
- package/install/codex.mjs +3 -3
- package/manifest.json +62 -49
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/commands/sim-test.md +71 -6
- package/pipeline/lib/plugin-skill-roots.mjs +80 -0
- package/pipeline/lib/usage-lifecycle.mjs +83 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
- package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
- package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +129 -36
- package/pipeline/multi-agent-refs/features/valven.md +30 -3
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
- package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
- package/pipeline/schemas/explore-run.schema.json +108 -0
- package/pipeline/schemas/prefs.schema.json +24 -3
- package/pipeline/scripts/_stack-routing.mjs +2 -0
- package/pipeline/scripts/bug-bash.mjs +379 -0
- package/pipeline/scripts/explore-findings.mjs +410 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -2
- package/pipeline/scripts/pr-timeline.mjs +135 -0
- package/pipeline/scripts/pre-commit-check.sh +4 -2
- package/pipeline/scripts/skill-conformance.mjs +17 -71
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/uninstall.mjs +29 -8
- package/pipeline/scripts/usage-identity.mjs +311 -26
- package/pipeline/scripts/usage-register.mjs +268 -62
- package/pipeline/scripts/usage-report.mjs +59 -7
- package/pipeline/scripts/valven-gate.mjs +292 -11
- package/pipeline/skills/.skill-manifest.json +18 -10
- package/pipeline/skills/.skills-index.json +28 -6
- package/pipeline/skills/shared/README.md +6 -4
- package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
- package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
- package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
- package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
- package/pipeline/skills/skills-index.md +4 -2
- package/pipeline/skills/shared/core/valven-rules/SKILL.md +0 -62
|
@@ -18,15 +18,19 @@
|
|
|
18
18
|
* - It never registers when `usageLog.optOut` is true. That is permanent and
|
|
19
19
|
* checked before anything else, including any credential read (the
|
|
20
20
|
* credential store and gh).
|
|
21
|
-
* - It never registers a name it has not checked: the login sent is the
|
|
22
|
-
*
|
|
23
|
-
* (usage-identity.mjs
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
29
|
-
*
|
|
21
|
+
* - It never registers a name it has not checked: the login sent is the GitHub
|
|
22
|
+
* account on this machine that can read the pipeline repository
|
|
23
|
+
* (usage-identity.mjs: gh, the git credential helper, or an SSH key). No such
|
|
24
|
+
* account, no registration. The request proves the caller owns the login:
|
|
25
|
+
* a gh or credential-helper token as `Authorization: Bearer`, or for an SSH
|
|
26
|
+
* key a signature over a one-time server challenge
|
|
27
|
+
* (`ssh-keygen -Y sign`), sent with no Authorization header at all. The
|
|
28
|
+
* server pins the login to the token it issues.
|
|
29
|
+
* - It never sends a GitHub token over plain http to anything but loopback,
|
|
30
|
+
* never follows a redirect with it, never prints it, never stores it, and
|
|
31
|
+
* never puts it on an argv: gh or git prints it on its own stdout and it goes
|
|
32
|
+
* straight into the header. The credential-helper token is read again at
|
|
33
|
+
* registration rather than kept from the identity check.
|
|
30
34
|
* - It never fails a caller. Offline, endpoint down, ingest disabled by the
|
|
31
35
|
* admin, no credential store: one status line, exit 0.
|
|
32
36
|
* - It never reports run CONTENT. That is usage-report.mjs's contract, and it
|
|
@@ -35,22 +39,29 @@
|
|
|
35
39
|
* Usage:
|
|
36
40
|
* node usage-register.mjs [--prefs <path>] [--json] [--quiet] [--dry-run] [--feedback] [--refresh]
|
|
37
41
|
*
|
|
38
|
-
* `--refresh` re-checks which
|
|
39
|
-
* repository instead of using the cached `usageLog.login`.
|
|
42
|
+
* `--refresh` re-checks which GitHub account on this machine can read the
|
|
43
|
+
* pipeline repository instead of using the cached `usageLog.login`.
|
|
40
44
|
*
|
|
41
45
|
* Exit: always 0. `--json` says what happened; the shell does not need to care.
|
|
42
46
|
*
|
|
43
47
|
* @module pipeline/scripts/usage-register
|
|
44
48
|
*/
|
|
45
49
|
|
|
46
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
47
|
-
import { spawnSync } from "node:child_process";
|
|
50
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
48
52
|
import { join } from "node:path";
|
|
49
|
-
import { homedir, hostname } from "node:os";
|
|
53
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
50
54
|
import { pathToFileURL } from "node:url";
|
|
51
55
|
import { hostCommand } from "../lib/user-facing.mjs";
|
|
52
56
|
import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
|
|
53
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
SSH_TIMEOUT_MS,
|
|
59
|
+
credentialToken,
|
|
60
|
+
displayPath,
|
|
61
|
+
ghTokenFor,
|
|
62
|
+
resolveReportingLogin,
|
|
63
|
+
sshKeygenBin,
|
|
64
|
+
} from "./usage-identity.mjs";
|
|
54
65
|
import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
|
|
55
66
|
|
|
56
67
|
const DEFAULT_ENDPOINT = "https://mmerterden.com/api/usage/ingest";
|
|
@@ -66,10 +77,10 @@ function readJson(file) {
|
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/**
|
|
69
|
-
* The name this machine registers under: the
|
|
70
|
-
* read the pipeline repository (usage-identity.mjs),
|
|
71
|
-
*
|
|
72
|
-
* active.
|
|
80
|
+
* The name this machine registers under: the GitHub account on this machine
|
|
81
|
+
* that can read the pipeline repository (usage-identity.mjs), with the method
|
|
82
|
+
* it was found by, or login null when none can. Never the git identity, never
|
|
83
|
+
* the OS user, never the account gh merely has active.
|
|
73
84
|
*/
|
|
74
85
|
export function reportingUser(prefsPath, { env = process.env, refresh = false } = {}) {
|
|
75
86
|
return resolveReportingLogin({ prefsPath, env, refresh });
|
|
@@ -82,6 +93,12 @@ export function reportingUser(prefsPath, { env = process.env, refresh = false }
|
|
|
82
93
|
* them, and "you opted out" and "we could not reach the endpoint" are different
|
|
83
94
|
* facts about the same silence.
|
|
84
95
|
*/
|
|
96
|
+
function pinned(u) {
|
|
97
|
+
return (
|
|
98
|
+
typeof u.registeredAs === "string" && u.registeredAs !== "" && u.registeredFallback !== true
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
export function decideRegistration(
|
|
86
103
|
prefs,
|
|
87
104
|
{
|
|
@@ -102,8 +119,18 @@ export function decideRegistration(
|
|
|
102
119
|
if (env.MULTI_AGENT_USAGE_TOKEN) return { act: false, reason: "token in environment" };
|
|
103
120
|
if (typeof u.token === "string" && u.token.trim())
|
|
104
121
|
return { act: false, reason: "token in prefs" };
|
|
122
|
+
// A registered token stays pinned to its login whatever this machine holds
|
|
123
|
+
// later, so an account verified once keeps naming the runs after it is logged
|
|
124
|
+
// out. A registration marked `registeredFallback` (20.17.0) was made with an
|
|
125
|
+
// account that cannot read the repository, so it is not pinned.
|
|
126
|
+
if (hasStoredToken && login === null && pinned(u)) {
|
|
127
|
+
return { act: false, reason: "token already onboarded" };
|
|
128
|
+
}
|
|
105
129
|
if (login === null) {
|
|
106
|
-
return { act: false, reason: loginReason || "no
|
|
130
|
+
return { act: false, reason: loginReason || "no GitHub account on this machine has access" };
|
|
131
|
+
}
|
|
132
|
+
if (hasStoredToken && login && u.registeredFallback === true) {
|
|
133
|
+
return { act: true, reason: `replacing a fallback registration with ${login}` };
|
|
107
134
|
}
|
|
108
135
|
// The server pins a token to the login it was issued for. A token issued to a
|
|
109
136
|
// different login, or before the login was verified, reports under the wrong
|
|
@@ -150,56 +177,148 @@ export function registerUrlAllowed(url, trustedHosts = []) {
|
|
|
150
177
|
return trusted.has(u.hostname.toLowerCase());
|
|
151
178
|
}
|
|
152
179
|
|
|
153
|
-
async function
|
|
180
|
+
async function postJson(url, body, bearer = null) {
|
|
154
181
|
const ctrl = new AbortController();
|
|
155
182
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
183
|
+
const headers = { "Content-Type": "application/json" };
|
|
184
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
156
185
|
try {
|
|
157
186
|
const res = await fetch(url, {
|
|
158
187
|
method: "POST",
|
|
159
|
-
headers
|
|
188
|
+
headers,
|
|
160
189
|
body: JSON.stringify(body),
|
|
161
190
|
redirect: "error",
|
|
162
191
|
signal: ctrl.signal,
|
|
163
192
|
});
|
|
164
193
|
const json = await res.json().catch(() => null);
|
|
165
|
-
|
|
166
|
-
const detail = typeof json?.error === "string" ? `: ${json.error.slice(0, 120)}` : "";
|
|
167
|
-
if (json?.code === "login_mismatch") {
|
|
168
|
-
return {
|
|
169
|
-
token: null,
|
|
170
|
-
why: `the gh token does not belong to ${body.u}; run \`gh auth status\` and retry with --refresh`,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
if (res.status === 401) {
|
|
174
|
-
return {
|
|
175
|
-
token: null,
|
|
176
|
-
why:
|
|
177
|
-
`endpoint answered ${res.status}${detail} - GitHub did not accept the token for ${body.u}; ` +
|
|
178
|
-
`check \`gh auth status\` lists that account, then retry with --refresh`,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
if (res.status === 403) {
|
|
182
|
-
return {
|
|
183
|
-
token: null,
|
|
184
|
-
why: `endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
return { token: null, why: `endpoint answered ${res.status}${detail}` };
|
|
188
|
-
}
|
|
189
|
-
if (json?.accepted === false)
|
|
190
|
-
return { token: null, declined: true, why: "the endpoint declined this login" };
|
|
191
|
-
const token = typeof json?.token === "string" ? json.token.trim() : "";
|
|
192
|
-
return token ? { token, why: null } : { token: null, why: "endpoint returned no token" };
|
|
194
|
+
return { res, json, failure: null };
|
|
193
195
|
} catch (e) {
|
|
194
196
|
return {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
res: null,
|
|
198
|
+
json: null,
|
|
199
|
+
failure: e?.name === "AbortError" ? "endpoint timed out" : "endpoint unreachable",
|
|
197
200
|
};
|
|
198
201
|
} finally {
|
|
199
202
|
clearTimeout(timer);
|
|
200
203
|
}
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
const SSH_REFUSALS = {
|
|
207
|
+
ssh_signature_invalid: (u) =>
|
|
208
|
+
`the server could not verify the SSH signature for ${u}; retry with --refresh`,
|
|
209
|
+
ssh_key_not_on_account: (u) =>
|
|
210
|
+
`the SSH key is not on ${u}'s GitHub account; add its .pub under GitHub Settings > SSH keys, then retry with --refresh`,
|
|
211
|
+
ssh_key_unsupported: (u) =>
|
|
212
|
+
`the server does not accept this SSH key type for ${u}; put an ed25519, rsa or ecdsa-p256 key on that account, then retry with --refresh`,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function refusal(status, json, body, method) {
|
|
216
|
+
const detail = typeof json?.error === "string" ? `: ${json.error.slice(0, 120)}` : "";
|
|
217
|
+
const code = typeof json?.code === "string" ? json.code : "";
|
|
218
|
+
if (SSH_REFUSALS[code]) return `endpoint answered ${status} - ${SSH_REFUSALS[code](body.u)}`;
|
|
219
|
+
if (code === "login_mismatch") {
|
|
220
|
+
return method === "gh"
|
|
221
|
+
? `the gh token does not belong to ${body.u}; run \`gh auth status\` and retry with --refresh`
|
|
222
|
+
: `the proof does not belong to ${body.u}; retry with --refresh`;
|
|
223
|
+
}
|
|
224
|
+
if (status === 401) {
|
|
225
|
+
const fix =
|
|
226
|
+
method === "gh"
|
|
227
|
+
? "check `gh auth status` lists that account, then retry with --refresh"
|
|
228
|
+
: "retry with --refresh";
|
|
229
|
+
return `endpoint answered ${status}${detail} - GitHub did not accept the proof for ${body.u}; ${fix}`;
|
|
230
|
+
}
|
|
231
|
+
if (status === 403) {
|
|
232
|
+
return (
|
|
233
|
+
`endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository; ` +
|
|
234
|
+
"log in the account that has it (`gh auth login`, or add its SSH key), then rerun with --refresh"
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return `endpoint answered ${status}${detail}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function requestToken(url, body, proof, method) {
|
|
241
|
+
const { res, json, failure } = await postJson(url, body, proof);
|
|
242
|
+
if (failure) return { token: null, why: failure };
|
|
243
|
+
if (!res.ok) return { token: null, why: refusal(res.status, json, body, method) };
|
|
244
|
+
if (json?.accepted === false)
|
|
245
|
+
return { token: null, declined: true, why: "the endpoint declined this login" };
|
|
246
|
+
const token = typeof json?.token === "string" ? json.token.trim() : "";
|
|
247
|
+
const pinnedLogin = typeof json?.u === "string" && json.u.trim() ? json.u.trim() : null;
|
|
248
|
+
return token
|
|
249
|
+
? { token, why: null, login: pinnedLogin }
|
|
250
|
+
: { token: null, why: "endpoint returned no token" };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Sign `challenge` with the SSH key at `key` under `namespace`. The exact
|
|
255
|
+
* challenge bytes go into a 0600 file in a fresh temp dir that is removed
|
|
256
|
+
* afterwards. stdin is closed and askpass is disabled, so a key that needs a
|
|
257
|
+
* passphrase fails at once; the `.pub` is then tried, which signs through
|
|
258
|
+
* ssh-agent when the key is loaded there. Returns the armored signature or null.
|
|
259
|
+
*/
|
|
260
|
+
export function sshSign(key, namespace, challenge, env = process.env) {
|
|
261
|
+
let dir = null;
|
|
262
|
+
try {
|
|
263
|
+
dir = mkdtempSync(join(tmpdir(), "ma-usage-sign-"));
|
|
264
|
+
const file = join(dir, "challenge");
|
|
265
|
+
writeFileSync(file, challenge, { mode: 0o600 });
|
|
266
|
+
const tries = key.endsWith(".pub") ? [key] : [key, `${key}.pub`];
|
|
267
|
+
for (const f of tries) {
|
|
268
|
+
if (!existsSync(f)) continue;
|
|
269
|
+
rmSync(`${file}.sig`, { force: true });
|
|
270
|
+
const r = spawnSync(sshKeygenBin(env), ["-Y", "sign", "-f", f, "-n", namespace, file], {
|
|
271
|
+
encoding: "utf8",
|
|
272
|
+
timeout: SSH_TIMEOUT_MS,
|
|
273
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
274
|
+
env: { ...env, SSH_ASKPASS_REQUIRE: "never" },
|
|
275
|
+
});
|
|
276
|
+
if (r.status === 0 && existsSync(`${file}.sig`)) {
|
|
277
|
+
const sig = readFileSync(`${file}.sig`, "utf8");
|
|
278
|
+
if (sig.includes("BEGIN SSH SIGNATURE")) return sig;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
} finally {
|
|
285
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The challenge URL next to the register URL. */
|
|
290
|
+
export function challengeUrl(url) {
|
|
291
|
+
return `${url.replace(/\/+$/, "")}/challenge`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function requestTokenWithSsh(url, body, key) {
|
|
295
|
+
const ch = await postJson(challengeUrl(url), { u: body.u });
|
|
296
|
+
if (ch.failure) return { token: null, why: ch.failure };
|
|
297
|
+
if (!ch.res.ok) {
|
|
298
|
+
if (ch.res.status === 404 || ch.res.status === 405) {
|
|
299
|
+
return {
|
|
300
|
+
token: null,
|
|
301
|
+
why: `the endpoint does not accept an SSH key as proof yet (challenge answered ${ch.res.status})`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return { token: null, why: refusal(ch.res.status, ch.json, body, "ssh") };
|
|
305
|
+
}
|
|
306
|
+
const challenge = typeof ch.json?.challenge === "string" ? ch.json.challenge : "";
|
|
307
|
+
const namespace = typeof ch.json?.namespace === "string" ? ch.json.namespace : "";
|
|
308
|
+
if (!challenge || !/^[A-Za-z0-9@._-]{1,128}$/.test(namespace)) {
|
|
309
|
+
return { token: null, why: "the endpoint returned no usable SSH challenge" };
|
|
310
|
+
}
|
|
311
|
+
const signature = sshSign(key, namespace, challenge);
|
|
312
|
+
if (!signature) {
|
|
313
|
+
const shown = displayPath(key);
|
|
314
|
+
return {
|
|
315
|
+
token: null,
|
|
316
|
+
why: `could not sign with the SSH key ${shown} (it needs a passphrase and is not in ssh-agent); run \`ssh-add ${shown}\`, then retry with --refresh`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return requestToken(url, { ...body, ssh: { challenge, signature } }, null, "ssh");
|
|
320
|
+
}
|
|
321
|
+
|
|
203
322
|
/**
|
|
204
323
|
* Write the switch, and the credential-store ENTRY NAME when there is one.
|
|
205
324
|
*
|
|
@@ -227,7 +346,11 @@ export function persistPrefs(
|
|
|
227
346
|
}
|
|
228
347
|
j.global.usageLog = j.global.usageLog || {};
|
|
229
348
|
if (enableTelemetry) j.global.usageLog.enabled = true;
|
|
230
|
-
if (registeredAs)
|
|
349
|
+
if (registeredAs) {
|
|
350
|
+
j.global.usageLog.registeredAs = registeredAs;
|
|
351
|
+
delete j.global.usageLog.registeredFallback;
|
|
352
|
+
j.global.usageLog.registeredAt = new Date().toISOString();
|
|
353
|
+
}
|
|
231
354
|
return j;
|
|
232
355
|
},
|
|
233
356
|
{ mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
|
|
@@ -238,6 +361,62 @@ export function persistPrefs(
|
|
|
238
361
|
}
|
|
239
362
|
}
|
|
240
363
|
|
|
364
|
+
/**
|
|
365
|
+
* True when this machine's runs are not pinned to a verified account AND no
|
|
366
|
+
* GitHub account on it can read the pipeline repository (no gh account, no
|
|
367
|
+
* credential-helper token and no SSH key with access). `login` is the identity
|
|
368
|
+
* check's answer when it ran; otherwise the cached `usageLog.login` stands in.
|
|
369
|
+
* Setup and update read it from `--json` and offer the fix once.
|
|
370
|
+
*/
|
|
371
|
+
export function personalAccountMissing(prefs, login = undefined, env = process.env) {
|
|
372
|
+
const u = prefs?.global?.usageLog || {};
|
|
373
|
+
if (u.optOut === true) return false;
|
|
374
|
+
if (env.MULTI_AGENT_USAGE_TOKEN) return false;
|
|
375
|
+
if (typeof u.token === "string" && u.token.trim()) return false;
|
|
376
|
+
if (pinned(u)) return false;
|
|
377
|
+
const found =
|
|
378
|
+
login !== undefined
|
|
379
|
+
? login
|
|
380
|
+
: u.loginFallback === true || typeof u.login !== "string"
|
|
381
|
+
? null
|
|
382
|
+
: u.login;
|
|
383
|
+
return !found;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Report the installed version when it changed since the last report, detached.
|
|
388
|
+
* A first install happens before registration and an update runs this call
|
|
389
|
+
* right after installing, so both are reported from here.
|
|
390
|
+
*/
|
|
391
|
+
function sendInstalledVersion(prefsPath) {
|
|
392
|
+
try {
|
|
393
|
+
spawn(process.execPath, [join(import.meta.dirname, "usage-report.mjs"), "--lifecycle"], {
|
|
394
|
+
detached: true,
|
|
395
|
+
stdio: "ignore",
|
|
396
|
+
env: { ...process.env, MULTI_AGENT_PREFS: prefsPath },
|
|
397
|
+
}).unref();
|
|
398
|
+
} catch {
|
|
399
|
+
// reporting never fails the caller
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const QUIET_REASONS = new Set([
|
|
404
|
+
"opted out",
|
|
405
|
+
"token in environment",
|
|
406
|
+
"token in prefs",
|
|
407
|
+
"token already onboarded",
|
|
408
|
+
"no prefs",
|
|
409
|
+
]);
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Whether an outcome leaves a machine silent for a reason its user can fix.
|
|
413
|
+
* `--quiet` callers discard stdout, so this is the one line they still see.
|
|
414
|
+
*/
|
|
415
|
+
function reportingStaysOff(status, detail) {
|
|
416
|
+
if (status === "unavailable" || status === "declined") return true;
|
|
417
|
+
return status === "skipped" && !QUIET_REASONS.has(detail);
|
|
418
|
+
}
|
|
419
|
+
|
|
241
420
|
async function main(argv) {
|
|
242
421
|
const opts = {
|
|
243
422
|
quiet: false,
|
|
@@ -263,8 +442,15 @@ async function main(argv) {
|
|
|
263
442
|
const say = (line) => {
|
|
264
443
|
if (!opts.quiet) process.stdout.write(`${line}\n`);
|
|
265
444
|
};
|
|
445
|
+
const identity = { login: undefined };
|
|
266
446
|
const done = (status, detail) => {
|
|
267
|
-
if (opts.json)
|
|
447
|
+
if (opts.json) {
|
|
448
|
+
const needsPersonalAccount = personalAccountMissing(readJson(prefsPath), identity.login);
|
|
449
|
+
process.stdout.write(`${JSON.stringify({ status, detail, needsPersonalAccount })}\n`);
|
|
450
|
+
}
|
|
451
|
+
if (opts.quiet && reportingStaysOff(status, detail)) {
|
|
452
|
+
process.stderr.write(`multi-agent: usage reporting is off - ${detail}\n`);
|
|
453
|
+
}
|
|
268
454
|
return 0;
|
|
269
455
|
};
|
|
270
456
|
|
|
@@ -289,6 +475,7 @@ async function main(argv) {
|
|
|
289
475
|
const who = identityMatters
|
|
290
476
|
? reportingUser(prefsPath, { refresh: opts.refresh })
|
|
291
477
|
: { login: undefined, reason: null };
|
|
478
|
+
identity.login = who.login;
|
|
292
479
|
const current = readJson(prefsPath) || prefs;
|
|
293
480
|
const decision = decideRegistration(current, {
|
|
294
481
|
hasStoredToken: has,
|
|
@@ -305,12 +492,19 @@ async function main(argv) {
|
|
|
305
492
|
const name = current.global?.keychainMapping?.usage_ingest || null;
|
|
306
493
|
if (persistPrefs(prefsPath, name)) {
|
|
307
494
|
say(" -> operational reporting: enabled (a token was already onboarded)");
|
|
495
|
+
sendInstalledVersion(prefsPath);
|
|
308
496
|
return done("enabled", decision.reason);
|
|
309
497
|
}
|
|
310
498
|
say(" -> operational reporting: could not write preferences");
|
|
311
499
|
return done("unavailable", "prefs write failed");
|
|
312
500
|
}
|
|
313
501
|
}
|
|
502
|
+
if (
|
|
503
|
+
decision.reason === "token already onboarded" &&
|
|
504
|
+
current.global?.usageLog?.enabled === true
|
|
505
|
+
) {
|
|
506
|
+
sendInstalledVersion(prefsPath);
|
|
507
|
+
}
|
|
314
508
|
say(` -> operational reporting: unchanged (${decision.reason})`);
|
|
315
509
|
return done("skipped", decision.reason);
|
|
316
510
|
}
|
|
@@ -336,14 +530,22 @@ async function main(argv) {
|
|
|
336
530
|
return done("dry-run", url);
|
|
337
531
|
}
|
|
338
532
|
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
533
|
+
let result;
|
|
534
|
+
if (who.method === "ssh") {
|
|
535
|
+
result = await requestTokenWithSsh(url, { u: user, c: host }, who.key);
|
|
536
|
+
} else {
|
|
537
|
+
const proof = who.method === "credential" ? credentialToken() : ghTokenFor(user);
|
|
538
|
+
if (!proof) {
|
|
539
|
+
const why =
|
|
540
|
+
who.method === "credential"
|
|
541
|
+
? `the git credential helper no longer returns a GitHub token for ${user}; retry with --refresh`
|
|
542
|
+
: `gh has no token for ${user}; run \`gh auth login\` for that account`;
|
|
543
|
+
say(` -> operational reporting left off (${why})`);
|
|
544
|
+
return done("unavailable", why);
|
|
545
|
+
}
|
|
546
|
+
result = await requestToken(url, { u: user, c: host }, proof, who.method || "gh");
|
|
344
547
|
}
|
|
345
|
-
|
|
346
|
-
const { token, why, declined } = await requestToken(url, { u: user, c: host }, proof);
|
|
548
|
+
const { token, why, declined, login: pinnedLogin } = result;
|
|
347
549
|
if (declined) {
|
|
348
550
|
say(` -> operational reporting left off (${why})`);
|
|
349
551
|
return done("declined", why);
|
|
@@ -364,7 +566,10 @@ async function main(argv) {
|
|
|
364
566
|
say(" -> operational reporting left off (the credential store refused the token)");
|
|
365
567
|
return done("unavailable", "credential store write failed");
|
|
366
568
|
}
|
|
367
|
-
persistPrefs(prefsPath, entry, {
|
|
569
|
+
persistPrefs(prefsPath, entry, {
|
|
570
|
+
enableTelemetry: !optedOut,
|
|
571
|
+
registeredAs: pinnedLogin || user,
|
|
572
|
+
});
|
|
368
573
|
if (optedOut) {
|
|
369
574
|
say(" -> feedback token registered; telemetry stays off (usageLog.optOut is set)");
|
|
370
575
|
return done("registered-feedback-only", entry);
|
|
@@ -373,6 +578,7 @@ async function main(argv) {
|
|
|
373
578
|
" -> operational reporting: registered this machine (write-only token in the credential store)",
|
|
374
579
|
);
|
|
375
580
|
say(" opt out any time: set global.usageLog.optOut=true in multi-agent-preferences.json");
|
|
581
|
+
sendInstalledVersion(prefsPath);
|
|
376
582
|
return done("registered", entry);
|
|
377
583
|
}
|
|
378
584
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* usage-report.mjs - optional, opt-in per-run operational reporting.
|
|
3
3
|
*
|
|
4
4
|
* Off by default; a no-op unless prefs.global.usageLog.enabled is true, a
|
|
5
|
-
* token resolves, and a
|
|
6
|
-
*
|
|
5
|
+
* token resolves, and a GitHub account on this machine (gh, the git
|
|
6
|
+
* credential helper or an SSH key) can read the pipeline repository
|
|
7
|
+
* (usage-identity.mjs). Emits one compact run record keyed by run
|
|
7
8
|
* id (`taskId`), upserted at every phase transition and at completion, halt
|
|
8
9
|
* and park, fire-and-forget - never throws, never delays a run.
|
|
9
10
|
* Only coarse run metadata is included; never prompts, code, diffs, token
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
* node usage-report.mjs --state /path/to/agent-state.json [--event <kind>]
|
|
23
24
|
* node usage-report.mjs --task-id PROJ-123 [--phase N] [--event <kind>]
|
|
24
25
|
*
|
|
26
|
+
* node usage-report.mjs --lifecycle (report the installed version when it changed)
|
|
25
27
|
* --event phase | completed | halted | parked (default: derived from state)
|
|
26
28
|
*
|
|
27
29
|
* @module pipeline/scripts/usage-report
|
|
@@ -44,11 +46,13 @@ import { execFileSync } from "child_process";
|
|
|
44
46
|
import { createHash } from "crypto";
|
|
45
47
|
import { costUsd } from "./_cost.mjs";
|
|
46
48
|
import { defaultPrefsPath, resolveReportingLogin } from "./usage-identity.mjs";
|
|
49
|
+
import { installedVersion, lifecycleEvent } from "../lib/usage-lifecycle.mjs";
|
|
50
|
+
import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
|
|
47
51
|
import { KNOWN_STACKS, pluginsForStacks } from "./_stack-routing.mjs";
|
|
48
52
|
|
|
49
53
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
50
54
|
const ENDPOINT_DEFAULT = "https://mmerterden.com/api/usage/ingest";
|
|
51
|
-
const TIMEOUT_MS =
|
|
55
|
+
const TIMEOUT_MS = 6000;
|
|
52
56
|
|
|
53
57
|
// The emitter ships verbatim into ~/.claude, ~/.copilot and ~/.codex, so it must
|
|
54
58
|
// never hard-code one host's tree: a Copilot- or Codex-only install has no
|
|
@@ -71,6 +75,7 @@ function parseArgs(argv) {
|
|
|
71
75
|
status: null,
|
|
72
76
|
event: null,
|
|
73
77
|
dryRun: false,
|
|
78
|
+
lifecycle: false,
|
|
74
79
|
};
|
|
75
80
|
for (let i = 0; i < argv.length; i += 1) {
|
|
76
81
|
const a = argv[i];
|
|
@@ -80,6 +85,7 @@ function parseArgs(argv) {
|
|
|
80
85
|
else if (a === "--status") out.status = argv[++i];
|
|
81
86
|
else if (a === "--event") out.event = argv[++i];
|
|
82
87
|
else if (a === "--dry-run") out.dryRun = true;
|
|
88
|
+
else if (a === "--lifecycle") out.lifecycle = true;
|
|
83
89
|
}
|
|
84
90
|
return out;
|
|
85
91
|
}
|
|
@@ -659,9 +665,9 @@ function appendErrorLedger(event) {
|
|
|
659
665
|
}
|
|
660
666
|
}
|
|
661
667
|
|
|
662
|
-
// Telemetry identity is the GitHub account that can read the
|
|
663
|
-
// repository (usage-identity.mjs), never the git identity.name and
|
|
664
|
-
// account gh merely has active. The server replaces it with the login pinned to
|
|
668
|
+
// Telemetry identity is the GitHub account on this machine that can read the
|
|
669
|
+
// pipeline repository (usage-identity.mjs), never the git identity.name and
|
|
670
|
+
// never the account gh merely has active. The server replaces it with the login pinned to
|
|
665
671
|
// the ingest token, so this value is informational.
|
|
666
672
|
function resolveUser(opts = {}) {
|
|
667
673
|
return resolveReportingLogin(opts).login;
|
|
@@ -806,6 +812,43 @@ function endpointAllowed(endpoint) {
|
|
|
806
812
|
return false;
|
|
807
813
|
}
|
|
808
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Send an install or update record when the installed version (from the
|
|
817
|
+
* installer's stamp) is not the last one reported, and move
|
|
818
|
+
* `usageLog.reportedVersion` forward once the server accepted it.
|
|
819
|
+
*/
|
|
820
|
+
async function reportInstalledVersion(endpoint, token, login, usageLog) {
|
|
821
|
+
const e = lifecycleEvent({ installed: installedVersion(homedir()), usageLog });
|
|
822
|
+
if (!e) return;
|
|
823
|
+
const r = await post(endpoint, token, { ...e, u: login, os: detectOs(), cli: detectCli() });
|
|
824
|
+
if (r.outcome !== "accepted") return;
|
|
825
|
+
try {
|
|
826
|
+
updateJsonFileSync(
|
|
827
|
+
defaultPrefsPath(),
|
|
828
|
+
(j) => {
|
|
829
|
+
if (!j || typeof j !== "object" || Array.isArray(j)) return undefined;
|
|
830
|
+
j.global = j.global || {};
|
|
831
|
+
j.global.usageLog = { ...(j.global.usageLog || {}), reportedVersion: e.v };
|
|
832
|
+
return j;
|
|
833
|
+
},
|
|
834
|
+
{ mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
|
|
835
|
+
);
|
|
836
|
+
} catch {
|
|
837
|
+
// the next send reports the same version again, under the same id
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The login a registered token is pinned to. The server reports every record
|
|
843
|
+
* under it whatever the client sends, so a machine that registered once needs
|
|
844
|
+
* no gh call to name its runs, and keeps the name after that account logs out.
|
|
845
|
+
*/
|
|
846
|
+
function registeredLogin(prefs) {
|
|
847
|
+
if (prefs?.registeredFallback === true) return null;
|
|
848
|
+
const login = typeof prefs?.registeredAs === "string" ? prefs.registeredAs.trim() : "";
|
|
849
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/.test(login) ? { login, reason: "registered" } : null;
|
|
850
|
+
}
|
|
851
|
+
|
|
809
852
|
async function main() {
|
|
810
853
|
const args = parseArgs(process.argv.slice(2));
|
|
811
854
|
if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
|
|
@@ -829,6 +872,13 @@ async function main() {
|
|
|
829
872
|
return;
|
|
830
873
|
}
|
|
831
874
|
|
|
875
|
+
if (args.lifecycle) {
|
|
876
|
+
if (args.dryRun) return;
|
|
877
|
+
const who = registeredLogin(prefs) ?? resolveReportingLogin();
|
|
878
|
+
if (who.login) await reportInstalledVersion(endpoint, token, who.login, prefs);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
832
882
|
const statePath = resolveStatePath(args);
|
|
833
883
|
let state = statePath ? readJson(statePath) : null;
|
|
834
884
|
|
|
@@ -844,7 +894,7 @@ async function main() {
|
|
|
844
894
|
if (args.status) state.status = args.status;
|
|
845
895
|
const kind = EVENT_KINDS.includes(args.event) ? args.event : null;
|
|
846
896
|
|
|
847
|
-
const who = resolveReportingLogin();
|
|
897
|
+
const who = registeredLogin(prefs) ?? resolveReportingLogin();
|
|
848
898
|
if (!who.login) {
|
|
849
899
|
process.stderr.write(`usage-report: not sent - ${who.reason}\n`);
|
|
850
900
|
if (!args.dryRun) {
|
|
@@ -863,6 +913,8 @@ async function main() {
|
|
|
863
913
|
if (event.st !== "in_progress") appendErrorLedger(event);
|
|
864
914
|
const result = await post(endpoint, token, event);
|
|
865
915
|
recordSend({ id: event.id, ev: event.ev, host: hostOf(endpoint), ...result });
|
|
916
|
+
if (result.outcome === "accepted")
|
|
917
|
+
await reportInstalledVersion(endpoint, token, who.login, prefs);
|
|
866
918
|
if (result.outcome !== "accepted") {
|
|
867
919
|
const detail = result.status ?? result.reason;
|
|
868
920
|
const where = result.location ? ` to ${result.location}` : "";
|