@mmerterden/multi-agent-pipeline 20.17.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 +12 -0
- package/docs/facts.json +1 -1
- package/docs/features.md +1 -1
- package/install/catalog-history.json +1 -1
- package/manifest.json +18 -18
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/update/SKILL.md +2 -2
- package/pipeline/multi-agent-refs/features/usage-reporting.md +106 -67
- package/pipeline/schemas/prefs.schema.json +23 -2
- package/pipeline/scripts/usage-identity.mjs +312 -53
- package/pipeline/scripts/usage-register.mjs +204 -85
- package/pipeline/scripts/usage-report.mjs +7 -5
- package/pipeline/skills/.skill-manifest.json +3 -3
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +2 -2
|
@@ -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";
|
|
50
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
47
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
|
{
|
|
@@ -90,7 +107,6 @@ export function decideRegistration(
|
|
|
90
107
|
forFeedback = false,
|
|
91
108
|
login = undefined,
|
|
92
109
|
loginReason = null,
|
|
93
|
-
fallback = false,
|
|
94
110
|
} = {},
|
|
95
111
|
) {
|
|
96
112
|
const g = prefs?.global || {};
|
|
@@ -103,14 +119,18 @@ export function decideRegistration(
|
|
|
103
119
|
if (env.MULTI_AGENT_USAGE_TOKEN) return { act: false, reason: "token in environment" };
|
|
104
120
|
if (typeof u.token === "string" && u.token.trim())
|
|
105
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
|
+
}
|
|
106
129
|
if (login === null) {
|
|
107
|
-
return { act: false, reason: loginReason || "no
|
|
130
|
+
return { act: false, reason: loginReason || "no GitHub account on this machine has access" };
|
|
108
131
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// keeps naming the runs after it is logged out.
|
|
112
|
-
if (hasStoredToken && fallback && u.registeredAs) {
|
|
113
|
-
return { act: false, reason: "token already onboarded" };
|
|
132
|
+
if (hasStoredToken && login && u.registeredFallback === true) {
|
|
133
|
+
return { act: true, reason: `replacing a fallback registration with ${login}` };
|
|
114
134
|
}
|
|
115
135
|
// The server pins a token to the login it was issued for. A token issued to a
|
|
116
136
|
// different login, or before the login was verified, reports under the wrong
|
|
@@ -157,61 +177,148 @@ export function registerUrlAllowed(url, trustedHosts = []) {
|
|
|
157
177
|
return trusted.has(u.hostname.toLowerCase());
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
async function
|
|
180
|
+
async function postJson(url, body, bearer = null) {
|
|
161
181
|
const ctrl = new AbortController();
|
|
162
182
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
183
|
+
const headers = { "Content-Type": "application/json" };
|
|
184
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
163
185
|
try {
|
|
164
186
|
const res = await fetch(url, {
|
|
165
187
|
method: "POST",
|
|
166
|
-
headers
|
|
188
|
+
headers,
|
|
167
189
|
body: JSON.stringify(body),
|
|
168
190
|
redirect: "error",
|
|
169
191
|
signal: ctrl.signal,
|
|
170
192
|
});
|
|
171
193
|
const json = await res.json().catch(() => null);
|
|
172
|
-
|
|
173
|
-
const detail = typeof json?.error === "string" ? `: ${json.error.slice(0, 120)}` : "";
|
|
174
|
-
if (json?.code === "login_mismatch") {
|
|
175
|
-
return {
|
|
176
|
-
token: null,
|
|
177
|
-
why: `the gh token does not belong to ${body.u}; run \`gh auth status\` and retry with --refresh`,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
if (res.status === 401) {
|
|
181
|
-
return {
|
|
182
|
-
token: null,
|
|
183
|
-
why:
|
|
184
|
-
`endpoint answered ${res.status}${detail} - GitHub did not accept the token for ${body.u}; ` +
|
|
185
|
-
`check \`gh auth status\` lists that account, then retry with --refresh`,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
if (res.status === 403) {
|
|
189
|
-
return {
|
|
190
|
-
token: null,
|
|
191
|
-
why:
|
|
192
|
-
`endpoint answered 403${detail} - ${body.u} needs collaborator access to the pipeline repository; ` +
|
|
193
|
-
`add the account that has it with \`gh auth login\`, then rerun with --refresh`,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
return { token: null, why: `endpoint answered ${res.status}${detail}` };
|
|
197
|
-
}
|
|
198
|
-
if (json?.accepted === false)
|
|
199
|
-
return { token: null, declined: true, why: "the endpoint declined this login" };
|
|
200
|
-
const token = typeof json?.token === "string" ? json.token.trim() : "";
|
|
201
|
-
const pinned = typeof json?.u === "string" && json.u.trim() ? json.u.trim() : null;
|
|
202
|
-
return token
|
|
203
|
-
? { token, why: null, login: pinned }
|
|
204
|
-
: { token: null, why: "endpoint returned no token" };
|
|
194
|
+
return { res, json, failure: null };
|
|
205
195
|
} catch (e) {
|
|
206
196
|
return {
|
|
207
|
-
|
|
208
|
-
|
|
197
|
+
res: null,
|
|
198
|
+
json: null,
|
|
199
|
+
failure: e?.name === "AbortError" ? "endpoint timed out" : "endpoint unreachable",
|
|
209
200
|
};
|
|
210
201
|
} finally {
|
|
211
202
|
clearTimeout(timer);
|
|
212
203
|
}
|
|
213
204
|
}
|
|
214
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
|
+
|
|
215
322
|
/**
|
|
216
323
|
* Write the switch, and the credential-store ENTRY NAME when there is one.
|
|
217
324
|
*
|
|
@@ -225,7 +332,7 @@ async function requestToken(url, body, proof) {
|
|
|
225
332
|
export function persistPrefs(
|
|
226
333
|
prefsPath,
|
|
227
334
|
entryName = null,
|
|
228
|
-
{ enableTelemetry = true, registeredAs = null
|
|
335
|
+
{ enableTelemetry = true, registeredAs = null } = {},
|
|
229
336
|
) {
|
|
230
337
|
try {
|
|
231
338
|
const written = updateJsonFileSync(
|
|
@@ -241,7 +348,7 @@ export function persistPrefs(
|
|
|
241
348
|
if (enableTelemetry) j.global.usageLog.enabled = true;
|
|
242
349
|
if (registeredAs) {
|
|
243
350
|
j.global.usageLog.registeredAs = registeredAs;
|
|
244
|
-
j.global.usageLog.registeredFallback
|
|
351
|
+
delete j.global.usageLog.registeredFallback;
|
|
245
352
|
j.global.usageLog.registeredAt = new Date().toISOString();
|
|
246
353
|
}
|
|
247
354
|
return j;
|
|
@@ -255,16 +362,25 @@ export function persistPrefs(
|
|
|
255
362
|
}
|
|
256
363
|
|
|
257
364
|
/**
|
|
258
|
-
* True
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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.
|
|
261
370
|
*/
|
|
262
|
-
export function personalAccountMissing(prefs) {
|
|
371
|
+
export function personalAccountMissing(prefs, login = undefined, env = process.env) {
|
|
263
372
|
const u = prefs?.global?.usageLog || {};
|
|
264
373
|
if (u.optOut === true) return false;
|
|
265
|
-
if (
|
|
374
|
+
if (env.MULTI_AGENT_USAGE_TOKEN) return false;
|
|
266
375
|
if (typeof u.token === "string" && u.token.trim()) return false;
|
|
267
|
-
|
|
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;
|
|
268
384
|
}
|
|
269
385
|
|
|
270
386
|
/**
|
|
@@ -326,9 +442,10 @@ async function main(argv) {
|
|
|
326
442
|
const say = (line) => {
|
|
327
443
|
if (!opts.quiet) process.stdout.write(`${line}\n`);
|
|
328
444
|
};
|
|
445
|
+
const identity = { login: undefined };
|
|
329
446
|
const done = (status, detail) => {
|
|
330
447
|
if (opts.json) {
|
|
331
|
-
const needsPersonalAccount = personalAccountMissing(readJson(prefsPath));
|
|
448
|
+
const needsPersonalAccount = personalAccountMissing(readJson(prefsPath), identity.login);
|
|
332
449
|
process.stdout.write(`${JSON.stringify({ status, detail, needsPersonalAccount })}\n`);
|
|
333
450
|
}
|
|
334
451
|
if (opts.quiet && reportingStaysOff(status, detail)) {
|
|
@@ -358,13 +475,13 @@ async function main(argv) {
|
|
|
358
475
|
const who = identityMatters
|
|
359
476
|
? reportingUser(prefsPath, { refresh: opts.refresh })
|
|
360
477
|
: { login: undefined, reason: null };
|
|
478
|
+
identity.login = who.login;
|
|
361
479
|
const current = readJson(prefsPath) || prefs;
|
|
362
480
|
const decision = decideRegistration(current, {
|
|
363
481
|
hasStoredToken: has,
|
|
364
482
|
forFeedback: opts.feedback,
|
|
365
483
|
login: who.login,
|
|
366
484
|
loginReason: who.reason,
|
|
367
|
-
fallback: who.fallback === true,
|
|
368
485
|
});
|
|
369
486
|
if (!decision.act) {
|
|
370
487
|
const repairable = ["token in environment", "token in prefs", "token already onboarded"];
|
|
@@ -413,19 +530,22 @@ async function main(argv) {
|
|
|
413
530
|
return done("dry-run", url);
|
|
414
531
|
}
|
|
415
532
|
|
|
416
|
-
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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");
|
|
421
547
|
}
|
|
422
|
-
|
|
423
|
-
const {
|
|
424
|
-
token,
|
|
425
|
-
why,
|
|
426
|
-
declined,
|
|
427
|
-
login: pinnedLogin,
|
|
428
|
-
} = await requestToken(url, { u: user, c: host }, proof);
|
|
548
|
+
const { token, why, declined, login: pinnedLogin } = result;
|
|
429
549
|
if (declined) {
|
|
430
550
|
say(` -> operational reporting left off (${why})`);
|
|
431
551
|
return done("declined", why);
|
|
@@ -449,7 +569,6 @@ async function main(argv) {
|
|
|
449
569
|
persistPrefs(prefsPath, entry, {
|
|
450
570
|
enableTelemetry: !optedOut,
|
|
451
571
|
registeredAs: pinnedLogin || user,
|
|
452
|
-
registeredFallback: who.fallback === true,
|
|
453
572
|
});
|
|
454
573
|
if (optedOut) {
|
|
455
574
|
say(" -> feedback token registered; telemetry stays off (usageLog.optOut is set)");
|
|
@@ -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
|
|
@@ -664,9 +665,9 @@ function appendErrorLedger(event) {
|
|
|
664
665
|
}
|
|
665
666
|
}
|
|
666
667
|
|
|
667
|
-
// Telemetry identity is the GitHub account that can read the
|
|
668
|
-
// repository (usage-identity.mjs), never the git identity.name and
|
|
669
|
-
// 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
|
|
670
671
|
// the ingest token, so this value is informational.
|
|
671
672
|
function resolveUser(opts = {}) {
|
|
672
673
|
return resolveReportingLogin(opts).login;
|
|
@@ -843,6 +844,7 @@ async function reportInstalledVersion(endpoint, token, login, usageLog) {
|
|
|
843
844
|
* no gh call to name its runs, and keeps the name after that account logs out.
|
|
844
845
|
*/
|
|
845
846
|
function registeredLogin(prefs) {
|
|
847
|
+
if (prefs?.registeredFallback === true) return null;
|
|
846
848
|
const login = typeof prefs?.registeredAs === "string" ? prefs.registeredAs.trim() : "";
|
|
847
849
|
return /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/.test(login) ? { login, reason: "registered" } : null;
|
|
848
850
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-07T19:19:02Z",
|
|
4
4
|
"skillCount": 223,
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
},
|
|
214
214
|
{
|
|
215
215
|
"path": "shared/core/multi-agent-setup/SKILL.md",
|
|
216
|
-
"sha256": "
|
|
216
|
+
"sha256": "427df811e5130da1ab6e23ff78202eb9134fb76bcd601924fba5668cbd630158"
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
"path": "shared/core/multi-agent-stack/SKILL.md",
|
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
},
|
|
266
266
|
{
|
|
267
267
|
"path": "shared/core/multi-agent-update/SKILL.md",
|
|
268
|
-
"sha256": "
|
|
268
|
+
"sha256": "013317432263490a01d081b657928b977012afb78f75a64d278d94d2daa8e894"
|
|
269
269
|
},
|
|
270
270
|
{
|
|
271
271
|
"path": "shared/core/multi-agent/SKILL.md",
|
|
@@ -136,8 +136,8 @@ Then, **whether they pasted or skipped**, run:
|
|
|
136
136
|
node "$HOME/.copilot/scripts/usage-register.mjs" --json
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
If its JSON line says `"needsPersonalAccount":true`, offer
|
|
140
|
-
|
|
139
|
+
If its JSON line says `"needsPersonalAccount":true`, offer once: `gh auth login`,
|
|
140
|
+
or an SSH key on the personal account (the "Personal account" step in
|
|
141
141
|
`$HOME/.claude/multi-agent-refs/features/usage-reporting.md`).
|
|
142
142
|
|
|
143
143
|
It maps whatever token exists and switches reporting on, and requests a
|
|
@@ -44,8 +44,8 @@ Update the pipeline with a single command. Preferences are preserved; only skill
|
|
|
44
44
|
already onboarded, and permanently blocked by `usageLog.optOut: true`. Offline
|
|
45
45
|
or endpoint down leaves reporting off with one line, never an error. Contract:
|
|
46
46
|
`$HOME/.claude/multi-agent-refs/features/usage-reporting.md`.
|
|
47
|
-
If its JSON line says `"needsPersonalAccount":true`, offer
|
|
48
|
-
|
|
47
|
+
If its JSON line says `"needsPersonalAccount":true`, offer once: `gh auth login`,
|
|
48
|
+
or an SSH key on the personal account (the "Personal account" step in
|
|
49
49
|
`$HOME/.claude/multi-agent-refs/features/usage-reporting.md`).
|
|
50
50
|
6. Show the new version + changelog
|
|
51
51
|
7. Smoke test (schema + cross-cli)
|