nexarch 0.12.25 → 0.12.26

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.
@@ -2,11 +2,20 @@ import { hostname, platform, arch } from "node:os";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { expandHomePath, supportsAutoWrite, writeClientConfig } from "../lib/client-config-writers.js";
5
6
  // Matches www.nexarch.ai — the default production host for the web app that
6
7
  // owns /api/agent-enrollments/exchange. Not the same service as
7
8
  // mcp.nexarch.ai (the MCP gateway); --host exists because staging and
8
9
  // self-hosted deployments serve the exchange route from a different origin.
9
10
  const DEFAULT_EXCHANGE_HOST = "https://www.nexarch.ai";
11
+ function renderInstructions(template, manifest, token, mcpEndpoint) {
12
+ return template
13
+ .replaceAll("{{envVarBraced}}", `\${${manifest.mcp.auth.environmentVariable}}`)
14
+ .replaceAll("{{envVar}}", manifest.mcp.auth.environmentVariable)
15
+ .replaceAll("{{mcpUrl}}", mcpEndpoint)
16
+ .replaceAll("{{serverKey}}", manifest.mcp.serverName)
17
+ .replaceAll("{{token}}", token);
18
+ }
10
19
  const ERROR_MESSAGES = {
11
20
  enrollment_invalid: "The enrollment code is invalid, expired, or already used. Ask whoever created it to issue a new one.",
12
21
  enrollment_client_mismatch: "This code was issued for a different client type than the one passed to --client. Check the client code against the one configured when the enrollment was created.",
@@ -39,6 +48,7 @@ function redactedManifest(manifest) {
39
48
  export async function enroll(args) {
40
49
  const asJson = parseFlag(args, "--json");
41
50
  const printToken = parseFlag(args, "--print-token");
51
+ const skipClientConfig = parseFlag(args, "--skip-client-config");
42
52
  const code = parseOptionValue(args, "--code");
43
53
  const clientCode = parseOptionValue(args, "--client");
44
54
  const clientVersion = parseOptionValue(args, "--client-version");
@@ -69,18 +79,49 @@ export async function enroll(args) {
69
79
  const destination = outPath ?? join(homedir(), ".nexarch", "agent-credential.json");
70
80
  mkdirSync(dirname(destination), { recursive: true });
71
81
  writeFileSync(destination, JSON.stringify(manifest, null, 2), { mode: 0o600 });
82
+ // The gateway's JSON-RPC endpoint is always {mcp.url}/mcp — mcp.url on the
83
+ // manifest is the bare origin (e.g. https://mcp.nexarch.ai), matching the
84
+ // convention nexarch mcp-config already uses for the same gateway.
85
+ const mcpEndpoint = `${manifest.mcp.url.replace(/\/$/, "")}/mcp`;
86
+ let wroteClientConfig = false;
87
+ if (!skipClientConfig && manifest.envFilePath && manifest.mcpConfigPath && supportsAutoWrite(manifest.mcpConfigMergeStrategy)) {
88
+ writeClientConfig(manifest.mcpConfigMergeStrategy, {
89
+ envFilePath: expandHomePath(manifest.envFilePath),
90
+ mcpConfigPath: expandHomePath(manifest.mcpConfigPath),
91
+ serverKey: manifest.mcp.serverName,
92
+ envVar: manifest.mcp.auth.environmentVariable,
93
+ mcpUrl: mcpEndpoint,
94
+ token: manifest.credential.token,
95
+ });
96
+ wroteClientConfig = true;
97
+ }
72
98
  if (asJson) {
73
99
  const output = printToken ? manifest : redactedManifest(manifest);
74
- process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
100
+ process.stdout.write(`${JSON.stringify({ ...output, wroteClientConfig }, null, 2)}\n`);
75
101
  return;
76
102
  }
77
103
  console.log(`✓ Enrolled agent ${manifest.agent.ref} in workspace "${manifest.workspace.name}"`);
78
104
  console.log(` Scopes : ${manifest.credential.scopes.join(", ")}`);
79
105
  console.log(` Expires : ${manifest.credential.expiresAt ?? "never"}`);
80
- console.log(` MCP server : ${manifest.mcp.url}`);
106
+ console.log(` MCP server : ${mcpEndpoint}`);
81
107
  console.log(` Credential : saved to ${destination} (mode 600)`);
82
- console.log(`\nExport it as ${manifest.mcp.auth.environmentVariable} in the agent runtime's own environment — read`);
83
- console.log(`the token from that file rather than pasting it into a shell command or config file.`);
108
+ if (wroteClientConfig) {
109
+ console.log(`\n✓ Wired the connection into ${manifest.envFilePath} and ${manifest.mcpConfigPath}`);
110
+ }
111
+ else if (skipClientConfig) {
112
+ console.log(`\nSkipped client config (--skip-client-config passed).`);
113
+ }
114
+ else {
115
+ console.log(`\nNo known auto-write target for client "${clientCode}" — connect it manually:`);
116
+ }
117
+ const shownToken = printToken ? manifest.credential.token : `<see ${destination}>`;
118
+ if (manifest.connectInstructionsTemplate) {
119
+ console.log(renderInstructions(manifest.connectInstructionsTemplate, manifest, shownToken, mcpEndpoint));
120
+ }
121
+ else if (!wroteClientConfig) {
122
+ console.log(`Export ${manifest.mcp.auth.environmentVariable}=${shownToken} in the agent runtime's own environment, then`);
123
+ console.log(`point its MCP client at ${mcpEndpoint} (streamable-http, Authorization: Bearer \${${manifest.mcp.auth.environmentVariable}}).`);
124
+ }
84
125
  if (printToken) {
85
126
  console.log(`\nToken: ${manifest.credential.token}`);
86
127
  }
@@ -8,7 +8,7 @@ import { requireCredentials } from "../lib/credentials.js";
8
8
  import { fetchAgentRegistryOrThrow } from "../lib/agent-registry.js";
9
9
  import { callMcpTool, mcpInitialize, mcpListTools } from "../lib/mcp.js";
10
10
  import { buildVersionAttributes } from "../lib/version-normalization.js";
11
- import { requestTrustAttestation } from "../lib/trust.js";
11
+ import { requestTrustAttestation, TRUST_ATTESTATION_SCOPE } from "../lib/trust.js";
12
12
  const CLI_VERSION = (() => {
13
13
  try {
14
14
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
@@ -443,6 +443,30 @@ function injectAgentConfigs(registry, runtimeCodes, dryRun) {
443
443
  }
444
444
  return [];
445
445
  }
446
+ /**
447
+ * Whether the trust attestation already sitting in `path` still holds up: present, unexpired,
448
+ * and minted under the scope this build of the CLI currently mints under. A registration
449
+ * section can be byte-identical to the current template (so `injectAgentConfigs` reports
450
+ * `already_present` and writes nothing) while its neighbouring attestation was minted under an
451
+ * old scope value or has since expired — that staleness is independent of whether the
452
+ * registration prose changed, so it needs its own check rather than riding on
453
+ * `already_present` vs `updated`. See ADR-0112.
454
+ */
455
+ function isTrustAttestationStale(path) {
456
+ if (!existsSync(path))
457
+ return true;
458
+ const content = readFileSync(path, "utf8");
459
+ const match = content.match(/<!-- nexarch:trust-attestation:start -->\n([\s\S]*?)\n<!-- nexarch:trust-attestation:end -->/);
460
+ const body = match ? match[1] : content;
461
+ const scope = body.match(/^scope:\s*(\S+)\s*$/m)?.[1];
462
+ const expiresAt = body.match(/^expires_at:\s*(\S+)\s*$/m)?.[1];
463
+ if (!scope || !expiresAt)
464
+ return true;
465
+ if (scope !== TRUST_ATTESTATION_SCOPE)
466
+ return true;
467
+ const expiry = Date.parse(expiresAt);
468
+ return Number.isNaN(expiry) || expiry <= Date.now();
469
+ }
446
470
  function injectTrustAttestationBlock(path, attestation) {
447
471
  // The fallbacks read this very file, so they have to name it: the block is
448
472
  // injected into AGENTS.md and .cursorrules too, where a hardcoded CLAUDE.md
@@ -1258,14 +1282,17 @@ export async function initAgent(args) {
1258
1282
  // the dry-run result is accurate and no second pass is needed.
1259
1283
  agentConfigResults = existingInstructionTargets;
1260
1284
  }
1261
- // Attest only files actually written (injected/updated) just now. When
1262
- // consent wasn't granted, nothing was written above — `existingInstructionTargets`
1263
- // is dry-run data with no corresponding file changeso there is
1264
- // nothing new to attest. An "already_present" target needs no fresh
1265
- // attestation either: its content, and whatever attestation it already
1266
- // carries from a prior run, hasn't changed.
1285
+ // Attest files actually written (injected/updated) just now, plus any
1286
+ // "already_present" target whose existing attestation is itself stale
1287
+ // (missing, expired, or minted under an old scope)the registration
1288
+ // prose matching the template says nothing about whether the neighbouring
1289
+ // attestation is still current, so that's checked independently rather
1290
+ // than assumed from the write status. See ADR-0112 / isTrustAttestationStale.
1291
+ // When consent wasn't granted, nothing was written above —
1292
+ // `existingInstructionTargets` is dry-run data with no corresponding file
1293
+ // change — so there is nothing to attest.
1267
1294
  const attestationTargets = instructionsWriteAllowed
1268
- ? agentConfigResults.filter((r) => r.status === "injected" || r.status === "updated")
1295
+ ? agentConfigResults.filter((r) => r.status === "injected" || r.status === "updated" || isTrustAttestationStale(r.path))
1269
1296
  : [];
1270
1297
  if (attestationTargets.length > 0) {
1271
1298
  trustAttestationAttempted = true;
@@ -1338,7 +1365,9 @@ export async function initAgent(args) {
1338
1365
  : !instructionsWriteAllowed
1339
1366
  ? "skipped (consent not granted)"
1340
1367
  : !trustAttestationAttempted
1341
- ? "skipped (no instruction target written)"
1368
+ ? agentConfigResults.length > 0
1369
+ ? "already current (no refresh needed)"
1370
+ : "skipped (no instruction target written)"
1342
1371
  : trustAttestation?.ok
1343
1372
  ? "minted and injected into instruction file(s)"
1344
1373
  : `unavailable (${trustAttestation?.reason ?? "unknown"})`,
package/dist/index.js CHANGED
@@ -108,12 +108,16 @@ Usage:
108
108
  nexarch enroll Bootstrap a headless (browserless) agent with a one-time
109
109
  enrollment code — no login required. Writes the resulting
110
110
  credential to ~/.nexarch/agent-credential.json (mode 600)
111
- rather than printing it.
111
+ rather than printing it. For a client with a known config
112
+ format (e.g. hermes-agent), also writes the connection
113
+ directly into that client's own env/config files; for
114
+ anything else, prints instructions instead.
112
115
  Options: --code <code> (required, starts with nxe_)
113
116
  --client <code> (required, e.g. hermes-agent)
114
117
  --client-version <v>
115
118
  --host <baseUrl> (default: https://www.nexarch.ai)
116
119
  --out <path> write credential here instead
120
+ --skip-client-config don't touch the client's own files
117
121
  --print-token also print the token to stdout
118
122
  --json
119
123
  nexarch logout Remove stored credentials
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { parseDocument } from "yaml";
5
+ export function expandHomePath(path) {
6
+ if (path.startsWith("~/") || path === "~") {
7
+ return join(homedir(), path.slice(1));
8
+ }
9
+ return path;
10
+ }
11
+ /**
12
+ * Replaces the line for `key=` if present, otherwise appends it — never
13
+ * touches any other line, so unrelated vars in an existing .env survive.
14
+ */
15
+ function upsertEnvVar(filePath, key, value) {
16
+ mkdirSync(dirname(filePath), { recursive: true });
17
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
18
+ const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
19
+ const lineIndex = lines.findIndex((line) => line.startsWith(`${key}=`));
20
+ const newLine = `${key}=${value}`;
21
+ if (lineIndex >= 0) {
22
+ lines[lineIndex] = newLine;
23
+ }
24
+ else {
25
+ if (lines.length > 0 && lines[lines.length - 1].trim() !== "")
26
+ lines.push("");
27
+ lines.push(newLine);
28
+ }
29
+ const out = lines.join("\n").replace(/\n*$/, "\n");
30
+ writeFileSync(filePath, out, { mode: 0o600 });
31
+ }
32
+ /**
33
+ * Merges one `mcp_servers.<serverKey>` entry into a YAML document, preserving
34
+ * every other key, comment, and formatting choice already in the file — this
35
+ * is someone else's live config, not a file this CLI owns. `${VAR}` in the
36
+ * Authorization header is left as a literal template string; the client
37
+ * itself (Hermes) resolves it from its own env at connect time, so the raw
38
+ * token is never written into this file.
39
+ */
40
+ function yamlUrlBearerStyle(configPath, serverKey, envVar, mcpUrl) {
41
+ mkdirSync(dirname(configPath), { recursive: true });
42
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
43
+ const doc = parseDocument(existing);
44
+ doc.setIn(["mcp_servers", serverKey, "url"], mcpUrl);
45
+ doc.setIn(["mcp_servers", serverKey, "headers", "Authorization"], `Bearer \${${envVar}}`);
46
+ writeFileSync(configPath, doc.toString(), { mode: 0o600 });
47
+ }
48
+ const STRATEGIES = {
49
+ yaml_url_bearer_style: (t) => yamlUrlBearerStyle(t.mcpConfigPath, t.serverKey, t.envVar, t.mcpUrl),
50
+ };
51
+ export function supportsAutoWrite(mergeStrategy) {
52
+ return Boolean(mergeStrategy && STRATEGIES[mergeStrategy]);
53
+ }
54
+ /** Throws if mergeStrategy isn't recognized — check supportsAutoWrite first. */
55
+ export function writeClientConfig(mergeStrategy, targets) {
56
+ const strategy = STRATEGIES[mergeStrategy];
57
+ if (!strategy)
58
+ throw new Error(`Unknown mcp_config_merge_strategy: ${mergeStrategy}`);
59
+ upsertEnvVar(targets.envFilePath, targets.envVar, targets.token);
60
+ strategy(targets);
61
+ }
package/dist/lib/trust.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import https from "https";
2
2
  import { requireCredentials } from "./credentials.js";
3
3
  const MCP_GATEWAY_URL = "https://mcp.nexarch.ai";
4
+ /** The scope every attestation is minted under — see ADR-0112. Exported so callers can tell a
5
+ * stored attestation's scope apart from what would be minted today, without duplicating the literal. */
6
+ export const TRUST_ATTESTATION_SCOPE = "agent_config_write";
4
7
  /**
5
8
  * `contentHash` (sha256 hex of the exact managed-section text being written)
6
9
  * binds the signature to the instructions themselves, not just to the claim
@@ -11,7 +14,7 @@ export async function requestTrustAttestation(agentId, contentHash) {
11
14
  const creds = requireCredentials();
12
15
  const body = JSON.stringify({
13
16
  agentId,
14
- scope: "agent_config_write",
17
+ scope: TRUST_ATTESTATION_SCOPE,
15
18
  ...(contentHash ? { contentHash } : {}),
16
19
  });
17
20
  return new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.12.25",
3
+ "version": "0.12.26",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",
@@ -32,5 +32,8 @@
32
32
  "@types/node": "^22",
33
33
  "tsx": "^4",
34
34
  "typescript": "^5"
35
+ },
36
+ "dependencies": {
37
+ "yaml": "^2.9.0"
35
38
  }
36
39
  }