@nexusmcp/cli 1.1.1 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +8 -2
  2. package/bin/cli.js +166 -53
  3. package/package.json +7 -7
package/README.md CHANGED
@@ -15,8 +15,14 @@ token copy/paste is needed. The CLI creates a protected `.env.nexus`, project-lo
15
15
  `.mcp.json`, `.nexus/project.json`, and agent guidance without printing or embedding the
16
16
  credential in MCP configuration. For Codex, a local header helper reads `.env.nexus` at
17
17
  connection time, so a restarted client does not depend on a globally exported token.
18
- Existing configuration is preserved; conflicting project or credential context fails
19
- closed.
18
+ Existing configuration is preserved; conflicting project or credential context fails
19
+ closed.
20
+
21
+ The CLI restricts `.env.nexus` to the current account (`0600` on POSIX and an
22
+ inheritance-disabled user/SYSTEM ACL on Windows). `doctor` fails when that protection
23
+ cannot be verified. The generated Codex setting uses the supported
24
+ `http_headers_helper` contract; it emits the Authorization header only to Codex and
25
+ never prints the credential in normal CLI output.
20
26
 
21
27
  During the private beta the CLI connects to the hosted Cloud Run service by default.
22
28
  Use `--server=<https-url>` only for an explicitly approved alternate deployment.
package/bin/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import crypto from 'crypto';
6
- import { execSync, spawn } from 'child_process';
6
+ import { execSync, spawn, spawnSync } from 'child_process';
7
7
  import { fileURLToPath } from 'url';
8
8
 
9
9
  const CLI_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -25,7 +25,9 @@ const EXTENSION_TOOLS = [
25
25
  'save_skill',
26
26
  'search_skills',
27
27
  ].sort();
