@bluenotelogic/harness 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/NOTICE +5 -0
- package/README.md +175 -20
- package/assets/antigravity-plugin/README.md +69 -0
- package/assets/antigravity-plugin/hooks.json +77 -0
- package/assets/antigravity-plugin/plugin.json +5 -0
- package/assets/antigravity-plugin/rules/proofmem.md +9 -0
- package/assets/antigravity-plugin/scripts/antigravity-hook.mjs +630 -0
- package/assets/antigravity-plugin/scripts/resolve-bash.mjs +48 -0
- package/dist/antigravity.d.ts +26 -0
- package/dist/antigravity.js +118 -0
- package/dist/antigravity.js.map +1 -0
- package/dist/bundle.d.ts +22 -0
- package/dist/bundle.js +36 -1
- package/dist/bundle.js.map +1 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.js +398 -64
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +44 -3
- package/dist/oauth.js +106 -11
- package/dist/oauth.js.map +1 -1
- package/dist/writers.d.ts +57 -15
- package/dist/writers.js +258 -58
- package/dist/writers.js.map +1 -1
- package/package.json +10 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* bnl-harness — point this machine at
|
|
3
|
+
* bnl-harness — point this machine at ProofMEM in one command.
|
|
4
4
|
*
|
|
5
5
|
* npx @bluenotelogic/harness init
|
|
6
6
|
*
|
|
@@ -10,29 +10,30 @@
|
|
|
10
10
|
*
|
|
11
11
|
* THE IMPORTANT PART — the `.mcp.json` this writes carries NO TOKEN:
|
|
12
12
|
*
|
|
13
|
-
* { "mcpServers": { "
|
|
13
|
+
* { "mcpServers": { "proofmem": { "type": "http", "url": "https://proofmem.com/mcp" } } }
|
|
14
14
|
*
|
|
15
15
|
* OAuth-capable hosts (Claude Code, VS Code) discover auth from the endpoint's 401 +
|
|
16
16
|
* WWW-Authenticate, run the browser flow against Blue Note ID, and store their own
|
|
17
17
|
* credentials. Nothing secret is written into a repo file, so the file is safe to commit
|
|
18
18
|
* and there is nothing to rotate.
|
|
19
19
|
*
|
|
20
|
-
* Codex and
|
|
21
|
-
*
|
|
22
|
-
* path creates no credential and no database row.
|
|
20
|
+
* Codex and hook subprocesses cannot do that yet, so token minting remains explicit: `--codex`
|
|
21
|
+
* writes only to `~/.codex/.env`, while `--antigravity` writes a separate owner-only hook key.
|
|
23
22
|
*/
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { join, resolve } from "node:path";
|
|
26
|
-
import { discover, registerClient, authorizeViaBrowser, authorizeViaDevice, } from "./oauth.js";
|
|
27
|
-
import {
|
|
28
|
-
import { planMcpJson,
|
|
29
|
-
|
|
30
|
-
const
|
|
26
|
+
import { discover, registerClient, authorizeViaBrowser, authorizeViaDevice, revokeRefreshToken, AccessDeniedError, } from "./oauth.js";
|
|
27
|
+
import { tenantTokenForSignIn, fetchBundle, SubscriptionRequiredError, IdentityIncompleteError, } from "./bundle.js";
|
|
28
|
+
import { planMcpJson, planCodexConfig, planEnvVar, legacyEnvVarsPresent, planRoutingFile, assertNoSecret, readIfExists, isNoop, apply, } from "./writers.js";
|
|
29
|
+
import { assertAntigravityInstallTarget, antigravityConfigHome, antigravityHookTokenPath, antigravityInstallPaths, planAntigravityInstall, } from "./antigravity.js";
|
|
30
|
+
const DEFAULT_BASE_URL = "https://proofmem.com";
|
|
31
|
+
const DEFAULT_CLIENT_ID = "proofmem-mcp";
|
|
31
32
|
/**
|
|
32
33
|
* `blue_note_identity`, NOT `email`. The two are easy to confuse and the failure is silent
|
|
33
34
|
* until the very last step:
|
|
34
35
|
*
|
|
35
|
-
* The `
|
|
36
|
+
* The `proofmem-mcp` provider has exactly four scope mappings attached — openid, profile,
|
|
36
37
|
* offline_access and blue_note_identity. The stock `email` mapping is deliberately NOT among
|
|
37
38
|
* them, and Authentik ignores a scope it has no mapping for rather than refusing it. So asking
|
|
38
39
|
* for `email` succeeded all the way through consent and the code exchange, then produced a
|
|
@@ -44,7 +45,14 @@ const DEFAULT_CLIENT_ID = "blue-note-mem-mcp";
|
|
|
44
45
|
* BlueNoteOidc.php:201 in caveau-mem, which is the same identity resolution this trades into.
|
|
45
46
|
*/
|
|
46
47
|
const DEFAULT_SCOPE = "openid profile blue_note_identity offline_access";
|
|
47
|
-
|
|
48
|
+
/** Where a Blue Note ID's email address is verified. */
|
|
49
|
+
const BLUE_NOTE_ID_URL = "https://id.bluenotelogic.com";
|
|
50
|
+
const TOKEN_ENV_VAR = "PROOFMEM_TOKEN";
|
|
51
|
+
/**
|
|
52
|
+
* Earlier names for the Codex token. Left in `~/.codex/.env` untouched — BNL staff machines still
|
|
53
|
+
* run tools that read them — and only ever named (never their values) in a notice.
|
|
54
|
+
*/
|
|
55
|
+
const LEGACY_TOKEN_ENV_VARS = ["BNL_MEMORY_MCP_TOKEN", "CAVEAU_MCP_TOKEN"];
|
|
48
56
|
/**
|
|
49
57
|
* `--timeout`, in seconds. Refused rather than coerced: a typo'd `--timeout abc` silently
|
|
50
58
|
* becoming NaN — and therefore an instant timeout — would be a second, quieter version of the
|
|
@@ -65,11 +73,15 @@ function parseArgs(argv) {
|
|
|
65
73
|
device: false,
|
|
66
74
|
token: null,
|
|
67
75
|
mintToken: false,
|
|
76
|
+
explicitMintToken: false,
|
|
68
77
|
harnessHome: null,
|
|
69
78
|
serverName: null,
|
|
70
79
|
clientId: DEFAULT_CLIENT_ID,
|
|
71
80
|
openBrowser: true,
|
|
72
81
|
codex: false,
|
|
82
|
+
antigravity: false,
|
|
83
|
+
antigravityHome: null,
|
|
84
|
+
verifyGate: false,
|
|
73
85
|
timeoutMs: null,
|
|
74
86
|
};
|
|
75
87
|
let command = "init";
|
|
@@ -102,11 +114,22 @@ function parseArgs(argv) {
|
|
|
102
114
|
break;
|
|
103
115
|
case "--mint-token":
|
|
104
116
|
opts.mintToken = true;
|
|
117
|
+
opts.explicitMintToken = true;
|
|
105
118
|
break;
|
|
106
119
|
case "--codex":
|
|
107
120
|
opts.codex = true;
|
|
108
121
|
opts.mintToken = true;
|
|
109
122
|
break;
|
|
123
|
+
case "--antigravity":
|
|
124
|
+
opts.antigravity = true;
|
|
125
|
+
opts.mintToken = true;
|
|
126
|
+
break;
|
|
127
|
+
case "--antigravity-home":
|
|
128
|
+
opts.antigravityHome = resolve(next());
|
|
129
|
+
break;
|
|
130
|
+
case "--verify-gate":
|
|
131
|
+
opts.verifyGate = true;
|
|
132
|
+
break;
|
|
110
133
|
case "--harness-home":
|
|
111
134
|
opts.harnessHome = resolve(next());
|
|
112
135
|
break;
|
|
@@ -136,18 +159,24 @@ function parseArgs(argv) {
|
|
|
136
159
|
return { command, opts };
|
|
137
160
|
}
|
|
138
161
|
const HELP = `
|
|
139
|
-
bnl-harness — configure this machine for
|
|
162
|
+
bnl-harness — configure this machine for ProofMEM
|
|
140
163
|
|
|
141
164
|
npx @bluenotelogic/harness init [options]
|
|
165
|
+
npx @bluenotelogic/harness doctor [options]
|
|
142
166
|
|
|
143
167
|
Options
|
|
144
168
|
--url <base> Server base URL (default ${DEFAULT_BASE_URL})
|
|
145
169
|
--dir <path> Repo to configure (default: current directory)
|
|
146
|
-
--dry-run Show every file that would change; write nothing
|
|
170
|
+
--dry-run Show every file that would change; write nothing and mint no
|
|
171
|
+
long-lived token (signing in still creates one 1-hour session key)
|
|
147
172
|
--device Device-code login, for headless boxes and SSH sessions
|
|
148
173
|
--token <token> Use an existing cmem_ token instead of logging in (CI)
|
|
149
174
|
--mint-token Also mint a token for hosts that cannot do OAuth
|
|
150
175
|
--codex Configure Codex too (implies --mint-token)
|
|
176
|
+
--antigravity Install the ProofMEM Antigravity plugin (implies --mint-token)
|
|
177
|
+
--antigravity-home Override Antigravity config home (default ~/.gemini/config)
|
|
178
|
+
--verify-gate Antigravity: run the open project's own build/test/lint when the
|
|
179
|
+
agent stops (off by default: it runs that project's code)
|
|
151
180
|
--harness-home <dir> Also materialize hosted routing into <dir>/state/routing/
|
|
152
181
|
--server-name <name> Override the MCP server key written into .mcp.json
|
|
153
182
|
--client-id <id> OAuth client_id (default ${DEFAULT_CLIENT_ID})
|
|
@@ -157,8 +186,8 @@ Options
|
|
|
157
186
|
-h, --help This text
|
|
158
187
|
-v, --version Version
|
|
159
188
|
|
|
160
|
-
By default nothing secret is written:
|
|
161
|
-
|
|
189
|
+
By default nothing secret is written: OAuth-capable hosts keep their own credentials. The
|
|
190
|
+
Antigravity plugin also stays tokenless; only its lifecycle hooks use a separate owner-only key.
|
|
162
191
|
`;
|
|
163
192
|
async function main() {
|
|
164
193
|
const { command, opts } = parseArgs(process.argv.slice(2));
|
|
@@ -170,8 +199,26 @@ async function main() {
|
|
|
170
199
|
console.log(await version());
|
|
171
200
|
return;
|
|
172
201
|
}
|
|
202
|
+
if (command === "doctor") {
|
|
203
|
+
runDoctor(opts);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
173
206
|
if (command !== "init")
|
|
174
207
|
fail(`unknown command: ${command}\nRun \`bnl-harness --help\`.`);
|
|
208
|
+
if (opts.antigravity && !opts.harnessHome) {
|
|
209
|
+
const fromEnv = process.env["HARNESS_HOME"];
|
|
210
|
+
if (fromEnv)
|
|
211
|
+
opts.harnessHome = resolve(fromEnv);
|
|
212
|
+
}
|
|
213
|
+
if (opts.antigravity && !opts.harnessHome) {
|
|
214
|
+
fail("--antigravity requires --harness-home <caveau-harness-v2> (or HARNESS_HOME).\n" +
|
|
215
|
+
"The Antigravity hooks need a Caveau Harness v2 checkout, set up with our team in a guided Caveau Harness session; contact ProofMEM to book one.");
|
|
216
|
+
}
|
|
217
|
+
if (opts.harnessHome)
|
|
218
|
+
assertHarnessV2(opts.harnessHome);
|
|
219
|
+
if (opts.antigravity) {
|
|
220
|
+
assertAntigravityInstallTarget(antigravityConfigHome(opts.antigravityHome));
|
|
221
|
+
}
|
|
175
222
|
const bundle = await getBundle(opts);
|
|
176
223
|
const changes = planAll(bundle, opts);
|
|
177
224
|
report(changes, opts, bundle);
|
|
@@ -181,40 +228,276 @@ async function main() {
|
|
|
181
228
|
if (!opts.dryRun)
|
|
182
229
|
printNextSteps(bundle, opts, changes);
|
|
183
230
|
}
|
|
184
|
-
|
|
231
|
+
const CODEX_BLOCK_START = "# BEGIN @bluenotelogic/harness managed MCP";
|
|
232
|
+
const CODEX_BLOCK_END = "# END @bluenotelogic/harness managed MCP";
|
|
233
|
+
const HARNESS_GENERATION = "caveau-harness-v2";
|
|
234
|
+
const TOKEN_PATTERN = /\b(?:cmem|mcp)_[A-Za-z0-9_-]{16,}/;
|
|
235
|
+
/** Refuse an archived or unidentified harness before writing hosted routing into it. */
|
|
236
|
+
function assertHarnessV2(harnessHome) {
|
|
237
|
+
const markerPath = join(harnessHome, ".harness-generation");
|
|
238
|
+
const marker = readIfExists(markerPath)?.trim();
|
|
239
|
+
if (marker !== HARNESS_GENERATION) {
|
|
240
|
+
const observed = marker ? `generation ${JSON.stringify(marker)}` : "no .harness-generation marker";
|
|
241
|
+
fail(`HARNESS_HOME must identify ${HARNESS_GENERATION}; ${harnessHome} has ${observed}. ` +
|
|
242
|
+
"The archived caveau-harness tree is not supported.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Read-only installation validation. It deliberately never prints credential values. */
|
|
246
|
+
function runDoctor(opts) {
|
|
247
|
+
const failures = [];
|
|
248
|
+
const warnings = [];
|
|
249
|
+
let harnessHome = opts.harnessHome ?? process.env["HARNESS_HOME"] ?? null;
|
|
250
|
+
const checkCodex = !opts.antigravity || opts.codex;
|
|
251
|
+
if (checkCodex) {
|
|
252
|
+
const codexHome = process.env["CODEX_HOME"] ?? join(homedir(), ".codex");
|
|
253
|
+
const configPath = join(codexHome, "config.toml");
|
|
254
|
+
const envPath = join(codexHome, ".env");
|
|
255
|
+
const config = readIfExists(configPath);
|
|
256
|
+
const envFile = readIfExists(envPath);
|
|
257
|
+
if (config === null) {
|
|
258
|
+
failures.push(`missing Codex config: ${configPath}`);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const startCount = config.split(CODEX_BLOCK_START).length - 1;
|
|
262
|
+
const endCount = config.split(CODEX_BLOCK_END).length - 1;
|
|
263
|
+
if (startCount !== 1 || endCount !== 1) {
|
|
264
|
+
failures.push("Codex config must contain exactly one installer-managed MCP block");
|
|
265
|
+
}
|
|
266
|
+
if (!config.includes(`bearer_token_env_var = "${TOKEN_ENV_VAR}"`)) {
|
|
267
|
+
failures.push(`Codex config must reference ${TOKEN_ENV_VAR}`);
|
|
268
|
+
}
|
|
269
|
+
if (TOKEN_PATTERN.test(config) || /authorization\s*=\s*["']?bearer\s+/i.test(config)) {
|
|
270
|
+
failures.push("Codex config contains a literal bearer credential");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (envFile === null || !new RegExp(`^${TOKEN_ENV_VAR}=\\S+`, "m").test(envFile)) {
|
|
274
|
+
failures.push(`Codex owner credential store must define ${TOKEN_ENV_VAR}`);
|
|
275
|
+
}
|
|
276
|
+
for (const legacy of legacyEnvVarsPresent(envFile, LEGACY_TOKEN_ENV_VARS)) {
|
|
277
|
+
warnings.push(`${legacy} is a legacy variable in ${envPath}; remove it by hand once nothing uses it`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (opts.antigravity) {
|
|
281
|
+
const configHome = antigravityConfigHome(opts.antigravityHome);
|
|
282
|
+
const { pluginRoot, tokenFile } = antigravityInstallPaths(configHome);
|
|
283
|
+
const manifestRaw = readIfExists(join(pluginRoot, "plugin.json"));
|
|
284
|
+
const hooks = readIfExists(join(pluginRoot, "hooks.json"));
|
|
285
|
+
const mcpRaw = readIfExists(join(pluginRoot, "mcp_config.json"));
|
|
286
|
+
const localRaw = readIfExists(join(pluginRoot, "harness-home.json"));
|
|
287
|
+
try {
|
|
288
|
+
const manifest = JSON.parse(manifestRaw ?? "null");
|
|
289
|
+
if (manifest?.["name"] !== "proofmem")
|
|
290
|
+
throw new Error("missing proofmem manifest");
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
failures.push(`Antigravity plugin manifest is missing or invalid: ${pluginRoot}`);
|
|
294
|
+
}
|
|
295
|
+
if (hooks === null
|
|
296
|
+
|| !hooks.includes("node ./scripts/antigravity-hook.mjs session-start")
|
|
297
|
+
|| hooks.includes('node \\"./scripts/antigravity-hook.mjs')) {
|
|
298
|
+
failures.push("Antigravity hooks are missing or use Windows-unsafe quoted script paths");
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const mcp = JSON.parse(mcpRaw ?? "null");
|
|
302
|
+
const servers = mcp?.["mcpServers"];
|
|
303
|
+
const server = servers && Object.values(servers)[0];
|
|
304
|
+
if (!server || typeof server["serverUrl"] !== "string")
|
|
305
|
+
throw new Error("missing serverUrl");
|
|
306
|
+
for (const forbidden of ["command", "args", "headers", "oauth", "env"]) {
|
|
307
|
+
if (Object.hasOwn(server, forbidden))
|
|
308
|
+
throw new Error(`forbidden ${forbidden}`);
|
|
309
|
+
}
|
|
310
|
+
if (TOKEN_PATTERN.test(mcpRaw ?? "") || /authorization\s*["':=]+\s*["']?bearer\s+/i.test(mcpRaw ?? "")) {
|
|
311
|
+
throw new Error("literal credential");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
failures.push(`Antigravity MCP config is not a tokenless native HTTP server (${error.message})`);
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const local = JSON.parse(localRaw ?? "null");
|
|
319
|
+
if (typeof local?.["harnessHome"] !== "string")
|
|
320
|
+
throw new Error("missing harnessHome");
|
|
321
|
+
if (typeof local?.["hookTokenFile"] !== "string")
|
|
322
|
+
throw new Error("missing hookTokenFile");
|
|
323
|
+
if (resolve(String(local["hookTokenFile"])) !== resolve(tokenFile)) {
|
|
324
|
+
throw new Error("hook token path is outside the installer-owned credential store");
|
|
325
|
+
}
|
|
326
|
+
harnessHome ??= String(local["harnessHome"]);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
failures.push(`Antigravity local Harness config is missing or invalid (${error.message})`);
|
|
330
|
+
}
|
|
331
|
+
const hookToken = readIfExists(tokenFile)?.trim() ?? "";
|
|
332
|
+
if (!/^(?:cmem|mcp)_[A-Za-z0-9_-]{16,}$/.test(hookToken)) {
|
|
333
|
+
failures.push("Antigravity owner credential store is missing or invalid");
|
|
334
|
+
}
|
|
335
|
+
// The hook credential is bounded (90 days). Expired, every hook fails open with no memory, so
|
|
336
|
+
// say so here rather than leaving the user to read 401 diagnostics in a session.
|
|
337
|
+
let expiresAt;
|
|
338
|
+
try {
|
|
339
|
+
const raw = JSON.parse(localRaw ?? "null")?.["hookTokenExpiresAt"];
|
|
340
|
+
expiresAt = typeof raw === "string" ? raw : undefined;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* reported above */
|
|
344
|
+
}
|
|
345
|
+
const expiresMs = expiresAt ? Date.parse(expiresAt) : NaN;
|
|
346
|
+
const renew = "rerun `init --antigravity` to renew it";
|
|
347
|
+
if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
|
|
348
|
+
failures.push(`Antigravity hook credential expired at ${expiresAt}; ${renew}`);
|
|
349
|
+
}
|
|
350
|
+
else if (!Number.isNaN(expiresMs) && expiresMs - Date.now() <= 14 * 24 * 60 * 60 * 1000) {
|
|
351
|
+
warnings.push(`Antigravity hook credential expires at ${expiresAt}; ${renew}`);
|
|
352
|
+
}
|
|
353
|
+
// Every hook runs the Harness core through bash. Without it they all fail open silently.
|
|
354
|
+
const resolver = join(pluginRoot, "scripts", "resolve-bash.mjs");
|
|
355
|
+
try {
|
|
356
|
+
execFileSync(process.execPath, [resolver, "-c", "exit 0"], { stdio: "ignore", timeout: 15_000, windowsHide: true });
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
failures.push("Antigravity hooks cannot find bash (on Windows, install Git for Windows; WSL bash is not used)");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (!harnessHome) {
|
|
363
|
+
failures.push("HARNESS_HOME is not set");
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const marker = readIfExists(join(harnessHome, ".harness-generation"))?.trim();
|
|
367
|
+
if (marker !== HARNESS_GENERATION) {
|
|
368
|
+
failures.push(`HARNESS_HOME must point at ${HARNESS_GENERATION}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const repoMcpPath = join(opts.dir, ".mcp.json");
|
|
372
|
+
const repoMcp = readIfExists(repoMcpPath);
|
|
373
|
+
if (repoMcp !== null && (TOKEN_PATTERN.test(repoMcp) || /authorization\s*["':=]+\s*["']?bearer\s+/i.test(repoMcp))) {
|
|
374
|
+
failures.push(`${repoMcpPath} contains a literal bearer credential`);
|
|
375
|
+
}
|
|
376
|
+
console.log("bnl-harness doctor");
|
|
377
|
+
if (checkCodex) {
|
|
378
|
+
console.log(` Codex config ${failures.some((v) => v.startsWith("Codex config") || v.startsWith("missing Codex")) ? "FAIL" : "ok"}`);
|
|
379
|
+
}
|
|
380
|
+
if (opts.antigravity) {
|
|
381
|
+
console.log(` Antigravity ${failures.some((v) => v.startsWith("Antigravity")) ? "FAIL" : "ok"}`);
|
|
382
|
+
}
|
|
383
|
+
console.log(` credential ${failures.some((v) => v.includes("credential store")) ? "FAIL" : "ok"}`);
|
|
384
|
+
console.log(` HARNESS_HOME ${failures.some((v) => v.startsWith("HARNESS_HOME")) ? "FAIL" : "ok"}`);
|
|
385
|
+
console.log(` repo config ${failures.some((v) => v.startsWith(repoMcpPath)) ? "FAIL" : "ok"}`);
|
|
386
|
+
for (const warning of warnings)
|
|
387
|
+
console.log(` WARN ${warning}`);
|
|
388
|
+
if (failures.length)
|
|
389
|
+
fail(`doctor found ${failures.length} problem(s):\n- ${failures.join("\n- ")}`);
|
|
390
|
+
console.log("\nInstallation is healthy.");
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* `--codex --antigravity` used to write ONE minted value into both ~/.codex/.env and the
|
|
394
|
+
* Antigravity hook-token file, so the "separate hook credential" was not separate and revoking
|
|
395
|
+
* one revoked both. When both stores are written, the hook gets its own mint from this sign-in.
|
|
396
|
+
*/
|
|
397
|
+
async function withHookToken(opts, credential, bundle) {
|
|
398
|
+
if (!opts.antigravity || !(opts.codex || opts.explicitMintToken) || opts.dryRun)
|
|
399
|
+
return bundle;
|
|
400
|
+
const second = await fetchBundle(opts.baseUrl, credential, { mintToken: true });
|
|
401
|
+
return { ...bundle, hookToken: second.token };
|
|
402
|
+
}
|
|
185
403
|
async function getBundle(opts) {
|
|
404
|
+
const mintToken = opts.mintToken && !opts.dryRun;
|
|
186
405
|
if (opts.token) {
|
|
187
406
|
log(`Using the token supplied on the command line (skipping login).`);
|
|
188
|
-
return fetchBundle(opts.baseUrl, opts.token, { mintToken
|
|
407
|
+
return withHookToken(opts, opts.token, await fetchBundle(opts.baseUrl, opts.token, { mintToken }));
|
|
189
408
|
}
|
|
190
409
|
log(`Discovering ${opts.baseUrl}…`);
|
|
191
410
|
const meta = await discover(opts.baseUrl);
|
|
192
411
|
const clientId = await registerClient(meta, opts.clientId);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
412
|
+
let tokens;
|
|
413
|
+
try {
|
|
414
|
+
tokens = opts.device
|
|
415
|
+
? await authorizeViaDevice({
|
|
416
|
+
meta, clientId, scope: DEFAULT_SCOPE, resource: `${opts.baseUrl}/mcp`,
|
|
417
|
+
timeoutMs: opts.timeoutMs ?? undefined, log,
|
|
418
|
+
})
|
|
419
|
+
: await authorizeViaBrowser({
|
|
420
|
+
meta, clientId, scope: DEFAULT_SCOPE, resource: `${opts.baseUrl}/mcp`,
|
|
421
|
+
timeoutMs: opts.timeoutMs ?? undefined, openBrowser: opts.openBrowser, log,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
// No token was issued, so there is no refresh grant to end here.
|
|
426
|
+
if (e instanceof AccessDeniedError)
|
|
427
|
+
fail(signInRefusedMessage(opts.baseUrl, e.flow));
|
|
428
|
+
throw e;
|
|
429
|
+
}
|
|
430
|
+
// The sign-in's refresh token is never used: end it on every way out of here, success or not
|
|
431
|
+
// (see revokeRefreshToken). Before fail(), too — fail() exits without running a finally.
|
|
432
|
+
const endSignIn = async () => {
|
|
433
|
+
if (!tokens.refresh_token)
|
|
434
|
+
return;
|
|
435
|
+
if (!(await revokeRefreshToken(meta, clientId, tokens.refresh_token))) {
|
|
436
|
+
log("Note: the server did not confirm ending this sign-in's refresh grant. This CLI never uses it;\n" +
|
|
437
|
+
" it lapses on its own when it expires (through proofmem.com, within 35 days).");
|
|
438
|
+
}
|
|
439
|
+
};
|
|
202
440
|
log("Signed in. Resolving your memory tenant…");
|
|
203
441
|
let tenantToken;
|
|
204
442
|
try {
|
|
205
|
-
|
|
443
|
+
// Through the proofmem.com gateway the access token already is the tenant key; only a Blue
|
|
444
|
+
// Note ID token is exchanged (see tenantTokenForSignIn).
|
|
445
|
+
tenantToken = await tenantTokenForSignIn(opts.baseUrl, tokens.access_token);
|
|
206
446
|
}
|
|
207
447
|
catch (e) {
|
|
448
|
+
await endSignIn();
|
|
208
449
|
if (e instanceof SubscriptionRequiredError) {
|
|
209
450
|
// Authenticated fine — they just do not have the product. Say that, and say where to
|
|
210
451
|
// go, rather than leaving them re-trying a login that already worked.
|
|
211
452
|
fail(`${e.message}\n\n` +
|
|
212
|
-
`Your Blue Note Account signed in successfully — it just has no
|
|
453
|
+
`Your Blue Note Account signed in successfully — it just has no ProofMEM\n` +
|
|
213
454
|
`subscription attached. Start one at ${opts.baseUrl}/pricing.php`);
|
|
214
455
|
}
|
|
456
|
+
if (e instanceof IdentityIncompleteError) {
|
|
457
|
+
// Same shape of mistake as above: the login worked. Do not let this read as an auth
|
|
458
|
+
// failure, or the user re-runs `init` forever against a problem no login can fix.
|
|
459
|
+
fail(`${e.message}\n\n` +
|
|
460
|
+
`Your Blue Note Account signed in successfully — the sign-in itself is not the problem,\n` +
|
|
461
|
+
`so running this again will not help until the account or client scope is corrected.`);
|
|
462
|
+
}
|
|
215
463
|
throw e;
|
|
216
464
|
}
|
|
217
|
-
|
|
465
|
+
try {
|
|
466
|
+
return await withHookToken(opts, tenantToken, await fetchBundle(opts.baseUrl, tenantToken, { mintToken }));
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
await endSignIn();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* What an `access_denied` from the token endpoint means (AccessDeniedError). Through proofmem.com
|
|
474
|
+
* the gateway answers it for a Blue Note ID sign-in that WORKED, when the account has no ProofMEM
|
|
475
|
+
* subscription or no verified email; printed raw it read as a failed login and sent people round
|
|
476
|
+
* the sign-in again. On the device flow the request may also have been declined. Nothing from the
|
|
477
|
+
* server's response is echoed.
|
|
478
|
+
*/
|
|
479
|
+
function signInRefusedMessage(baseUrl, flow) {
|
|
480
|
+
const opening = flow === "device"
|
|
481
|
+
? `${baseUrl} would not issue a ProofMEM key for this sign-in (access_denied).\n` +
|
|
482
|
+
`The cause is one of:\n` +
|
|
483
|
+
` - The request was declined on the approval page.\n`
|
|
484
|
+
: `Blue Note ID signed you in, but ${baseUrl} would not issue a ProofMEM key\n` +
|
|
485
|
+
`for this account (access_denied). Signing in again will not change that. The cause is one of:\n`;
|
|
486
|
+
return (opening +
|
|
487
|
+
` - The account has no ProofMEM subscription. Start one at ${baseUrl}/pricing.php\n` +
|
|
488
|
+
` - The Blue Note ID has no verified email address. Verify it at ${BLUE_NOTE_ID_URL}\n` +
|
|
489
|
+
`Then run this command again.`);
|
|
490
|
+
}
|
|
491
|
+
/** The server's expiry ("2027-01-01 00:00:00Z" or ISO) as ISO-8601, or undefined if unparseable. */
|
|
492
|
+
function isoExpiry(raw) {
|
|
493
|
+
if (!raw)
|
|
494
|
+
return undefined;
|
|
495
|
+
const at = Date.parse(raw.trim().replace(" ", "T"));
|
|
496
|
+
return Number.isNaN(at) ? undefined : new Date(at).toISOString();
|
|
497
|
+
}
|
|
498
|
+
/** The token the Antigravity hooks get: its own mint when Codex also gets one, else the one mint. */
|
|
499
|
+
function antigravityHookToken(bundle, opts) {
|
|
500
|
+
return opts.codex || opts.explicitMintToken ? bundle.hookToken : bundle.token;
|
|
218
501
|
}
|
|
219
502
|
/** Everything this run would write, as data. Nothing here touches the filesystem twice. */
|
|
220
503
|
function planAll(bundle, opts) {
|
|
@@ -229,20 +512,55 @@ function planAll(bundle, opts) {
|
|
|
229
512
|
const mcpPath = join(opts.dir, ".mcp.json");
|
|
230
513
|
changes.push(planMcpJson(mcpPath, readIfExists(mcpPath), servers));
|
|
231
514
|
// --- Optional: a real credential, for hosts that cannot do OAuth. ---------------------
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
515
|
+
//
|
|
516
|
+
// 🔴 NEVER a repo-tree write, not even .claude/settings.local.json (Codex review,
|
|
517
|
+
// 2026-08-26: that file used to carry the literal token so the VS Code extension's editor
|
|
518
|
+
// button -- which launches `claude` without sourcing a shell env file -- could see it). Per
|
|
519
|
+
// the bundle's own declared auth split (bundle.auth.oauth_hosts/token_hosts), Claude Code
|
|
520
|
+
// and VS Code are OAuth-capable and need no token at all; minting exists for Codex/CI, which
|
|
521
|
+
// read ~/.codex/.env, a user-level file outside every repo. A VS Code user who wants the
|
|
522
|
+
// token-based flow no longer gets an editor-button shortcut -- they get OAuth instead, which
|
|
523
|
+
// is the tokenless path this tool exists to make the default.
|
|
524
|
+
//
|
|
525
|
+
// opts.mintToken alone gates this (not bundle.token): a --dry-run never requests a mint
|
|
526
|
+
// (see getBundle()), so bundle.token is never present here during a preview, but the preview
|
|
527
|
+
// must still show what a real run would create -- with an inert placeholder standing in for
|
|
528
|
+
// the value, and every such change marked `sensitive` so report() never echoes it either way.
|
|
529
|
+
if (opts.mintToken) {
|
|
530
|
+
const minted = bundle.token?.value;
|
|
531
|
+
if (!opts.dryRun && !minted) {
|
|
532
|
+
fail("--mint-token/--codex/--antigravity was requested but the server returned no token; " +
|
|
533
|
+
"refusing to write a placeholder in place of a real credential.");
|
|
534
|
+
}
|
|
535
|
+
const tokenValue = minted ?? "<minted on --apply, not shown here>";
|
|
536
|
+
const serverName = opts.serverName ?? Object.keys(servers)[0] ?? "proofmem";
|
|
537
|
+
// A bare --mint-token retains the original Codex-env behavior. --antigravity by itself does
|
|
538
|
+
// not create or modify Codex files: its one credential belongs only to hook subprocesses.
|
|
539
|
+
if (opts.codex || opts.explicitMintToken) {
|
|
540
|
+
const codexHome = process.env["CODEX_HOME"] ?? join(homedir(), ".codex");
|
|
541
|
+
if (opts.codex) {
|
|
542
|
+
const cfg = join(codexHome, "config.toml");
|
|
543
|
+
changes.push(planCodexConfig(cfg, readIfExists(cfg), serverName, bundle.endpoint.mcp_url, TOKEN_ENV_VAR));
|
|
544
|
+
}
|
|
545
|
+
const envPath = join(codexHome, ".env");
|
|
546
|
+
changes.push(planEnvVar(envPath, readIfExists(envPath), TOKEN_ENV_VAR, tokenValue, LEGACY_TOKEN_ENV_VARS));
|
|
547
|
+
}
|
|
548
|
+
if (opts.antigravity) {
|
|
549
|
+
const hook = antigravityHookToken(bundle, opts);
|
|
550
|
+
if (!opts.dryRun && !hook?.value) {
|
|
551
|
+
fail("--antigravity was requested but the server returned no hook token; refusing to write a placeholder.");
|
|
552
|
+
}
|
|
553
|
+
const configHome = antigravityConfigHome(opts.antigravityHome);
|
|
554
|
+
changes.push(...planAntigravityInstall({
|
|
555
|
+
configHome,
|
|
556
|
+
harnessHome: opts.harnessHome,
|
|
557
|
+
mcpUrl: bundle.endpoint.mcp_url,
|
|
558
|
+
serverName,
|
|
559
|
+
hookToken: hook?.value ?? tokenValue,
|
|
560
|
+
hookTokenExpiresAt: isoExpiry(hook?.expires_at),
|
|
561
|
+
verifyGate: opts.verifyGate,
|
|
562
|
+
}));
|
|
239
563
|
}
|
|
240
|
-
const envPath = join(codexHome, ".env");
|
|
241
|
-
changes.push(planEnvVar(envPath, readIfExists(envPath), TOKEN_ENV_VAR, minted));
|
|
242
|
-
// The VS Code extension launches `claude` without a shell, so an env block in
|
|
243
|
-
// settings.local.json is what makes a token-based setup work from the editor button.
|
|
244
|
-
const settings = join(opts.dir, ".claude", "settings.local.json");
|
|
245
|
-
changes.push(planSettingsLocal(settings, readIfExists(settings), { [TOKEN_ENV_VAR]: minted }));
|
|
246
564
|
}
|
|
247
565
|
// --- Optional: materialize the hosted routing the console edits. -----------------------
|
|
248
566
|
if (opts.harnessHome && bundle.routing) {
|
|
@@ -259,16 +577,20 @@ function planAll(bundle, opts) {
|
|
|
259
577
|
return changes;
|
|
260
578
|
}
|
|
261
579
|
/**
|
|
262
|
-
* The secret guard applies to REPO files only. `~/.codex/.env`
|
|
263
|
-
*
|
|
264
|
-
*
|
|
580
|
+
* The secret guard applies to REPO files only. `~/.codex/.env` is the one place a minted
|
|
581
|
+
* token is supposed to live — asserting against it would forbid the very thing `--mint-token`
|
|
582
|
+
* is for. Nothing else is exempt: in particular, no path under `opts.dir` (the target repo)
|
|
583
|
+
* ever qualifies, which is what makes this guard meaningful rather than a formality.
|
|
265
584
|
*/
|
|
266
585
|
function assertNoSecret2(c, opts) {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
join(process.env["CODEX_HOME"] ?? join(homedir(), ".codex"), ".env")
|
|
270
|
-
|
|
271
|
-
if (
|
|
586
|
+
const allowedCredentialStores = new Set();
|
|
587
|
+
if (opts.codex || opts.explicitMintToken) {
|
|
588
|
+
allowedCredentialStores.add(join(process.env["CODEX_HOME"] ?? join(homedir(), ".codex"), ".env"));
|
|
589
|
+
}
|
|
590
|
+
if (opts.antigravity) {
|
|
591
|
+
allowedCredentialStores.add(antigravityHookTokenPath(antigravityConfigHome(opts.antigravityHome)));
|
|
592
|
+
}
|
|
593
|
+
if (allowedCredentialStores.has(c.path))
|
|
272
594
|
return;
|
|
273
595
|
assertNoSecret(c);
|
|
274
596
|
}
|
|
@@ -284,13 +606,17 @@ function report(changes, opts, bundle) {
|
|
|
284
606
|
console.log(` SKIP ${c.path}\n ${c.skipped.replace(/\n/g, "\n ")}`);
|
|
285
607
|
continue;
|
|
286
608
|
}
|
|
287
|
-
|
|
288
|
-
|
|
609
|
+
console.log(` ${isNoop(c) ? "same " : c.before === null ? "create" : "update"} ${c.path}`);
|
|
610
|
+
if (c.notice)
|
|
611
|
+
console.log(` NOTICE ${c.notice}`);
|
|
612
|
+
if (isNoop(c))
|
|
289
613
|
continue;
|
|
290
|
-
}
|
|
291
|
-
console.log(` ${c.before === null ? "create" : "update"} ${c.path}`);
|
|
292
614
|
if (opts.dryRun && c.after) {
|
|
293
|
-
|
|
615
|
+
// Sensitive changes never echo `after` -- not the real value on an apply preview, and
|
|
616
|
+
// not even the inert placeholder, so a dry-run's output looks identical either way and
|
|
617
|
+
// teaches nobody to expect a real secret to show up here (Codex review, 2026-08-26).
|
|
618
|
+
const shown = c.sensitive ? "[redacted — holds a credential; not shown]" : c.after;
|
|
619
|
+
const body = shown.split("\n").map((l) => ` │ ${l}`).join("\n");
|
|
294
620
|
console.log(body.replace(/\n\s+│\s*$/, ""));
|
|
295
621
|
}
|
|
296
622
|
}
|
|
@@ -299,16 +625,24 @@ function report(changes, opts, bundle) {
|
|
|
299
625
|
console.log(" --dry-run: nothing was written.\n");
|
|
300
626
|
}
|
|
301
627
|
function printNextSteps(bundle, opts, changes) {
|
|
302
|
-
const oauth = !bundle.token;
|
|
303
628
|
console.log("Done.\n");
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
console.log(` 1. Open ${opts.dir} in Claude Code or VS Code`);
|
|
629
|
+
console.log(" Your .mcp.json carries no token — OAuth-capable hosts authenticate on first use");
|
|
630
|
+
console.log(" and keep their own credentials. It is safe to commit.\n");
|
|
631
|
+
console.log(` 1. Open ${opts.dir} in your editor`);
|
|
309
632
|
console.log(` 2. The '${Object.keys(bundle.mcp_json.mcpServers)[0]}' server appears automatically`);
|
|
310
|
-
if (bundle.token) {
|
|
311
|
-
|
|
633
|
+
if (bundle.token && (opts.codex || opts.explicitMintToken)) {
|
|
634
|
+
const expiry = bundle.token.expires_at ? `, expires ${bundle.token.expires_at}` : "";
|
|
635
|
+
console.log(` 3. A token was minted into ${TOKEN_ENV_VAR} — it is in your Codex env file, not this repo${expiry}`);
|
|
636
|
+
}
|
|
637
|
+
const hook = antigravityHookToken(bundle, opts);
|
|
638
|
+
if (hook && opts.antigravity) {
|
|
639
|
+
const expiry = hook.expires_at ? `; it expires ${hook.expires_at}` : "";
|
|
640
|
+
console.log(" 3. Restart Antigravity, enable the ProofMEM plugin, then authenticate its MCP server.");
|
|
641
|
+
console.log(` Its separate hook credential is owner-only and is never placed in MCP config${expiry}.`);
|
|
642
|
+
console.log(" To renew it, run this same command again (`doctor --antigravity` warns 14 days ahead).");
|
|
643
|
+
console.log(opts.verifyGate
|
|
644
|
+
? " The stop-time verify gate is ON: the open project's build/test/lint runs when the agent stops."
|
|
645
|
+
: " The stop-time verify gate is off; pass --verify-gate to run the project's build/test on stop.");
|
|
312
646
|
}
|
|
313
647
|
const notes = changes.filter((c) => c.note && !isNoop(c));
|
|
314
648
|
if (notes.length) {
|