@botbuddy/cli 1.5.0 → 1.5.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/package.json +3 -2
- package/src/botbuddy-release-repair.json +1 -0
- package/src/publish-equal.mjs +154 -6
- package/src/run.mjs +5 -1
- package/src/auth.test.mjs +0 -404
- package/src/discovery.test.mjs +0 -195
- package/src/docker-hygiene.test.mjs +0 -790
- package/src/locks.test.mjs +0 -60
- package/src/profile-bootstrap.test.mjs +0 -205
- package/src/publish-equal.test.mjs +0 -176
- package/src/publish-workflow.test.mjs +0 -122
- package/src/quiet-runner.test.mjs +0 -109
- package/src/run.test.mjs +0 -173
- package/src/stack.test.mjs +0 -756
- package/src/wait-profile.test.mjs +0 -30
- package/src/wait.test.mjs +0 -266
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
-
"src/"
|
|
11
|
+
"src/",
|
|
12
|
+
"!src/**/*.test.mjs"
|
|
12
13
|
],
|
|
13
14
|
"keywords": [
|
|
14
15
|
"mcp",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema_version":1,"source_version":"1.5.1","source_identity":"0f390ab84c23f56dcae6e8cc1331fb2fa8533ae2f026f396f8adb76d9485b2d8"}
|
package/src/publish-equal.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Used by .github/workflows/publish-cli-package.yml when the version already
|
|
6
6
|
// exists on npm: an equivalent package is a safe idempotent skip; any drift
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// triggers a fresh release-time version allocation so the change cannot merge
|
|
8
|
+
// green while staying unpublished.
|
|
9
9
|
//
|
|
10
10
|
// The comparison excludes ONLY npm-injected manifest fields (notably gitHead,
|
|
11
11
|
// which npm stamps into the tarball's package.json from the publishing commit).
|
|
12
12
|
// Every other manifest field (bin, files, engines, …) and every shipped file
|
|
13
13
|
// is compared, so a genuine metadata change still counts as drift.
|
|
14
14
|
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
16
17
|
import { join, relative } from "node:path";
|
|
17
18
|
|
|
@@ -22,6 +23,7 @@ import { join, relative } from "node:path";
|
|
|
22
23
|
// included, is compared. Key ORDER is still canonicalised so incidental
|
|
23
24
|
// ordering never reads as drift.
|
|
24
25
|
const NPM_INJECTED_KEYS = [];
|
|
26
|
+
const RELEASE_REPAIR_RECEIPT = "src/botbuddy-release-repair.json";
|
|
25
27
|
|
|
26
28
|
// Deep, key-sorted canonical form so incidental key ordering never reads as a
|
|
27
29
|
// difference.
|
|
@@ -112,9 +114,62 @@ export function isPrerelease(v) {
|
|
|
112
114
|
return parseSemver(v).pre.length > 0;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
function compareMain(a, b) {
|
|
118
|
+
for (let i = 0; i < 3; i++) {
|
|
119
|
+
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function incrementPatch(main) {
|
|
125
|
+
if (!Number.isSafeInteger(main[2]) || main[2] === Number.MAX_SAFE_INTEGER) {
|
|
126
|
+
throw new Error(`cannot allocate a patch version after ${main.join(".")}`);
|
|
127
|
+
}
|
|
128
|
+
return [main[0], main[1], main[2] + 1];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Allocate a release-only version once a tarball drifted from an immutable npm
|
|
132
|
+
// version. Stable releases advance from the greatest published stable version,
|
|
133
|
+
// preserving the established latest-tag ordering. Prereleases advance past the
|
|
134
|
+
// greatest published core version and retain a prerelease component, so they
|
|
135
|
+
// cannot accidentally become latest while still being greater than `next`.
|
|
136
|
+
// `unique` is a monotonic CI-run identity, not a timestamp, and is only used
|
|
137
|
+
// after a drift is confirmed.
|
|
138
|
+
export function nextUnpublishedVersion(local, publishedVersions, unique = "release") {
|
|
139
|
+
const parsedLocal = parseSemver(local);
|
|
140
|
+
if (!Array.isArray(publishedVersions)) throw new Error("published versions must be an array");
|
|
141
|
+
|
|
142
|
+
let greatestStable = parsedLocal.main;
|
|
143
|
+
let greatestCore = parsedLocal.main;
|
|
144
|
+
for (const version of publishedVersions) {
|
|
145
|
+
const parsed = parseSemver(version);
|
|
146
|
+
if (compareMain(parsed.main, greatestCore) > 0) greatestCore = parsed.main;
|
|
147
|
+
if (parsed.pre.length === 0 && compareMain(parsed.main, greatestStable) > 0) {
|
|
148
|
+
greatestStable = parsed.main;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (parsedLocal.pre.length === 0) return incrementPatch(greatestStable).join(".");
|
|
153
|
+
if (!/^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/.test(unique)) {
|
|
154
|
+
throw new Error(`not a prerelease identifier: ${unique}`);
|
|
155
|
+
}
|
|
156
|
+
return `${incrementPatch(greatestCore).join(".")}-release.${unique}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Kept as the narrow public helper for the stable publish path and for
|
|
160
|
+
// straightforward unit fixtures. The workflow uses nextUnpublishedVersion so
|
|
161
|
+
// a drifted prerelease remains safely tagged as a prerelease too.
|
|
162
|
+
export function nextUnpublishedStableVersion(local, publishedVersions) {
|
|
163
|
+
parseStable(local);
|
|
164
|
+
const next = nextUnpublishedVersion(local, publishedVersions);
|
|
165
|
+
parseStable(next);
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function normalizeManifest(json, { ignoreVersion = false } = {}) {
|
|
116
170
|
const m = JSON.parse(json);
|
|
117
171
|
for (const k of NPM_INJECTED_KEYS) delete m[k];
|
|
172
|
+
if (ignoreVersion) delete m.version;
|
|
118
173
|
return JSON.stringify(canon(m));
|
|
119
174
|
}
|
|
120
175
|
|
|
@@ -133,7 +188,7 @@ function walkRelative(root) {
|
|
|
133
188
|
}
|
|
134
189
|
|
|
135
190
|
// Compare two extracted `package/` directories. Returns { equal, reason }.
|
|
136
|
-
export function packagesEquivalent(localDir, pubDir) {
|
|
191
|
+
export function packagesEquivalent(localDir, pubDir, { ignoreVersion = false } = {}) {
|
|
137
192
|
const localFiles = walkRelative(localDir);
|
|
138
193
|
const pubFiles = walkRelative(pubDir);
|
|
139
194
|
if (localFiles.join("\n") !== pubFiles.join("\n")) {
|
|
@@ -146,7 +201,7 @@ export function packagesEquivalent(localDir, pubDir) {
|
|
|
146
201
|
const a = readFileSync(join(localDir, rel));
|
|
147
202
|
const b = readFileSync(join(pubDir, rel));
|
|
148
203
|
if (rel === "package.json") {
|
|
149
|
-
if (normalizeManifest(a.toString("utf8")) !== normalizeManifest(b.toString("utf8"))) {
|
|
204
|
+
if (normalizeManifest(a.toString("utf8"), { ignoreVersion }) !== normalizeManifest(b.toString("utf8"), { ignoreVersion })) {
|
|
150
205
|
return { equal: false, reason: "package.json differs beyond npm-injected fields (e.g. bin/files/engines)" };
|
|
151
206
|
}
|
|
152
207
|
} else if (!a.equals(b)) {
|
|
@@ -164,8 +219,47 @@ export function packagesEquivalent(localDir, pubDir) {
|
|
|
164
219
|
return { equal: true };
|
|
165
220
|
}
|
|
166
221
|
|
|
222
|
+
// A release-time repair carries a shipped receipt. It prevents an intentional
|
|
223
|
+
// restore of any historical tarball from being mistaken for an earlier repair
|
|
224
|
+
// of this exact source package. Versions and the receipt itself are excluded
|
|
225
|
+
// so the identity represents only the source package being repaired.
|
|
226
|
+
export function releaseRepairIdentity(packageDir) {
|
|
227
|
+
const hash = createHash("sha256");
|
|
228
|
+
for (const rel of walkRelative(packageDir)) {
|
|
229
|
+
if (rel === RELEASE_REPAIR_RECEIPT) continue;
|
|
230
|
+
const path = join(packageDir, rel);
|
|
231
|
+
const content = rel === "package.json"
|
|
232
|
+
? normalizeManifest(readFileSync(path, "utf8"), { ignoreVersion: true })
|
|
233
|
+
: readFileSync(path);
|
|
234
|
+
hash.update(rel);
|
|
235
|
+
hash.update("\0");
|
|
236
|
+
hash.update(String(statSync(path).mode & 0o777));
|
|
237
|
+
hash.update("\0");
|
|
238
|
+
hash.update(content);
|
|
239
|
+
hash.update("\0");
|
|
240
|
+
}
|
|
241
|
+
return hash.digest("hex");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// True only when `candidateDir` bears the receipt emitted by this workflow for
|
|
245
|
+
// the exact local source. A content-only historical match is deliberately not
|
|
246
|
+
// enough: it must still publish so the restored artifact becomes latest.
|
|
247
|
+
export function matchesReleaseRepair(localDir, candidateDir) {
|
|
248
|
+
try {
|
|
249
|
+
const receipt = JSON.parse(readFileSync(join(candidateDir, RELEASE_REPAIR_RECEIPT), "utf8"));
|
|
250
|
+
const localVersion = JSON.parse(readFileSync(join(localDir, "package.json"), "utf8")).version;
|
|
251
|
+
const sourceIdentity = releaseRepairIdentity(localDir);
|
|
252
|
+
return receipt.schema_version === 1
|
|
253
|
+
&& receipt.source_version === localVersion
|
|
254
|
+
&& receipt.source_identity === sourceIdentity
|
|
255
|
+
&& releaseRepairIdentity(candidateDir) === sourceIdentity;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
167
261
|
// CLI: `node cli-publish-equal.mjs <localPackageDir> <publishedPackageDir>`.
|
|
168
|
-
// Exit 0 = equivalent (safe skip); exit 3 = drift (must
|
|
262
|
+
// Exit 0 = equivalent (safe skip); exit 3 = drift (must allocate a new version).
|
|
169
263
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
170
264
|
const args = process.argv.slice(2);
|
|
171
265
|
// `--gt <next> <current>`: exit 0 iff stable `next` > stable `current`.
|
|
@@ -192,6 +286,60 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
192
286
|
process.exit(2);
|
|
193
287
|
}
|
|
194
288
|
}
|
|
289
|
+
// `--next-version <local> <unique>` reads the registry's JSON versions list
|
|
290
|
+
// on stdin and prints an absent semver suitable for a drifted package.
|
|
291
|
+
if (args[0] === "--next-version") {
|
|
292
|
+
const [, local, unique] = args;
|
|
293
|
+
try {
|
|
294
|
+
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
295
|
+
const versions = Array.isArray(input) ? input : [input];
|
|
296
|
+
console.log(nextUnpublishedVersion(local, versions, unique));
|
|
297
|
+
process.exit(0);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(String(err.message ?? err));
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (args[0] === "--repair-identity") {
|
|
304
|
+
const [, packageDir] = args;
|
|
305
|
+
if (!packageDir) {
|
|
306
|
+
console.error("usage: publish-equal.mjs --repair-identity <packageDir>");
|
|
307
|
+
process.exit(2);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
console.log(releaseRepairIdentity(packageDir));
|
|
311
|
+
process.exit(0);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error(String(err.message ?? err));
|
|
314
|
+
process.exit(2);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (args[0] === "--is-release-repair") {
|
|
318
|
+
const [, localDir, candidateDir] = args;
|
|
319
|
+
if (!localDir || !candidateDir) {
|
|
320
|
+
console.error("usage: publish-equal.mjs --is-release-repair <localPackageDir> <candidatePackageDir>");
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
323
|
+
process.exit(matchesReleaseRepair(localDir, candidateDir) ? 0 : 3);
|
|
324
|
+
}
|
|
325
|
+
// `--ignore-version <localPackageDir> <publishedPackageDir>` compares the
|
|
326
|
+
// shipped package while disregarding only package.json's version. The
|
|
327
|
+
// workflow uses this after confirmed drift to recognize an earlier
|
|
328
|
+
// release-time repair of the same source tarball on a rerun.
|
|
329
|
+
if (args[0] === "--ignore-version") {
|
|
330
|
+
const [, localDir, pubDir] = args;
|
|
331
|
+
if (!localDir || !pubDir) {
|
|
332
|
+
console.error("usage: publish-equal.mjs --ignore-version <localPackageDir> <publishedPackageDir>");
|
|
333
|
+
process.exit(2);
|
|
334
|
+
}
|
|
335
|
+
const result = packagesEquivalent(localDir, pubDir, { ignoreVersion: true });
|
|
336
|
+
if (result.equal) {
|
|
337
|
+
console.log("identical except package version");
|
|
338
|
+
process.exit(0);
|
|
339
|
+
}
|
|
340
|
+
console.error("drift: " + result.reason);
|
|
341
|
+
process.exit(3);
|
|
342
|
+
}
|
|
195
343
|
const [localDir, pubDir] = args;
|
|
196
344
|
if (!localDir || !pubDir) {
|
|
197
345
|
console.error("usage: publish-equal.mjs <localPackageDir> <publishedPackageDir> | --gt <next> <current>");
|
package/src/run.mjs
CHANGED
|
@@ -132,10 +132,14 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
|
|
|
132
132
|
// Subscribe before any filesystem I/O so a busy parallel test run (or a real
|
|
133
133
|
// fast failure) cannot lose the sole terminal event.
|
|
134
134
|
const completion = waitForChild(child);
|
|
135
|
-
|
|
135
|
+
// Capture pipes before the first await too. A short-lived command can emit
|
|
136
|
+
// all of its output while context persistence yields to the filesystem; if
|
|
137
|
+
// these listeners are attached afterwards, its terminal receipt silently
|
|
138
|
+
// reports zero bytes even though the command wrote successfully (BOT-1411).
|
|
136
139
|
const capture = boundedCapture();
|
|
137
140
|
child.stdout?.on("data", (chunk) => capture.append(chunk));
|
|
138
141
|
child.stderr?.on("data", (chunk) => capture.append(chunk));
|
|
142
|
+
await writeJson(context.context_path, { ...context, worker_pid: process.pid, workload_pid: child.pid });
|
|
139
143
|
let killTimer;
|
|
140
144
|
const timer = setTimeout(() => {
|
|
141
145
|
timedOut = true;
|
package/src/auth.test.mjs
DELETED
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
import http from "node:http";
|
|
4
|
-
import { EventEmitter } from "node:events";
|
|
5
|
-
|
|
6
|
-
import { doLogin } from "./auth.mjs";
|
|
7
|
-
import {
|
|
8
|
-
createLoopbackReceiver,
|
|
9
|
-
buildAuthorizeUrl,
|
|
10
|
-
generatePkce,
|
|
11
|
-
generateState,
|
|
12
|
-
openBrowser,
|
|
13
|
-
} from "./oauth-loopback.mjs";
|
|
14
|
-
|
|
15
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
function httpGet(url) {
|
|
18
|
-
return new Promise((resolve, reject) => {
|
|
19
|
-
http
|
|
20
|
-
.get(url, (res) => {
|
|
21
|
-
let body = "";
|
|
22
|
-
res.on("data", (c) => (body += c));
|
|
23
|
-
res.on("end", () => resolve({ status: res.statusCode, body, headers: res.headers }));
|
|
24
|
-
})
|
|
25
|
-
.on("error", reject);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function jsonResponse(obj, status = 200) {
|
|
30
|
-
return { status, ok: status < 400, json: async () => obj };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// A mock fetch that only ever answers /register and /token. It records every
|
|
34
|
-
// call so tests can assert /authorize is never fetched and the callback URI is
|
|
35
|
-
// identical across registration and token exchange.
|
|
36
|
-
function makeFetch({ tokenResponse, registerResponse } = {}) {
|
|
37
|
-
const calls = [];
|
|
38
|
-
const fetchImpl = async (url, opts = {}) => {
|
|
39
|
-
calls.push({ url, opts });
|
|
40
|
-
if (url.endsWith("/register")) {
|
|
41
|
-
const body = JSON.parse(opts.body);
|
|
42
|
-
return jsonResponse(registerResponse ?? { client_id: "client-123", redirect_uris: body.redirect_uris });
|
|
43
|
-
}
|
|
44
|
-
if (url.endsWith("/token")) {
|
|
45
|
-
return jsonResponse(tokenResponse ?? { access_token: "tok-secret-abc", expires_in: 3600 });
|
|
46
|
-
}
|
|
47
|
-
throw new Error(`unexpected fetch: ${url}`);
|
|
48
|
-
};
|
|
49
|
-
return { fetchImpl, calls };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// A stand-in for the browser: given the authorize URL, it plays the role of the
|
|
53
|
-
// server-driven redirect and hits the loopback callback with a valid code+state.
|
|
54
|
-
function browserThatCompletes(code = "auth-code-xyz") {
|
|
55
|
-
return async (authUrl) => {
|
|
56
|
-
const u = new URL(authUrl);
|
|
57
|
-
const redirectUri = u.searchParams.get("redirect_uri");
|
|
58
|
-
const state = u.searchParams.get("state");
|
|
59
|
-
await httpGet(`${redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`);
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function captureUrl(line) {
|
|
64
|
-
const m = String(line).match(/https?:\/\/[^\s\x1b]+/);
|
|
65
|
-
return m ? m[0] : null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function waitFor(pred, timeout = 2000) {
|
|
69
|
-
const start = Date.now();
|
|
70
|
-
while (!pred()) {
|
|
71
|
-
if (Date.now() - start > timeout) throw new Error("waitFor timed out");
|
|
72
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const SERVER = "https://server.test/functions/v1/mcp-server";
|
|
77
|
-
|
|
78
|
-
function baseDeps(overrides = {}) {
|
|
79
|
-
return {
|
|
80
|
-
serverUrl: SERVER,
|
|
81
|
-
saveConfig: () => {},
|
|
82
|
-
getConfig: () => ({}),
|
|
83
|
-
log: () => {},
|
|
84
|
-
errorLog: () => {},
|
|
85
|
-
timeoutMs: 3000,
|
|
86
|
-
now: () => 1000,
|
|
87
|
-
...overrides,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ─── AC-16: happy path ───────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
test("BOT-1383: happy path registers, authorizes via browser, exchanges token, saves config", async () => {
|
|
94
|
-
const { fetchImpl, calls } = makeFetch();
|
|
95
|
-
const out = [];
|
|
96
|
-
let saved = null;
|
|
97
|
-
|
|
98
|
-
const result = await doLogin(
|
|
99
|
-
{ noBrowser: false },
|
|
100
|
-
baseDeps({
|
|
101
|
-
fetch: fetchImpl,
|
|
102
|
-
openBrowser: browserThatCompletes("code-1"),
|
|
103
|
-
saveConfig: (c) => { saved = c; },
|
|
104
|
-
getConfig: () => ({ existing: "keep" }),
|
|
105
|
-
log: (s) => out.push(String(s)),
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
assert.ok(saved, "config was saved");
|
|
110
|
-
assert.equal(saved.access_token, "tok-secret-abc");
|
|
111
|
-
assert.equal(saved.client_id, "client-123");
|
|
112
|
-
assert.equal(saved.existing, "keep", "preserves existing config fields");
|
|
113
|
-
assert.equal(saved.token_expires_at, 1000 + 3600 * 1000);
|
|
114
|
-
assert.match(result.redirectUri, /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ─── AC-16: callback URI identical across registration and token exchange ─────
|
|
118
|
-
|
|
119
|
-
test("BOT-1383: the callback URI is identical in registration and token exchange", async () => {
|
|
120
|
-
const { fetchImpl, calls } = makeFetch();
|
|
121
|
-
await doLogin({}, baseDeps({ fetch: fetchImpl, openBrowser: browserThatCompletes() }));
|
|
122
|
-
|
|
123
|
-
const reg = calls.find((c) => c.url.endsWith("/register"));
|
|
124
|
-
const tok = calls.find((c) => c.url.endsWith("/token"));
|
|
125
|
-
const regRedirect = JSON.parse(reg.opts.body).redirect_uris[0];
|
|
126
|
-
const tokRedirect = new URLSearchParams(tok.opts.body.toString()).get("redirect_uri");
|
|
127
|
-
|
|
128
|
-
assert.equal(regRedirect, tokRedirect);
|
|
129
|
-
assert.match(regRedirect, /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
|
|
130
|
-
assert.doesNotMatch(regRedirect, /19836/, "does not keep the old fixed port");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// ─── AC-16 regression: the CLI never fetches /authorize ───────────────────────
|
|
134
|
-
|
|
135
|
-
test("BOT-1383 regression: the CLI never fetches /authorize and cannot hit the old redirect path", async () => {
|
|
136
|
-
const { fetchImpl, calls } = makeFetch();
|
|
137
|
-
await doLogin({}, baseDeps({ fetch: fetchImpl, openBrowser: browserThatCompletes() }));
|
|
138
|
-
|
|
139
|
-
assert.ok(!calls.some((c) => c.url.includes("/authorize")), "no /authorize fetch");
|
|
140
|
-
assert.deepEqual(
|
|
141
|
-
calls.map((c) => c.url.replace(SERVER, "")),
|
|
142
|
-
["/register", "/token"],
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// ─── AC-4/AC-16: secrets never leak to output ─────────────────────────────────
|
|
147
|
-
|
|
148
|
-
test("BOT-1383: verifier and access token never appear in terminal output", async () => {
|
|
149
|
-
const { fetchImpl, calls } = makeFetch();
|
|
150
|
-
const out = [];
|
|
151
|
-
await doLogin(
|
|
152
|
-
{},
|
|
153
|
-
baseDeps({ fetch: fetchImpl, openBrowser: browserThatCompletes(), log: (s) => out.push(String(s)), errorLog: (s) => out.push(String(s)) }),
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const joined = out.join("\n");
|
|
157
|
-
const tok = calls.find((c) => c.url.endsWith("/token"));
|
|
158
|
-
const verifier = new URLSearchParams(tok.opts.body.toString()).get("code_verifier");
|
|
159
|
-
|
|
160
|
-
assert.ok(verifier && verifier.length >= 43, "verifier present in token exchange");
|
|
161
|
-
assert.ok(!joined.includes(verifier), "verifier not printed");
|
|
162
|
-
assert.ok(!joined.includes("tok-secret-abc"), "access token not printed");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ─── AC-9: browser callback page discloses no secrets and is no-store ─────────
|
|
166
|
-
|
|
167
|
-
test("BOT-1383: the callback success page is no-store and discloses no secrets", async () => {
|
|
168
|
-
const receiver = createLoopbackReceiver({ expectedState: "state-1" });
|
|
169
|
-
const { redirectUri } = await receiver.listen();
|
|
170
|
-
const waited = receiver.waitForCallback({ timeoutMs: 2000 });
|
|
171
|
-
|
|
172
|
-
const resp = await httpGet(`${redirectUri}?code=SUPERSECRETCODE&state=state-1`);
|
|
173
|
-
const { code } = await waited;
|
|
174
|
-
|
|
175
|
-
assert.equal(code, "SUPERSECRETCODE");
|
|
176
|
-
assert.equal(resp.status, 200);
|
|
177
|
-
assert.equal(resp.headers["cache-control"], "no-store");
|
|
178
|
-
assert.ok(!resp.body.includes("SUPERSECRETCODE"), "code not in HTML");
|
|
179
|
-
assert.ok(!resp.body.includes("state-1"), "state not in HTML");
|
|
180
|
-
// The page fires before token exchange, so it must not claim final success;
|
|
181
|
-
// it points the user to the terminal for the actual result.
|
|
182
|
-
assert.match(resp.body, /terminal/i, "directs the user to the terminal");
|
|
183
|
-
assert.doesNotMatch(resp.body, /succeeded/i, "does not over-claim authentication success");
|
|
184
|
-
await receiver.close();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// ─── AC-8: state validation ───────────────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
test("BOT-1383: a wrong-state request is rejected but the listener keeps waiting", async () => {
|
|
190
|
-
const receiver = createLoopbackReceiver({ expectedState: "good" });
|
|
191
|
-
const { redirectUri } = await receiver.listen();
|
|
192
|
-
const waited = receiver.waitForCallback({ timeoutMs: 2000 });
|
|
193
|
-
|
|
194
|
-
// A stale/malicious tab or probe with the wrong state must NOT abort the login.
|
|
195
|
-
const bad = await httpGet(`${redirectUri}?code=stolen&state=bad`);
|
|
196
|
-
assert.equal(bad.status, 400);
|
|
197
|
-
|
|
198
|
-
// The legitimate callback still completes afterward.
|
|
199
|
-
const good = await httpGet(`${redirectUri}?code=real&state=good`);
|
|
200
|
-
assert.equal(good.status, 200);
|
|
201
|
-
const { code } = await waited;
|
|
202
|
-
assert.equal(code, "real");
|
|
203
|
-
await receiver.close();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("BOT-1383: a callback missing the code is rejected", async () => {
|
|
207
|
-
const receiver = createLoopbackReceiver({ expectedState: "good" });
|
|
208
|
-
const { redirectUri } = await receiver.listen();
|
|
209
|
-
const rejected = assert.rejects(receiver.waitForCallback({ timeoutMs: 2000 }), /code/i);
|
|
210
|
-
|
|
211
|
-
const resp = await httpGet(`${redirectUri}?state=good`);
|
|
212
|
-
assert.equal(resp.status, 400);
|
|
213
|
-
await rejected;
|
|
214
|
-
await receiver.close();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// ─── AC-8: OAuth denial ───────────────────────────────────────────────────────
|
|
218
|
-
|
|
219
|
-
test("BOT-1383: an OAuth error/denial callback is surfaced", async () => {
|
|
220
|
-
const receiver = createLoopbackReceiver({ expectedState: "s" });
|
|
221
|
-
const { redirectUri } = await receiver.listen();
|
|
222
|
-
const rejected = assert.rejects(receiver.waitForCallback({ timeoutMs: 2000 }), (err) => err.code === "access_denied");
|
|
223
|
-
|
|
224
|
-
await httpGet(`${redirectUri}?state=s&error=access_denied&error_description=User%20denied`);
|
|
225
|
-
await rejected;
|
|
226
|
-
await receiver.close();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// ─── AC-8: duplicate callback completes at most once ──────────────────────────
|
|
230
|
-
|
|
231
|
-
test("BOT-1383: a duplicate callback completes at most once", async () => {
|
|
232
|
-
const receiver = createLoopbackReceiver({ expectedState: "s" });
|
|
233
|
-
const { redirectUri } = await receiver.listen();
|
|
234
|
-
const waited = receiver.waitForCallback({ timeoutMs: 2000 });
|
|
235
|
-
|
|
236
|
-
const r1 = await httpGet(`${redirectUri}?code=c1&state=s`);
|
|
237
|
-
const { code } = await waited;
|
|
238
|
-
const r2 = await httpGet(`${redirectUri}?code=c2&state=s`);
|
|
239
|
-
|
|
240
|
-
assert.equal(code, "c1", "first code wins");
|
|
241
|
-
assert.equal(r1.status, 200);
|
|
242
|
-
assert.equal(r2.status, 200, "duplicate handled gracefully");
|
|
243
|
-
await receiver.close();
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// ─── AC-16: unrelated paths keep the listener waiting ─────────────────────────
|
|
247
|
-
|
|
248
|
-
test("BOT-1383: a request to /favicon.ico does not complete the login", async () => {
|
|
249
|
-
const receiver = createLoopbackReceiver({ expectedState: "s" });
|
|
250
|
-
const { redirectUri, port } = await receiver.listen();
|
|
251
|
-
const waited = receiver.waitForCallback({ timeoutMs: 2000 });
|
|
252
|
-
|
|
253
|
-
const fav = await httpGet(`http://127.0.0.1:${port}/favicon.ico`);
|
|
254
|
-
assert.equal(fav.status, 404);
|
|
255
|
-
|
|
256
|
-
await httpGet(`${redirectUri}?code=ok&state=s`);
|
|
257
|
-
const { code } = await waited;
|
|
258
|
-
assert.equal(code, "ok");
|
|
259
|
-
await receiver.close();
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
// ─── AC-11: token failure saves nothing ───────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
test("BOT-1383: a token-exchange failure errors and saves no credentials", async () => {
|
|
265
|
-
const { fetchImpl } = makeFetch({ tokenResponse: { error: "invalid_grant" } });
|
|
266
|
-
let saved = null;
|
|
267
|
-
await assert.rejects(
|
|
268
|
-
doLogin({}, baseDeps({ fetch: fetchImpl, openBrowser: browserThatCompletes(), saveConfig: () => { saved = "X"; } })),
|
|
269
|
-
/Token exchange failed/,
|
|
270
|
-
);
|
|
271
|
-
assert.equal(saved, null);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
test("BOT-1383: a registration failure errors before any browser launch", async () => {
|
|
275
|
-
const { fetchImpl } = makeFetch({ registerResponse: { error: "bad" } });
|
|
276
|
-
let opened = false;
|
|
277
|
-
await assert.rejects(
|
|
278
|
-
doLogin({}, baseDeps({ fetch: fetchImpl, openBrowser: async () => { opened = true; } })),
|
|
279
|
-
/Client registration failed/,
|
|
280
|
-
);
|
|
281
|
-
assert.equal(opened, false, "no browser launched when registration fails");
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// ─── AC-11: timeout closes the listener and saves nothing ─────────────────────
|
|
285
|
-
|
|
286
|
-
test("BOT-1383: a timeout errors, saves nothing, and never opens a real browser", async () => {
|
|
287
|
-
const { fetchImpl } = makeFetch();
|
|
288
|
-
let saved = null;
|
|
289
|
-
const silentBrowser = async () => {}; // never fires the callback
|
|
290
|
-
await assert.rejects(
|
|
291
|
-
doLogin({}, baseDeps({ fetch: fetchImpl, openBrowser: silentBrowser, saveConfig: () => { saved = "X"; }, timeoutMs: 120 })),
|
|
292
|
-
/Timed out/i,
|
|
293
|
-
);
|
|
294
|
-
assert.equal(saved, null);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// ─── AC-12: browser-launch failure still allows manual completion ─────────────
|
|
298
|
-
|
|
299
|
-
test("BOT-1383: a browser-launch failure still allows manual completion", async () => {
|
|
300
|
-
const { fetchImpl, calls } = makeFetch();
|
|
301
|
-
const out = [];
|
|
302
|
-
let saved = null;
|
|
303
|
-
const failingBrowser = async () => { throw new Error("no browser here"); };
|
|
304
|
-
|
|
305
|
-
const login = doLogin(
|
|
306
|
-
{},
|
|
307
|
-
baseDeps({ fetch: fetchImpl, openBrowser: failingBrowser, saveConfig: (c) => { saved = c; }, log: (s) => out.push(String(s)) }),
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
// The user opens the printed URL manually: fire the callback ourselves.
|
|
311
|
-
await waitFor(() => out.some((l) => l.includes("/authorize?")));
|
|
312
|
-
const authUrl = captureUrl(out.find((l) => l.includes("/authorize?")));
|
|
313
|
-
const u = new URL(authUrl);
|
|
314
|
-
await httpGet(`${u.searchParams.get("redirect_uri")}?code=manual&state=${u.searchParams.get("state")}`);
|
|
315
|
-
|
|
316
|
-
await login;
|
|
317
|
-
assert.ok(saved && saved.access_token === "tok-secret-abc");
|
|
318
|
-
assert.ok(out.join("\n").includes("Couldn't open a browser"), "prints manual guidance");
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// ─── AC-13: re-running login uses a fresh listener and state ───────────────────
|
|
322
|
-
|
|
323
|
-
test("BOT-1383: re-running login uses an independent state and listener", async () => {
|
|
324
|
-
async function runOnce() {
|
|
325
|
-
const { fetchImpl } = makeFetch();
|
|
326
|
-
const out = [];
|
|
327
|
-
let saved = null;
|
|
328
|
-
await doLogin({}, baseDeps({ fetch: fetchImpl, openBrowser: browserThatCompletes(), saveConfig: (c) => { saved = c; }, log: (s) => out.push(String(s)) }));
|
|
329
|
-
const authUrl = captureUrl(out.find((l) => l.includes("/authorize?")));
|
|
330
|
-
return { state: new URL(authUrl).searchParams.get("state"), saved };
|
|
331
|
-
}
|
|
332
|
-
const a = await runOnce();
|
|
333
|
-
const b = await runOnce();
|
|
334
|
-
|
|
335
|
-
assert.ok(a.saved.access_token && b.saved.access_token, "both logins succeed");
|
|
336
|
-
assert.notEqual(a.state, b.state, "each login mints a fresh state");
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
// ─── AC-11: listener cleanup frees the socket ─────────────────────────────────
|
|
340
|
-
|
|
341
|
-
test("BOT-1383: closing the receiver frees the socket", async () => {
|
|
342
|
-
const receiver = createLoopbackReceiver({ expectedState: "s" });
|
|
343
|
-
const { port } = await receiver.listen();
|
|
344
|
-
await receiver.close();
|
|
345
|
-
await assert.rejects(httpGet(`http://127.0.0.1:${port}/callback?state=s&code=c`), /ECONNREFUSED/);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
// ─── AC-5: browser opener uses argument-based spawning per platform ────────────
|
|
349
|
-
|
|
350
|
-
test("BOT-1383: openBrowser spawns an argument-based opener per platform, never a shell", async () => {
|
|
351
|
-
const spawned = [];
|
|
352
|
-
const fakeSpawn = (cmd, args, opts) => {
|
|
353
|
-
spawned.push({ cmd, args, opts });
|
|
354
|
-
const ee = new EventEmitter();
|
|
355
|
-
ee.unref = () => {};
|
|
356
|
-
setImmediate(() => ee.emit("spawn"));
|
|
357
|
-
return ee;
|
|
358
|
-
};
|
|
359
|
-
const url = "https://x.test/authorize?a=b&c=d";
|
|
360
|
-
await openBrowser(url, { platform: "darwin", spawn: fakeSpawn });
|
|
361
|
-
await openBrowser(url, { platform: "linux", spawn: fakeSpawn });
|
|
362
|
-
await openBrowser(url, { platform: "win32", spawn: fakeSpawn });
|
|
363
|
-
|
|
364
|
-
assert.deepEqual(spawned[0].args, [url]);
|
|
365
|
-
assert.equal(spawned[0].cmd, "open");
|
|
366
|
-
assert.equal(spawned[1].cmd, "xdg-open");
|
|
367
|
-
// Windows must NOT route the URL through cmd.exe (which splits on `&`); the
|
|
368
|
-
// whole URL is a single argv entry to rundll32's FileProtocolHandler.
|
|
369
|
-
assert.equal(spawned[2].cmd, "rundll32");
|
|
370
|
-
assert.deepEqual(spawned[2].args, ["url.dll,FileProtocolHandler", url], "URL is one unparsed argv entry");
|
|
371
|
-
assert.ok(spawned.every((s) => s.cmd !== "cmd"), "never invokes cmd.exe");
|
|
372
|
-
// The `&`-bearing URL survives intact as a single argument on every platform.
|
|
373
|
-
assert.ok(spawned.every((s) => s.args.includes(url)), "full URL passed as one discrete argument");
|
|
374
|
-
assert.ok(spawned.every((s) => s.opts.shell !== true), "never spawns a shell");
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test("BOT-1383: openBrowser rejects when the opener cannot spawn", async () => {
|
|
378
|
-
const fakeSpawn = () => {
|
|
379
|
-
const ee = new EventEmitter();
|
|
380
|
-
setImmediate(() => ee.emit("error", new Error("ENOENT xdg-open")));
|
|
381
|
-
return ee;
|
|
382
|
-
};
|
|
383
|
-
await assert.rejects(openBrowser("https://x", { platform: "linux", spawn: fakeSpawn }), /ENOENT/);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// ─── unit sanity for the pure builders ────────────────────────────────────────
|
|
387
|
-
|
|
388
|
-
test("BOT-1383: buildAuthorizeUrl carries PKCE S256 and never the verifier", () => {
|
|
389
|
-
const { codeVerifier, codeChallenge } = generatePkce();
|
|
390
|
-
const state = generateState();
|
|
391
|
-
const url = buildAuthorizeUrl({
|
|
392
|
-
serverUrl: SERVER,
|
|
393
|
-
clientId: "c1",
|
|
394
|
-
redirectUri: "http://127.0.0.1:5555/callback",
|
|
395
|
-
state,
|
|
396
|
-
codeChallenge,
|
|
397
|
-
});
|
|
398
|
-
const u = new URL(url);
|
|
399
|
-
assert.equal(u.searchParams.get("code_challenge_method"), "S256");
|
|
400
|
-
assert.equal(u.searchParams.get("code_challenge"), codeChallenge);
|
|
401
|
-
assert.equal(u.searchParams.get("response_type"), "code");
|
|
402
|
-
assert.equal(u.searchParams.get("redirect_uri"), "http://127.0.0.1:5555/callback");
|
|
403
|
-
assert.ok(!url.includes(codeVerifier), "verifier never in the browser URL");
|
|
404
|
-
});
|