28
- const CODEX_AUTH_HELPER_PATH = path.join('.nexus', 'auth-headers.cjs');
28
+ const CODEX_AUTH_HELPER_PATH = path.join('.nexus', 'auth-headers.cjs');
29
+ const CODEX_AUTH_HELPER_SETTING = 'http_headers_helper = "node .nexus/auth-headers.cjs"';
30
+ const CODEX_LEGACY_ENV_SETTING = 'bearer_token_env_var = "NEXUS_API_KEY"';
29
31
  const CODEX_AUTH_HELPER_CONTENT = `#!/usr/bin/env node
30
32
  // Nexus-owned Codex HTTP header helper. The bearer secret stays in .env.nexus.
31
33
  const fs = require('node:fs');
@@ -105,6 +107,61 @@ function writeJsonFile(filePath, value) {
105
107
  function sha256(content) {
106
108
  return crypto.createHash('sha256').update(content).digest('hex');
107
109
  }
110
+
111
+ function windowsUserSid() {
112
+ const result = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], {
113
+ encoding: 'utf8',
114
+ windowsHide: true,
115
+ stdio: ['ignore', 'pipe', 'pipe'],
116
+ });
117
+ if (result.status !== 0) return '';
118
+ return result.stdout.match(/S-1-(?:\d+-)+\d+/)?.[0] || '';
119
+ }
120
+
121
+ function protectCredentialFile(filePath) {
122
+ const absolutePath = path.resolve(filePath);
123
+ if (process.platform !== 'win32') {
124
+ fs.chmodSync(absolutePath, 0o600);
125
+ return;
126
+ }
127
+
128
+ const userSid = windowsUserSid();
129
+ if (!userSid) throw new Error('Unable to resolve the current Windows account SID.');
130
+ const result = spawnSync('icacls.exe', [
131
+ absolutePath,
132
+ '/inheritance:r',
133
+ '/grant:r', `*${userSid}:(F)`,
134
+ '/grant:r', '*S-1-5-18:(F)',
135
+ '/remove:g', '*S-1-1-0', '*S-1-5-11', '*S-1-5-32-545',
136
+ ], {
137
+ encoding: 'utf8',
138
+ windowsHide: true,
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ });
141
+ if (result.status !== 0) {
142
+ throw new Error('Windows refused the restricted credential-file ACL.');
143
+ }
144
+ }
145
+
146
+ function credentialProtectionStatus(filePath) {
147
+ if (!fs.existsSync(filePath)) return { ok: false, detail: 'credential file is missing' };
148
+ if (process.platform !== 'win32') {
149
+ const permissions = fs.statSync(filePath).mode & 0o777;
150
+ return {
151
+ ok: (permissions & 0o077) === 0,
152
+ detail: `mode ${permissions.toString(8).padStart(3, '0')}`,
153
+ };
154
+ }
155
+ const result = spawnSync('icacls.exe', [path.resolve(filePath), '/verify'], {
156
+ encoding: 'utf8',
157
+ windowsHide: true,
158
+ stdio: ['ignore', 'pipe', 'pipe'],
159
+ });
160
+ return {
161
+ ok: result.status === 0,
162
+ detail: result.status === 0 ? 'restricted Windows ACL is readable and valid' : 'Windows ACL verification failed',
163
+ };
164
+ }
108
165
 
109
166
  function writeTextIfAbsent(filePath, content, label) {
110
167
  if (fs.existsSync(filePath) && !force) {
@@ -142,7 +199,7 @@ function ensureRemoteMcpJson(filePath, entry, label, explicitHttpType = false) {
142
199
  }
143
200
  }
144
201
 
145
- function ensureCodexAuthHelper() {
202
+ function ensureCodexAuthHelper() {
146
203
  const helperDir = path.dirname(CODEX_AUTH_HELPER_PATH);
147
204
  if (!fs.existsSync(helperDir)) fs.mkdirSync(helperDir, { recursive: true });
148
205
  if (fs.existsSync(CODEX_AUTH_HELPER_PATH)) {
@@ -154,17 +211,27 @@ function ensureCodexAuthHelper() {
154
211
  }
155
212
  fs.writeFileSync(CODEX_AUTH_HELPER_PATH, CODEX_AUTH_HELPER_CONTENT, { encoding: 'utf-8', mode: 0o700 });
156
213
  try { fs.chmodSync(CODEX_AUTH_HELPER_PATH, 0o700); } catch (_) { /* Windows ACLs may ignore POSIX mode. */ }
157
- console.log(' [OK] Created the local Codex credential helper.');
158
- }
214
+ console.log(' [OK] Created the local Codex credential helper.');
215
+ }
216
+
217
+ function inspectCodexAuthHelper() {
218
+ if (!fs.existsSync(CODEX_AUTH_HELPER_PATH)) {
219
+ return { action: 'create credential helper', label: '.nexus/auth-headers.cjs' };
220
+ }
221
+ const content = fs.readFileSync(CODEX_AUTH_HELPER_PATH, 'utf-8');
222
+ return content === CODEX_AUTH_HELPER_CONTENT
223
+ ? { action: 'healthy', label: '.nexus/auth-headers.cjs' }
224
+ : { action: 'refused', label: '.nexus/auth-headers.cjs', detail: 'existing helper is not Nexus-owned' };
225
+ }
159
226
 
160
227
  function ensureCodexProjectConfig(apiUrl) {
161
- const codexDir = '.codex';
162
- const configPath = path.join(codexDir, 'config.toml');
163
- const block = `[mcp_servers.nexus-mcp]\nurl = "${apiUrl}/mcp"\nbearer_token_env_var = "NEXUS_API_KEY"\nstartup_timeout_sec = 10\ntool_timeout_sec = 60\n`;
164
- if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
165
- if (!fs.existsSync(configPath) || force) {
166
- fs.writeFileSync(configPath, block, 'utf-8');
167
- console.log(' [OK] Created project-scoped Codex MCP configuration.');
228
+ const codexDir = '.codex';
229
+ const configPath = path.join(codexDir, 'config.toml');
230
+ const block = `[mcp_servers.nexus-mcp]\nurl = "${apiUrl}/mcp"\n${CODEX_AUTH_HELPER_SETTING}\nstartup_timeout_sec = 10\ntool_timeout_sec = 60\n`;
231
+ if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
232
+ if (!fs.existsSync(configPath)) {
233
+ fs.writeFileSync(configPath, block, 'utf-8');
234
+ console.log(' [OK] Created project-scoped Codex MCP configuration.');
168
235
  return;
169
236
  }
170
237
  const existing = fs.readFileSync(configPath, 'utf-8');
@@ -175,23 +242,28 @@ function ensureCodexProjectConfig(apiUrl) {
175
242
  console.error('[NEXUS-CLI] Existing Nexus Codex entry points to another server; refusing to replace it.');
176
243
  process.exit(1);
177
244
  }
178
- if (existingSection[1].includes('bearer_token_env_var = "NEXUS_API_KEY"')) {
179
- console.log(' [NOTE] Preserved existing Nexus entry in .codex/config.toml.');
180
- return;
181
- }
182
- const prefix = existingSection[0].startsWith('\n') ? '\n' : '';
183
- fs.writeFileSync(configPath, existing.replace(sectionPattern, `${prefix}${block.trimEnd()}`), 'utf-8');
184
- console.log(' [OK] Updated the Nexus Codex entry to use its project-local credential helper.');
245
+ if (existingSection[1].includes(CODEX_AUTH_HELPER_SETTING)) {
246
+ console.log(' [NOTE] Preserved existing Nexus entry in .codex/config.toml.');
247
+ return;
248
+ }
249
+ if (!existingSection[1].includes(CODEX_LEGACY_ENV_SETTING)) {
250
+ console.error('[NEXUS-CLI] Existing Nexus Codex entry has an unrecognized credential source; refusing to replace it.');
251
+ process.exit(1);
252
+ }
253
+ const prefix = existingSection[0].startsWith('\n') ? '\n' : '';
254
+ fs.writeFileSync(configPath, existing.replace(sectionPattern, `${prefix}${block.trimEnd()}`), 'utf-8');
255
+ console.log(' [OK] Migrated the Nexus Codex entry to its project-local credential helper.');
185
256
  return;
186
257
  }
187
258
  fs.appendFileSync(configPath, `${existing.endsWith('\n') ? '' : '\n'}\n${block}`, 'utf-8');
188
259
  console.log(' [OK] Added Nexus to existing .codex/config.toml without changing other servers.');
189
260
  }
190
261
 
191
- function readEnvCredential() {
192
- if (process.env.NEXUS_API_KEY) return process.env.NEXUS_API_KEY.trim();
193
- if (!fs.existsSync('.env.nexus')) return '';
194
- return fs.readFileSync('.env.nexus', 'utf-8').match(/^NEXUS_API_KEY=(.*)$/m)?.[1]?.trim() || '';
262
+ function readEnvCredential() {
263
+ if (fs.existsSync('.env.nexus')) {
264
+ return fs.readFileSync('.env.nexus', 'utf-8').match(/^NEXUS_API_KEY=(.*)$/m)?.[1]?.trim() || '';
265
+ }
266
+ return process.env.NEXUS_API_KEY?.trim() || '';
195
267
  }
196
268
 
197
269
  function doctorCheck(ok, label, detail) {
@@ -420,8 +492,8 @@ function removeCodexEntry(apiUrl, apply) {
420
492
  const sectionPattern = /(?:^|\n)\[mcp_servers\.nexus-mcp\]\s*\n([\s\S]*?)(?=\n\s*\[|(?![\s\S]))/m;
421
493
  const match = content.match(sectionPattern);
422
494
  if (!match) return { action: 'absent', label };
423
- const ownedCredentialSource = match[1].includes('http_headers_helper = "node .nexus/auth-headers.cjs"')
424
- || match[1].includes('bearer_token_env_var = "NEXUS_API_KEY"');
495
+ const ownedCredentialSource = match[1].includes(CODEX_AUTH_HELPER_SETTING)
496
+ || match[1].includes(CODEX_LEGACY_ENV_SETTING);
425
497
  if (!match[1].includes(`url = "${apiUrl}/mcp"`) || !ownedCredentialSource) {
426
498
  return { action: 'refused', label, detail: 'Nexus section does not match the recorded server and credential contract' };
427
499
  }
@@ -578,18 +650,24 @@ function runRepair(title = 'Repair') {
578
650
  repairJsonEntry(path.join('.cursor', 'mcp.json'), { url: `${project.api_url}/mcp`, headers: { Authorization: 'Bearer ${env:NEXUS_API_KEY}' } }, project.api_url, '.cursor/mcp.json', shouldApply),
579
651
  repairJsonEntry(path.join('.agents', 'mcp_config.json'), { serverUrl: `${project.api_url}/mcp`, headers: { Authorization: 'Bearer ${NEXUS_API_KEY}' } }, project.api_url, '.agents/mcp_config.json', shouldApply),
580
652
  ];
581
- let operations = collectJsonOperations(false);
582
- const codexPath = path.join('.codex', 'config.toml');
653
+ let operations = collectJsonOperations(false);
654
+ const helperOperation = inspectCodexAuthHelper();
655
+ operations.push(helperOperation);
656
+ const codexPath = path.join('.codex', 'config.toml');
583
657
  const codexContent = fs.existsSync(codexPath) ? fs.readFileSync(codexPath, 'utf-8') : '';
584
658
  const codexHasSection = /^\s*\[mcp_servers\.nexus-mcp\]\s*$/m.test(codexContent);
585
659
  const codexHealthy = codexHasSection
586
660
  && codexContent.includes(`url = "${project.api_url}/mcp"`)
587
- && codexContent.includes('bearer_token_env_var = "NEXUS_API_KEY"');
588
- operations.push(codexHealthy
589
- ? { action: 'healthy', label: '.codex/config.toml' }
590
- : codexHasSection
591
- ? { action: 'refused', label: '.codex/config.toml', detail: 'existing Nexus section is not owned by the recorded project context' }
592
- : { action: 'add missing Nexus section', label: '.codex/config.toml' });
661
+ && codexContent.includes(CODEX_AUTH_HELPER_SETTING);
662
+ const codexOwned = !codexHasSection || (
663
+ codexContent.includes(`url = "${project.api_url}/mcp"`)
664
+ && (codexContent.includes(CODEX_AUTH_HELPER_SETTING) || codexContent.includes(CODEX_LEGACY_ENV_SETTING))
665
+ );
666
+ operations.push(codexHealthy
667
+ ? { action: 'healthy', label: '.codex/config.toml' }
668
+ : codexOwned
669
+ ? { action: codexHasSection ? 'migrate credential helper' : 'add missing Nexus section', label: '.codex/config.toml' }
670
+ : { action: 'refused', label: '.codex/config.toml', detail: 'existing Nexus section is not owned by the recorded project context' });
593
671
 
594
672
  const skillPath = path.join('.agents', 'skills', 'nexus', 'SKILL.md');
595
673
  const skillContent = provisionedNexusSkillContent();
@@ -600,8 +678,10 @@ function runRepair(title = 'Repair') {
600
678
  console.error(`[NEXUS-CLI] ${title} refused before making changes because ownership checks failed.`);
601
679
  process.exit(1);
602
680
  }
603
- if (apply) {
681
+ if (apply) {
604
682
  operations = collectJsonOperations(true);
683
+ if (helperOperation.action !== 'healthy') ensureCodexAuthHelper();
684
+ operations.push({ action: helperOperation.action === 'healthy' ? 'healthy' : 'create credential helper', label: '.nexus/auth-headers.cjs' });
605
685
  if (!codexHealthy) {
606
686
  ensureCodexProjectConfig(project.api_url);
607
687
  }
@@ -636,8 +716,21 @@ async function runDoctor() {
636
716
  healthy = doctorCheck(false, 'Project binding', 'run nexus init first') && healthy;
637
717
  }
638
718
 
639
- const token = readEnvCredential();
640
- healthy = doctorCheck(Boolean(token), 'Scoped credential', token ? 'available without printing it' : 'set NEXUS_API_KEY or initialize .env.nexus') && healthy;
719
+ const token = readEnvCredential();
720
+ healthy = doctorCheck(Boolean(token), 'Scoped credential', token ? 'available without printing it' : 'set NEXUS_API_KEY or initialize .env.nexus') && healthy;
721
+ const credentialProtection = fs.existsSync('.env.nexus')
722
+ ? credentialProtectionStatus('.env.nexus')
723
+ : {
724
+ ok: Boolean(process.env.NEXUS_API_KEY),
725
+ detail: process.env.NEXUS_API_KEY
726
+ ? 'credential supplied by the process environment'
727
+ : 'credential file is missing',
728
+ };
729
+ healthy = doctorCheck(
730
+ credentialProtection.ok,
731
+ 'Credential file permissions',
732
+ credentialProtection.detail,
733
+ ) && healthy;
641
734
 
642
735
  let mcpConfigured = false;
643
736
  if (fs.existsSync('.mcp.json')) {
@@ -658,8 +751,9 @@ async function runDoctor() {
658
751
  && nexusRemoteEntryIsOwned(cursorEntry, apiUrl)
659
752
  && nexusRemoteEntryIsOwned(agentsEntry, apiUrl)
660
753
  && codexContent.includes(`url = "${apiUrl}/mcp"`)
661
- && codexContent.includes('bearer_token_env_var = "NEXUS_API_KEY"');
662
- healthy = doctorCheck(clientAuthHealthy, 'Supported client auth', clientAuthHealthy ? 'Cursor, agents, and Codex use environment-based credentials' : 'run repair to restore supported environment-based auth contracts') && healthy;
754
+ && codexContent.includes(CODEX_AUTH_HELPER_SETTING)
755
+ && inspectCodexAuthHelper().action === 'healthy';
756
+ healthy = doctorCheck(clientAuthHealthy, 'Supported client auth', clientAuthHealthy ? 'Cursor and agents use environment expansion; Codex uses the project-local header helper' : 'run repair to restore supported credential contracts') && healthy;
663
757
 
664
758
  const gitignore = fs.existsSync('.gitignore') ? fs.readFileSync('.gitignore', 'utf-8') : '';
665
759
  const secretsIgnored = gitignore.split(/\r?\n/).some((line) => line.trim() === '.env.nexus');
@@ -767,22 +861,36 @@ if (command === 'init') {
767
861
  },
768
862
  '.agents/mcp_config.json'
769
863
  );
770
- ensureCodexProjectConfig(apiUrl);
771
-
772
- // 2. Write .env.nexus file with actual token
864
+ // 2. Write .env.nexus file with actual token
773
865
  const envContent = `NEXUS_API_KEY=${token}\nNEXUS_API_URL=${apiUrl}\n`;
774
- if (fs.existsSync('.env.nexus') && !force) {
775
- const existingEnv = fs.readFileSync('.env.nexus', 'utf-8').match(/^NEXUS_API_KEY=(.*)$/m)?.[1]?.trim();
776
- if (existingEnv && existingEnv !== token) {
866
+ if (fs.existsSync('.env.nexus') && !force) {
867
+ const existingEnv = fs.readFileSync('.env.nexus', 'utf-8').match(/^NEXUS_API_KEY=(.*)$/m)?.[1]?.trim();
868
+ if (!existingEnv) {
869
+ console.error('[NEXUS-CLI] Existing .env.nexus has no valid Nexus credential; refusing to preserve it.');
870
+ process.exit(1);
871
+ }
872
+ if (existingEnv && existingEnv !== token) {
777
873
  console.error('[NEXUS-CLI] Existing .env.nexus contains a different credential; refusing to replace it.');
778
874
  process.exit(1);
779
875
  }
780
- console.log(' [NOTE] Preserved existing .env.nexus credential file.');
781
- } else {
782
- fs.writeFileSync('.env.nexus', envContent, { encoding: 'utf-8', mode: 0o600 });
783
- try { fs.chmodSync('.env.nexus', 0o600); } catch (_) { /* Windows ACLs may ignore POSIX mode. */ }
784
- console.log(' [OK] Created .env.nexus credential file.');
785
- }
876
+ try {
877
+ protectCredentialFile('.env.nexus');
878
+ } catch (error) {
879
+ console.error(`[NEXUS-CLI] ${error.message} Refusing to continue with an unprotected credential.`);
880
+ process.exit(1);
881
+ }
882
+ console.log(' [NOTE] Preserved existing .env.nexus credential file and refreshed its protection.');
883
+ } else {
884
+ fs.writeFileSync('.env.nexus', envContent, { encoding: 'utf-8', mode: 0o600 });
885
+ try {
886
+ protectCredentialFile('.env.nexus');
887
+ } catch (error) {
888
+ try { fs.unlinkSync('.env.nexus'); } catch (_) { /* best-effort secret cleanup */ }
889
+ console.error(`[NEXUS-CLI] ${error.message} The credential file was removed.`);
890
+ process.exit(1);
891
+ }
892
+ console.log(' [OK] Created protected .env.nexus credential file.');
893
+ }
786
894
 
787
895
  // 3. Shield credentials in .gitignore
788
896
  const gitignorePath = '.gitignore';
@@ -791,11 +899,16 @@ if (command === 'init') {
791
899
  gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
792
900
  }
793
901
 
794
- if (!gitignoreContent.includes('.env.nexus')) {
902
+ if (!gitignoreContent.includes('.env.nexus')) {
795
903
  gitignoreContent += '\n# Nexus Secrets & Cache Shielding\n.env.nexus\n.nexus/\n';
796
904
  fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');
797
- console.log(' [OK] Updated .gitignore to shield .env.nexus credentials.');
798
- }
905
+ console.log(' [OK] Updated .gitignore to shield .env.nexus credentials.');
906
+ }
907
+
908
+ // Codex supports a local HTTP-header helper. It reads the protected project
909
+ // credential at connection time, so Codex does not require a global env var.
910
+ ensureCodexAuthHelper();
911
+ ensureCodexProjectConfig(apiUrl);
799
912
 
800
913
  // 4. Create .nexus/project.json
801
914
  const nexusDir = '.nexus';
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nexusmcp/cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Nexus MCP Lightweight Onboarding CLI & Session Hook Assistant",
5
5
  "main": "bin/cli.js",
6
- "files": [
7
- "bin",
8
- "templates",
9
- "README.md",
6
+ "files": [
7
+ "bin",
8
+ "templates",
9
+ "README.md",
10
10
  "LICENSE"
11
11
  ],
12
12
  "bin": {
@@ -26,9 +26,9 @@
26
26
  "directory": "packages/cli"
27
27
  },
28
28
  "scripts": {
29
- "test": "node test/guidance-sync.cjs && node test/smoke.cjs && node test/session-end.cjs && node test/upgrade-doctor.cjs",
29
+ "test": "node test/guidance-sync.cjs && node test/smoke.cjs && node test/session-end.cjs && node test/upgrade-doctor.cjs",
30
30
  "test:package": "node test/package-install.cjs",
31
- "prepublishOnly": "npm test && npm run test:package && node --check bin/cli.js"
31
+ "prepublishOnly": "npm test && npm run test:package && node --check bin/cli.js"
32
32
  },
33
33
  "keywords": [
34
34
  "mcp",