mycomesh-consumer 0.1.51 → 0.1.53

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.
@@ -0,0 +1,205 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ // Routing-only hints: never forward these as Gateway stateful-history headers.
4
+ export const CONSUMER_SESSION_HEADERS = Object.freeze([
5
+ "x-mycomesh-session-id",
6
+ "x-session-id",
7
+ "session_id",
8
+ ]);
9
+
10
+ const TOOL_CALL_TYPES = new Set([
11
+ "tool_call", "function_call", "local_shell_call", "tool_search_call",
12
+ "custom_tool_call", "mcp_tool_call",
13
+ ]);
14
+ const TOOL_OUTPUT_TYPES = new Set([
15
+ "function_call_output", "tool_search_output", "custom_tool_call_output",
16
+ "mcp_tool_call_output",
17
+ ]);
18
+
19
+ export class ConsumerSessionError extends Error {
20
+ constructor(message, statusCode = 400) {
21
+ super(message);
22
+ this.name = "ConsumerSessionError";
23
+ this.statusCode = statusCode;
24
+ this.status = statusCode;
25
+ this.code = statusCode === 409 ? "session_continuation_unavailable" : "invalid_session";
26
+ }
27
+ }
28
+
29
+ function object(value) {
30
+ return value !== null && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function validIdentifier(value, limit) {
34
+ return typeof value === "string" && value.length > 0 && value.length <= limit
35
+ && /^[\x21-\x7e]+$/.test(value) && !value.includes(",");
36
+ }
37
+
38
+ function sessionIdentifier(value) {
39
+ if (!validIdentifier(value, 128)) {
40
+ throw new ConsumerSessionError("Session id must be 1–128 printable ASCII characters without whitespace or commas");
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function headerSignals(headers) {
46
+ const values = [];
47
+ const entries = typeof headers?.entries === "function"
48
+ ? headers.entries() : Object.entries(headers || {});
49
+ for (const [name, value] of entries) {
50
+ if (!CONSUMER_SESSION_HEADERS.includes(name.toLowerCase())) continue;
51
+ if (value === undefined) continue;
52
+ if (Array.isArray(value)) {
53
+ if (value.length !== 1) throw new ConsumerSessionError("Duplicate session headers are not allowed");
54
+ values.push(sessionIdentifier(value[0]));
55
+ } else {
56
+ values.push(sessionIdentifier(value));
57
+ }
58
+ }
59
+ return values;
60
+ }
61
+
62
+ function toolReferences(body) {
63
+ const context = new Set();
64
+ const outputs = [];
65
+ for (const item of Array.isArray(body.input) ? body.input : []) {
66
+ if (!object(item)) continue;
67
+ if (TOOL_CALL_TYPES.has(item.type) && validIdentifier(item.call_id, 256)) context.add(item.call_id);
68
+ if (TOOL_OUTPUT_TYPES.has(item.type)) outputs.push(item.call_id);
69
+ }
70
+ for (const message of Array.isArray(body.messages) ? body.messages : []) {
71
+ if (!object(message)) continue;
72
+ if (message.role === "assistant" && Array.isArray(message.tool_calls)) {
73
+ for (const call of message.tool_calls) {
74
+ if (object(call) && validIdentifier(call.id, 256)) context.add(call.id);
75
+ }
76
+ }
77
+ if (message.role === "tool") outputs.push(message.tool_call_id);
78
+ }
79
+ return { context, outputs };
80
+ }
81
+
82
+ /**
83
+ * One instance belongs to one authenticated Consumer routing namespace.
84
+ * No prompt, cache key, wallet, or payment request id is treated as a session.
85
+ * Continuation mappings are process-local, bounded, and expire after inactivity.
86
+ */
87
+ export class ConsumerSessions {
88
+ constructor({ ttlMs = 30 * 60_000, maxEntries = 10_000, now = Date.now, generateId = randomUUID } = {}) {
89
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || !Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
90
+ throw new TypeError("Session TTL and capacity must be positive safe integers");
91
+ }
92
+ if (typeof now !== "function" || typeof generateId !== "function") {
93
+ throw new TypeError("Session clock and id generator must be functions");
94
+ }
95
+ this.ttlMs = ttlMs;
96
+ this.maxEntries = maxEntries;
97
+ this.now = now;
98
+ this.generateId = generateId;
99
+ this.entries = new Map();
100
+ }
101
+
102
+ get size() {
103
+ this.prune(this.now());
104
+ return this.entries.size;
105
+ }
106
+
107
+ prune(now) {
108
+ for (const [key, entry] of this.entries) {
109
+ if (entry.expiresAt <= now) this.entries.delete(key);
110
+ }
111
+ }
112
+
113
+ resolve(body, headers = {}) {
114
+ if (!object(body)) throw new ConsumerSessionError("Request body must be a JSON object");
115
+ if (body.metadata != null && !object(body.metadata)) {
116
+ throw new ConsumerSessionError("Request metadata must be a JSON object");
117
+ }
118
+ const signals = headerSignals(headers);
119
+ if (Object.hasOwn(body.metadata || {}, "mycomesh_session_id")) {
120
+ signals.push(sessionIdentifier(body.metadata.mycomesh_session_id));
121
+ }
122
+ if (body.conversation != null) {
123
+ const value = object(body.conversation) ? body.conversation.id : body.conversation;
124
+ signals.push(sessionIdentifier(value));
125
+ }
126
+ if (new Set(signals).size > 1) throw new ConsumerSessionError("Explicit session identifiers disagree");
127
+
128
+ const now = this.now();
129
+ this.prune(now);
130
+ const continuationSessions = new Set();
131
+ const matchedKeys = new Set();
132
+ const resolveReference = (kind, value, selfContained = false) => {
133
+ if (!validIdentifier(value, 256)) {
134
+ throw new ConsumerSessionError("Continuation reference must be a nonempty ASCII identifier", 409);
135
+ }
136
+ const key = `${kind}:${value}`;
137
+ const entry = this.entries.get(key);
138
+ // Full-history SDK/Codex requests may include old call+output pairs and
139
+ // are already supported statelessly by the Provider backend. An explicit
140
+ // new session may fork that history without inheriting its old route.
141
+ if (selfContained && signals.length) return;
142
+ if (!entry && selfContained) return;
143
+ if (!entry || !entry.sessionId) {
144
+ throw new ConsumerSessionError("Continuation is unknown, expired, or ambiguous; its original route is unavailable", 409);
145
+ }
146
+ continuationSessions.add(entry.sessionId);
147
+ matchedKeys.add(key);
148
+ };
149
+ if (body.previous_response_id != null) resolveReference("response", body.previous_response_id);
150
+ const { context, outputs } = toolReferences(body);
151
+ for (const callId of outputs) resolveReference("call", callId, context.has(callId));
152
+ if (continuationSessions.size > 1) {
153
+ throw new ConsumerSessionError("Continuation references belong to different sessions", 409);
154
+ }
155
+ const previousSession = continuationSessions.values().next().value;
156
+ if (previousSession && signals.length && previousSession !== signals[0]) {
157
+ throw new ConsumerSessionError("Explicit session does not match the continuation's original session", 409);
158
+ }
159
+ const id = signals[0] || previousSession || sessionIdentifier(this.generateId());
160
+ for (const key of matchedKeys) {
161
+ const entry = this.entries.get(key);
162
+ this.entries.delete(key);
163
+ this.entries.set(key, { ...entry, expiresAt: now + this.ttlMs });
164
+ }
165
+ return {
166
+ id,
167
+ body: { ...body, metadata: { ...body.metadata, mycomesh_session_id: id } },
168
+ explicit: signals.length > 0,
169
+ requiresOriginalRoute: matchedKeys.size > 0,
170
+ };
171
+ }
172
+
173
+ // Accept either a raw OpenAI response or relayInference's {status, payload}.
174
+ // Call only after the actual request succeeded on the selected route.
175
+ remember(result, sessionId) {
176
+ sessionIdentifier(sessionId);
177
+ if (!object(result) || (typeof result.status === "number" && (result.status < 200 || result.status >= 300))) return 0;
178
+ const payload = object(result.payload) ? result.payload : result;
179
+ if (payload.error) return 0;
180
+ const now = this.now();
181
+ this.prune(now);
182
+ let remembered = 0;
183
+ const save = (kind, value) => {
184
+ if (!validIdentifier(value, 256)) return;
185
+ const key = `${kind}:${value}`;
186
+ const previous = this.entries.get(key);
187
+ // A reused upstream id must not silently transfer a continuation route.
188
+ const resolvedSession = previous && previous.sessionId !== sessionId ? null : sessionId;
189
+ this.entries.delete(key);
190
+ this.entries.set(key, { sessionId: resolvedSession, expiresAt: now + this.ttlMs });
191
+ while (this.entries.size > this.maxEntries) this.entries.delete(this.entries.keys().next().value);
192
+ remembered += 1;
193
+ };
194
+ save("response", payload.id);
195
+ for (const item of Array.isArray(payload.output) ? payload.output : []) {
196
+ if (object(item) && TOOL_CALL_TYPES.has(item.type)) save("call", item.call_id);
197
+ }
198
+ for (const choice of Array.isArray(payload.choices) ? payload.choices : []) {
199
+ for (const call of Array.isArray(choice?.message?.tool_calls) ? choice.message.tool_calls : []) {
200
+ if (object(call)) save("call", call.id);
201
+ }
202
+ }
203
+ return remembered;
204
+ }
205
+ }
package/src/consumer.mjs CHANGED
@@ -1,7 +1,12 @@
1
1
  import { spawn as defaultSpawn } from "node:child_process";
2
2
  import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { rootCertificates } from "node:tls";
5
+ import { join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
4
7
  import * as readline from "node:readline/promises";
8
+ import { CONSUMER_RELEASE_VERSION } from "./release.mjs";
9
+ export { CONSUMER_RELEASE_VERSION } from "./release.mjs";
5
10
 
6
11
  import {
7
12
  DEFAULT_BASE_URL,
@@ -9,9 +14,19 @@ import {
9
14
  DEFAULT_RELAY_URL,
10
15
  NativeConsumerState,
11
16
  createConsumerServer,
17
+ parseNetworkConfig,
18
+ paymentKeyAddress,
12
19
  } from "./consumer-runtime.mjs";
13
20
 
14
- export const CONSUMER_RELEASE_VERSION = "0.1.51";
21
+ // V10 is a controlled committee testnet. Keep its manifest in the package,
22
+ // but make selecting it an explicit opt-in so the legacy V8 default remains
23
+ // safe for existing installs.
24
+ export const V10_CONTROLLED_TEST_NETWORK = fileURLToPath(
25
+ new URL("../networks/v10-controlled-test.json", import.meta.url),
26
+ );
27
+ export const V10_DYNAMIC_PROVIDER_AI_NETWORK = fileURLToPath(
28
+ new URL("../networks/v10-dynamic-20260926.json", import.meta.url),
29
+ );
15
30
  export const API_COMMANDS = new Set(["health", "models", "responses", "chat"]);
16
31
  const API_VALUE_OPTIONS = new Set([
17
32
  "--base-url",
@@ -31,7 +46,7 @@ const API_VALUE_OPTIONS = new Set([
31
46
  export const CONSUMER_HELP = `Usage: mycomesh-consumer [options] [-- codex-options]
32
47
 
33
48
  Start the local MycoMesh Consumer without Docker, Python, or a public Gateway.
34
- The process owns one persisted V8 payment key, selects healthy Relays, and
49
+ The process manages a persisted access key, selects healthy Relays, and
35
50
  exposes an OpenAI-compatible loopback API.
36
51
 
37
52
  Options:
@@ -44,16 +59,25 @@ Options:
44
59
  --ready-timeout SEC Relay readiness timeout (default: 1800)
45
60
  --data-dir DIR Payment key and history directory
46
61
  --relay URLS Comma-separated Relay URLs for automatic failover
62
+ --network-config FILE Use a trusted network manifest and its backup Relays
63
+ --ca-file FILE Private CA PEM for an explicit controlled-test network
64
+ --controlled-test Explicitly opt into a controlled test committee
65
+ --v10-controlled-test Select the bundled V10 fixed-budget controlled testnet
66
+ --v10-dynamic-provider-ai Select the bundled V10 dynamic Provider-AI testnet
47
67
  --proxy URL Optional outbound HTTP proxy
48
68
  --host HOST Listen address (default: 127.0.0.1)
49
69
  --port PORT Listen port (default: 8110)
50
70
  --max-fee UNITS Maximum fee per request (default: 100000)
51
71
  --dry-run Print the native startup plan
72
+ --doctor Check local Consumer configuration without starting or paying
73
+ --doctor-json Emit the same check as stable JSON for automation
52
74
  -h, --help Show this help
53
75
  -v, --version Show the package version
54
76
 
55
- Each Consumer process starts locked. Open the browser page, sign with the
56
- wallet that owns the on-chain payment-key grant, then use the displayed export.
77
+ Each Consumer process starts locked. Connect your browser wallet on the setup
78
+ page. For first use, choose Enable API access and confirm the wallet request;
79
+ returning users reuse the existing authorization. Copy the API URL and key (or
80
+ the combined export) into your client. Never enter a wallet private key.
57
81
  The page has no browser conversation state.`;
58
82
 
59
83
  class ConsumerCliError extends Error {
@@ -78,10 +102,13 @@ export async function main(argv, dependencies = {}) {
78
102
  stdout.write(`${CONSUMER_RELEASE_VERSION}\n`);
79
103
  return 0;
80
104
  }
105
+ if (parsed.doctor || parsed.doctorJson) {
106
+ return await consumerDoctor({ parsed, env, stdout, fetch: dependencies.fetch, json: parsed.doctorJson });
107
+ }
81
108
  if (parsed.dryRun) {
82
109
  stdout.write(`Native Consumer: ${parsed.host}:${parsed.port}\n`);
83
110
  stdout.write(`Data directory: ${parsed.dataDir}\n`);
84
- stdout.write(`Relays: ${parsed.relayUrls}\n`);
111
+ stdout.write(`Relays: ${!parsed.relayUrlsExplicit && parsed.networkConfig ? "from selected network manifest (legacy default if absent)" : parsed.relayUrls}\n`);
85
112
  stdout.write("Docker: disabled\n");
86
113
  return 0;
87
114
  }
@@ -93,7 +120,10 @@ export async function main(argv, dependencies = {}) {
93
120
  : new NativeConsumerState({
94
121
  env: { ...env, MYCOMESH_CONSUMER_DATA_DIR: parsed.dataDir },
95
122
  dataDir: parsed.dataDir,
96
- relayUrls: parsed.relayUrls,
123
+ relayUrls: parsed.relayUrlsExplicit ? parsed.relayUrls : undefined,
124
+ networkConfig: parsed.networkConfig,
125
+ caFile: parsed.caFile,
126
+ allowControlledTest: parsed.allowControlledTest,
97
127
  proxy: parsed.proxy,
98
128
  baseUrl: parsed.baseUrl,
99
129
  maxFeeUnits: parsed.maxFeeUnits,
@@ -139,6 +169,9 @@ export function parseArguments(argv, env = process.env) {
139
169
  baseUrlExplicit: Boolean(env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL),
140
170
  dataDir: env.MYCOMESH_CONSUMER_DATA_DIR || join(env.HOME || process.cwd(), ".mycomesh", "consumer"),
141
171
  relayUrls: env.MYCOMESH_V8_RELAY_URLS || env.MYCOMESH_CONSUMER_RELAY_URL || DEFAULT_RELAY_URL,
172
+ relayUrlsExplicit: Boolean(env.MYCOMESH_V8_RELAY_URLS || env.MYCOMESH_CONSUMER_RELAY_URL),
173
+ networkConfig: env.MYCOMESH_CONSUMER_NETWORK_CONFIG || undefined,
174
+ caFile: env.MYCOMESH_CONSUMER_CA_FILE || undefined,
142
175
  proxy: env.MYCOMESH_CONSUMER_PROXY || "",
143
176
  codexCommand: env.MYCOMESH_CODEX_COMMAND || "codex",
144
177
  readyTimeout: parsePositive(env.MYCOMESH_CONSUMER_READY_TIMEOUT_SECONDS || "1800", "ready timeout", 86400),
@@ -147,11 +180,16 @@ export function parseArguments(argv, env = process.env) {
147
180
  hostForUrl: env.MYCOMESH_CONSUMER_HOST || "127.0.0.1",
148
181
  port: parsePositive(env.MYCOMESH_CONSUMER_PORT || "8110", "port", 65535),
149
182
  scheme: "http",
183
+ allowControlledTest: false,
184
+ v10ControlledTest: false,
185
+ v10DynamicProviderAi: false,
150
186
  noBrowser: false,
151
187
  noCodex: env.MYCOMESH_CONSUMER_START_CODEX !== "1",
152
188
  stop: false,
153
189
  resetLocal: false,
154
190
  dryRun: false,
191
+ doctor: false,
192
+ doctorJson: false,
155
193
  help: false,
156
194
  version: false,
157
195
  codexArgs: [],
@@ -161,22 +199,37 @@ export function parseArguments(argv, env = process.env) {
161
199
  if (token === "--") { parsed.codexArgs = argv.slice(index + 1); break; }
162
200
  if (token === "-h" || token === "--help") { parsed.help = true; continue; }
163
201
  if (token === "-v" || token === "--version") { parsed.version = true; continue; }
202
+ if (token === "--controlled-test") { parsed.allowControlledTest = true; continue; }
203
+ if (token === "--v10-controlled-test") {
204
+ parsed.v10ControlledTest = true;
205
+ parsed.allowControlledTest = true;
206
+ continue;
207
+ }
208
+ if (token === "--v10-dynamic-provider-ai") {
209
+ parsed.v10DynamicProviderAi = true;
210
+ parsed.allowControlledTest = true;
211
+ continue;
212
+ }
164
213
  if (token === "--no-browser") { parsed.noBrowser = true; continue; }
165
214
  if (token === "--codex") { parsed.noCodex = false; continue; }
166
215
  if (token === "--no-codex") { parsed.noCodex = true; continue; }
167
216
  if (token === "--stop") { parsed.stop = true; continue; }
168
217
  if (token === "--reset-local") { parsed.resetLocal = true; continue; }
169
218
  if (token === "--dry-run") { parsed.dryRun = true; continue; }
219
+ if (token === "--doctor") { parsed.doctor = true; continue; }
220
+ if (token === "--doctor-json") { parsed.doctorJson = true; continue; }
170
221
  const separator = token.indexOf("=");
171
222
  const name = separator === -1 ? token : token.slice(0, separator);
172
223
  let value = separator === -1 ? undefined : token.slice(separator + 1);
173
- const options = new Set(["--base-url", "--data-dir", "--relay", "--proxy", "--codex-command", "--ready-timeout", "--host", "--port", "--max-fee"]);
224
+ const options = new Set(["--base-url", "--data-dir", "--relay", "--network-config", "--ca-file", "--proxy", "--codex-command", "--ready-timeout", "--host", "--port", "--max-fee"]);
174
225
  if (!options.has(name)) throw new ConsumerCliError(`unknown option: ${token}`, 2);
175
226
  if (value === undefined) { index += 1; value = argv[index]; }
176
227
  if (!value) throw new ConsumerCliError(`${name} requires a value`, 2);
177
228
  if (name === "--base-url") { parsed.baseUrl = value; parsed.baseUrlExplicit = true; }
178
229
  if (name === "--data-dir") parsed.dataDir = value;
179
- if (name === "--relay") parsed.relayUrls = value;
230
+ if (name === "--relay") { parsed.relayUrls = value; parsed.relayUrlsExplicit = true; }
231
+ if (name === "--network-config") parsed.networkConfig = value;
232
+ if (name === "--ca-file") parsed.caFile = value;
180
233
  if (name === "--proxy") parsed.proxy = value;
181
234
  if (name === "--codex-command") parsed.codexCommand = value;
182
235
  if (name === "--ready-timeout") parsed.readyTimeout = parsePositive(value, name, 86400);
@@ -184,11 +237,154 @@ export function parseArguments(argv, env = process.env) {
184
237
  if (name === "--port") parsed.port = parsePositive(value, name, 65535);
185
238
  if (name === "--max-fee") parsed.maxFeeUnits = parsePositive(value, name);
186
239
  }
240
+ if (parsed.v10ControlledTest && !parsed.networkConfig) {
241
+ parsed.networkConfig = env.MYCOMESH_V10_NETWORK_CONFIG || V10_CONTROLLED_TEST_NETWORK;
242
+ }
243
+ if (parsed.v10DynamicProviderAi && !parsed.networkConfig) {
244
+ parsed.networkConfig = env.MYCOMESH_V10_DYNAMIC_NETWORK_CONFIG || V10_DYNAMIC_PROVIDER_AI_NETWORK;
245
+ }
187
246
  if (!parsed.baseUrlExplicit) parsed.baseUrl = `http://${parsed.hostForUrl}:${parsed.port}/v1`;
188
247
  try { new URL(parsed.baseUrl); } catch { throw new ConsumerCliError("--base-url must be an absolute URL", 2); }
189
248
  return parsed;
190
249
  }
191
250
 
251
+ // Read-only preflight. This deliberately does not construct NativeConsumerState:
252
+ // its constructor creates a payment key on first run. A doctor command must be
253
+ // safe to run from installers, CI and support scripts without changing state.
254
+ async function consumerDoctor({ parsed, env, stdout, fetch: injectedFetch, json }) {
255
+ const checks = [];
256
+ const add = (id, status, message, details = undefined) => {
257
+ const check = { id, status, message };
258
+ if (details !== undefined) check.details = details;
259
+ checks.push(check);
260
+ };
261
+ const dataDir = parsed.dataDir;
262
+ if (existsSync(dataDir)) add("data_dir", "ready", "Consumer data directory exists", { path: dataDir });
263
+ else add("data_dir", "setup_required", "Consumer data directory has not been created", { path: dataDir });
264
+
265
+ const keyPath = join(dataDir, "payment-key");
266
+ const configuredKey = String(env.MYCOMESH_V8_PAYMENT_KEY || "").trim();
267
+ let paymentAddress;
268
+ try {
269
+ if (configuredKey) paymentAddress = paymentKeyAddress(configuredKey);
270
+ else if (existsSync(keyPath)) paymentAddress = paymentKeyAddress(readFileSync(keyPath, "utf8").trim());
271
+ else throw Object.assign(new Error("payment key is not initialized"), { code: "setup_required" });
272
+ add("payment_key", "ready", "Payment key is present and valid", {
273
+ source: configuredKey ? "environment" : "data_dir",
274
+ address: paymentAddress,
275
+ });
276
+ } catch (error) {
277
+ add("payment_key", error.code === "setup_required" ? "setup_required" : "blocked", error.message);
278
+ }
279
+
280
+ let network;
281
+ try {
282
+ network = parseNetworkConfig(parsed.networkConfig, { allowControlledTest: parsed.allowControlledTest });
283
+ add("network_manifest", "ready", parsed.networkConfig ? "Settlement network manifest is valid" : "Using built-in settlement network", {
284
+ path: parsed.networkConfig || null,
285
+ protocol_version: network.protocol_version,
286
+ chain_id: network.chain_id,
287
+ settlement_contract: network.settlement_contract,
288
+ });
289
+ } catch (error) {
290
+ add("network_manifest", "blocked", error.message, { path: parsed.networkConfig || null });
291
+ }
292
+
293
+ const relayUrls = network?.relay_urls?.length ? network.relay_urls : String(parsed.relayUrls || "")
294
+ .split(",").map((value) => value.trim()).filter(Boolean);
295
+ const validRelays = [];
296
+ for (const value of relayUrls) {
297
+ try {
298
+ const url = new URL(value);
299
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("URL must be an HTTPS origin without credentials or query");
300
+ validRelays.push(url.href.replace(/\/+$/, ""));
301
+ } catch (error) {
302
+ add("relay_urls", "blocked", `Invalid Relay URL: ${error.message}`, { url: value });
303
+ }
304
+ }
305
+ if (validRelays.length) add("relay_urls", "ready", `${validRelays.length} Relay URL${validRelays.length === 1 ? "" : "s"} configured`, { urls: validRelays });
306
+ else if (!checks.some((check) => check.id === "relay_urls")) add("relay_urls", "blocked", "No usable Relay URL is configured");
307
+
308
+ const pidPath = join(dataDir, "consumer.pid");
309
+ if (!existsSync(pidPath)) add("local_process", "ready", "No stale Consumer pid file found", { running: false });
310
+ else {
311
+ const pid = Number(readFileSync(pidPath, "utf8").trim());
312
+ let running = false;
313
+ try { if (Number.isInteger(pid) && pid > 1) { process.kill(pid, 0); running = true; } } catch (error) { if (error.code !== "ESRCH") running = true; }
314
+ add("local_process", running ? "ready" : "blocked", running ? "Consumer process is running" : "Consumer pid file is stale", { pid, running });
315
+ }
316
+
317
+ const doctorFetchConfig = {
318
+ ...parsed,
319
+ caFile: parsed.caFile || network?.tls_ca_file,
320
+ };
321
+ const fetchImpl = injectedFetch || ((url, options) => consumerDoctorFetch(url, options, doctorFetchConfig));
322
+ const health = [];
323
+ if (validRelays.length && typeof fetchImpl === "function") {
324
+ await Promise.all(validRelays.map(async (relay) => {
325
+ const controller = new AbortController();
326
+ const timer = setTimeout(() => controller.abort(), 1500);
327
+ try {
328
+ const response = await fetchImpl(`${relay}/health`, { method: "GET", redirect: "error", signal: controller.signal });
329
+ health.push({ url: relay, status: response.ok ? "ready" : "blocked", http_status: response.status });
330
+ await response.body?.cancel?.();
331
+ } catch (error) { health.push({ url: relay, status: "blocked", error: error.message }); }
332
+ finally { clearTimeout(timer); }
333
+ }));
334
+ const healthy = health.filter((item) => item.status === "ready").length;
335
+ const relayStatus = healthy === 0 ? "blocked" : healthy < health.length ? "degraded" : "ready";
336
+ add("relay_health", relayStatus, healthy ? `${healthy}/${health.length} Relay health checks passed` : "All Relay health checks failed", { relays: health });
337
+ } else add("relay_health", "setup_required", "Relay health was not checked because no usable Relay URL is configured");
338
+
339
+ const hasBlocked = checks.some((check) => check.status === "blocked");
340
+ const hasSetup = checks.some((check) => check.status === "setup_required");
341
+ const hasDegraded = checks.some((check) => check.status === "degraded");
342
+ const report = {
343
+ schema: "mycomesh.consumer.doctor.v1",
344
+ status: hasBlocked ? "blocked" : hasSetup ? "setup_required" : hasDegraded ? "degraded" : "ready",
345
+ release: { version: CONSUMER_RELEASE_VERSION },
346
+ config: { data_dir: dataDir, host: parsed.host, port: parsed.port, base_url: parsed.baseUrl },
347
+ checks,
348
+ };
349
+ if (json) stdout.write(`${JSON.stringify(report)}\n`);
350
+ else {
351
+ stdout.write(`Consumer doctor: ${report.status}\n`);
352
+ for (const check of checks) stdout.write(`${check.status === "ready" ? "✓" : check.status === "setup_required" ? "!" : check.status === "degraded" ? "~" : "✗"} ${check.id}: ${check.message}\n`);
353
+ }
354
+ return report.status === "blocked" ? 1 : 0;
355
+ }
356
+
357
+ export function consumerDoctorFetch(url, options = {}, parsed, requestImpl = httpsRequest) {
358
+ const target = new URL(url);
359
+ if (target.protocol !== "https:" || !parsed.caFile) return globalThis.fetch(url, options);
360
+ const ca = readFileSync(resolve(parsed.caFile)).toString("utf8");
361
+ return new Promise((resolvePromise, reject) => {
362
+ const request = requestImpl({
363
+ protocol: target.protocol,
364
+ hostname: target.hostname,
365
+ port: target.port || 443,
366
+ path: `${target.pathname}${target.search}`,
367
+ method: options.method || "GET",
368
+ // A private manifest CA supplements, rather than replaces, public trust.
369
+ ca: [...rootCertificates, ca],
370
+ rejectUnauthorized: true,
371
+ }, (response) => {
372
+ response.resume();
373
+ resolvePromise({
374
+ ok: (response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300,
375
+ status: response.statusCode || 0,
376
+ body: { cancel: async () => {} },
377
+ });
378
+ });
379
+ const abort = () => request.destroy(options.signal?.reason || new Error("request aborted"));
380
+ options.signal?.addEventListener("abort", abort, { once: true });
381
+ request.setTimeout(1500, () => request.destroy(new Error("request timed out")));
382
+ request.once("error", reject);
383
+ request.once("close", () => options.signal?.removeEventListener("abort", abort));
384
+ request.end();
385
+ });
386
+ }
387
+
192
388
  function parsePositive(value, label, maximum = Number.MAX_SAFE_INTEGER) {
193
389
  if (!/^[1-9][0-9]*$/.test(String(value))) throw new ConsumerCliError(`${label} must be a positive integer`, 2);
194
390
  const parsed = Number(value);
@@ -213,7 +409,7 @@ export function isApiInvocation(argv) {
213
409
 
214
410
  async function waitUntilReady(state, timeoutSeconds, stdout) {
215
411
  const started = Date.now();
216
- stdout.write("Waiting for a healthy Settlement V8 Relay...\n");
412
+ stdout.write(`Waiting for a healthy Settlement V${state.network?.protocol_version || 8} Relay...\n`);
217
413
  while (true) {
218
414
  try { await state.chooseRelay(); return; } catch (error) {
219
415
  if (Date.now() - started >= timeoutSeconds * 1000) throw new ConsumerCliError(`timed out waiting for a healthy Relay: ${error.message}`);
@@ -0,0 +1,12 @@
1
+ // Package versions are kept in one runtime module so every executable reports
2
+ // the same version as the package metadata that ships it.
3
+ export const CONSUMER_RELEASE_VERSION = "0.1.53";
4
+ export const PROVIDER_RELEASE_VERSION = "0.1.39";
5
+
6
+ // Provider release pins are intentionally unbound in the source tree. The
7
+ // release candidate builder injects the commit and immutable OCI digest into a
8
+ // temporary Provider package after the image has been published. Keeping these
9
+ // values null prevents a source checkout from pretending that a not-yet-built
10
+ // image belongs to the current source.
11
+ export const PROVIDER_RELEASE_SOURCE_COMMIT = null;
12
+ export const PROVIDER_RELEASE_IMAGE = null;