@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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bnl-harness — point this machine at Blue Note Memory in one command.
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": { "mem": { "type": "http", "url": "https://mem.bnlgit.com/mcp" } } }
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 CI cannot do that yet, so `--mint-token` exists for them — and it writes the
21
- * token to `~/.codex/.env`, never into a repo. Minting is OPT-IN precisely so the default
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 { exchangeForTenantToken, fetchBundle, SubscriptionRequiredError, } from "./bundle.js";
28
- import { planMcpJson, planSettingsLocal, planCodexConfig, planEnvVar, planRoutingFile, assertNoSecret, readIfExists, isNoop, apply, } from "./writers.js";
29
- const DEFAULT_BASE_URL = "https://mem.bnlgit.com";
30
- const DEFAULT_CLIENT_ID = "blue-note-mem-mcp";
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 `blue-note-mem-mcp` provider has exactly four scope mappings attached — openid, profile,
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
- const TOKEN_ENV_VAR = "BNL_MEMORY_MCP_TOKEN";
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 Blue Note Memory
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: Claude Code and VS Code authenticate over OAuth and
161
- keep their own credentials, so the .mcp.json this produces is safe to commit.
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
- /** Authenticate (unless given a token) and fetch the bundle. */
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: opts.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
- const tokens = opts.device
194
- ? await authorizeViaDevice({
195
- meta, clientId, scope: DEFAULT_SCOPE, resource: `${opts.baseUrl}/mcp`,
196
- timeoutMs: opts.timeoutMs ?? undefined, log,
197
- })
198
- : await authorizeViaBrowser({
199
- meta, clientId, scope: DEFAULT_SCOPE, resource: `${opts.baseUrl}/mcp`,
200
- timeoutMs: opts.timeoutMs ?? undefined, openBrowser: opts.openBrowser, log,
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
- tenantToken = await exchangeForTenantToken(opts.baseUrl, tokens.access_token);
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 Blue Note Memory\n` +
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
- return fetchBundle(opts.baseUrl, tenantToken, { mintToken: opts.mintToken });
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
- const minted = bundle.token?.value;
233
- if (minted) {
234
- const codexHome = process.env["CODEX_HOME"] ?? join(homedir(), ".codex");
235
- const serverName = opts.serverName ?? Object.keys(servers)[0] ?? "mem";
236
- if (opts.codex) {
237
- const cfg = join(codexHome, "config.toml");
238
- changes.push(planCodexConfig(cfg, readIfExists(cfg), serverName, bundle.endpoint.mcp_url, TOKEN_ENV_VAR));
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` and
263
- * `.claude/settings.local.json` are where a minted token is supposed to live — asserting
264
- * against them would forbid the very thing `--mint-token` is for.
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 credentialStores = [
268
- join(opts.dir, ".claude", "settings.local.json"),
269
- join(process.env["CODEX_HOME"] ?? join(homedir(), ".codex"), ".env"),
270
- ];
271
- if (credentialStores.includes(c.path))
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
- if (isNoop(c)) {
288
- console.log(` same ${c.path}`);
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
- const body = c.after.split("\n").map((l) => ` │ ${l}`).join("\n");
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
- if (oauth) {
305
- console.log(" Your .mcp.json carries no token — Claude Code and VS Code will authenticate");
306
- console.log(" over OAuth on first use and keep their own credentials. It is safe to commit.\n");
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
- console.log(` 3. A token was minted into ${TOKEN_ENV_VAR} — it is in your Codex env file, not this repo`);
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) {