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.
- package/dist/commands/enroll.js +45 -4
- package/dist/commands/init-agent.js +38 -9
- package/dist/index.js +5 -1
- package/dist/lib/client-config-writers.js +61 -0
- package/dist/lib/trust.js +4 -1
- package/package.json +4 -1
package/dist/commands/enroll.js
CHANGED
|
@@ -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 : ${
|
|
106
|
+
console.log(` MCP server : ${mcpEndpoint}`);
|
|
81
107
|
console.log(` Credential : saved to ${destination} (mode 600)`);
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
// attestation
|
|
1266
|
-
//
|
|
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
|
-
?
|
|
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:
|
|
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.
|
|
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
|
}
|