mycomesh-consumer 0.1.51 → 0.1.52

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,11 @@
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 { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
  import * as readline from "node:readline/promises";
7
+ import { CONSUMER_RELEASE_VERSION } from "./release.mjs";
8
+ export { CONSUMER_RELEASE_VERSION } from "./release.mjs";
5
9
 
6
10
  import {
7
11
  DEFAULT_BASE_URL,
@@ -9,9 +13,19 @@ import {
9
13
  DEFAULT_RELAY_URL,
10
14
  NativeConsumerState,
11
15
  createConsumerServer,
16
+ parseNetworkConfig,
17
+ paymentKeyAddress,
12
18
  } from "./consumer-runtime.mjs";
13
19
 
14
- export const CONSUMER_RELEASE_VERSION = "0.1.51";
20
+ // V10 is a controlled committee testnet. Keep its manifest in the package,
21
+ // but make selecting it an explicit opt-in so the legacy V8 default remains
22
+ // safe for existing installs.
23
+ export const V10_CONTROLLED_TEST_NETWORK = fileURLToPath(
24
+ new URL("../networks/v10-controlled-test.json", import.meta.url),
25
+ );
26
+ export const V10_DYNAMIC_PROVIDER_AI_NETWORK = fileURLToPath(
27
+ new URL("../networks/v10-dynamic-20260926.json", import.meta.url),
28
+ );
15
29
  export const API_COMMANDS = new Set(["health", "models", "responses", "chat"]);
16
30
  const API_VALUE_OPTIONS = new Set([
17
31
  "--base-url",
@@ -31,7 +45,7 @@ const API_VALUE_OPTIONS = new Set([
31
45
  export const CONSUMER_HELP = `Usage: mycomesh-consumer [options] [-- codex-options]
32
46
 
33
47
  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
48
+ The process manages a persisted access key, selects healthy Relays, and
35
49
  exposes an OpenAI-compatible loopback API.
36
50
 
37
51
  Options:
@@ -44,16 +58,25 @@ Options:
44
58
  --ready-timeout SEC Relay readiness timeout (default: 1800)
45
59
  --data-dir DIR Payment key and history directory
46
60
  --relay URLS Comma-separated Relay URLs for automatic failover
61
+ --network-config FILE Use a trusted network manifest and its backup Relays
62
+ --ca-file FILE Private CA PEM for an explicit controlled-test network
63
+ --controlled-test Explicitly opt into a controlled test committee
64
+ --v10-controlled-test Select the bundled V10 fixed-budget controlled testnet
65
+ --v10-dynamic-provider-ai Select the bundled V10 dynamic Provider-AI testnet
47
66
  --proxy URL Optional outbound HTTP proxy
48
67
  --host HOST Listen address (default: 127.0.0.1)
49
68
  --port PORT Listen port (default: 8110)
50
69
  --max-fee UNITS Maximum fee per request (default: 100000)
51
70
  --dry-run Print the native startup plan
71
+ --doctor Check local Consumer configuration without starting or paying
72
+ --doctor-json Emit the same check as stable JSON for automation
52
73
  -h, --help Show this help
53
74
  -v, --version Show the package version
54
75
 
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.
76
+ Each Consumer process starts locked. Connect your browser wallet on the setup
77
+ page. For first use, choose Enable API access and confirm the wallet request;
78
+ returning users reuse the existing authorization. Copy the API URL and key (or
79
+ the combined export) into your client. Never enter a wallet private key.
57
80
  The page has no browser conversation state.`;
58
81
 
59
82
  class ConsumerCliError extends Error {
@@ -78,10 +101,13 @@ export async function main(argv, dependencies = {}) {
78
101
  stdout.write(`${CONSUMER_RELEASE_VERSION}\n`);
79
102
  return 0;
80
103
  }
104
+ if (parsed.doctor || parsed.doctorJson) {
105
+ return await consumerDoctor({ parsed, env, stdout, fetch: dependencies.fetch, json: parsed.doctorJson });
106
+ }
81
107
  if (parsed.dryRun) {
82
108
  stdout.write(`Native Consumer: ${parsed.host}:${parsed.port}\n`);
83
109
  stdout.write(`Data directory: ${parsed.dataDir}\n`);
84
- stdout.write(`Relays: ${parsed.relayUrls}\n`);
110
+ stdout.write(`Relays: ${!parsed.relayUrlsExplicit && parsed.networkConfig ? "from selected network manifest (legacy default if absent)" : parsed.relayUrls}\n`);
85
111
  stdout.write("Docker: disabled\n");
86
112
  return 0;
87
113
  }
@@ -93,7 +119,10 @@ export async function main(argv, dependencies = {}) {
93
119
  : new NativeConsumerState({
94
120
  env: { ...env, MYCOMESH_CONSUMER_DATA_DIR: parsed.dataDir },
95
121
  dataDir: parsed.dataDir,
96
- relayUrls: parsed.relayUrls,
122
+ relayUrls: parsed.relayUrlsExplicit ? parsed.relayUrls : undefined,
123
+ networkConfig: parsed.networkConfig,
124
+ caFile: parsed.caFile,
125
+ allowControlledTest: parsed.allowControlledTest,
97
126
  proxy: parsed.proxy,
98
127
  baseUrl: parsed.baseUrl,
99
128
  maxFeeUnits: parsed.maxFeeUnits,
@@ -139,6 +168,9 @@ export function parseArguments(argv, env = process.env) {
139
168
  baseUrlExplicit: Boolean(env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL),
140
169
  dataDir: env.MYCOMESH_CONSUMER_DATA_DIR || join(env.HOME || process.cwd(), ".mycomesh", "consumer"),
141
170
  relayUrls: env.MYCOMESH_V8_RELAY_URLS || env.MYCOMESH_CONSUMER_RELAY_URL || DEFAULT_RELAY_URL,
171
+ relayUrlsExplicit: Boolean(env.MYCOMESH_V8_RELAY_URLS || env.MYCOMESH_CONSUMER_RELAY_URL),
172
+ networkConfig: env.MYCOMESH_CONSUMER_NETWORK_CONFIG || undefined,
173
+ caFile: env.MYCOMESH_CONSUMER_CA_FILE || undefined,
142
174
  proxy: env.MYCOMESH_CONSUMER_PROXY || "",
143
175
  codexCommand: env.MYCOMESH_CODEX_COMMAND || "codex",
144
176
  readyTimeout: parsePositive(env.MYCOMESH_CONSUMER_READY_TIMEOUT_SECONDS || "1800", "ready timeout", 86400),
@@ -147,11 +179,16 @@ export function parseArguments(argv, env = process.env) {
147
179
  hostForUrl: env.MYCOMESH_CONSUMER_HOST || "127.0.0.1",
148
180
  port: parsePositive(env.MYCOMESH_CONSUMER_PORT || "8110", "port", 65535),
149
181
  scheme: "http",
182
+ allowControlledTest: false,
183
+ v10ControlledTest: false,
184
+ v10DynamicProviderAi: false,
150
185
  noBrowser: false,
151
186
  noCodex: env.MYCOMESH_CONSUMER_START_CODEX !== "1",
152
187
  stop: false,
153
188
  resetLocal: false,
154
189
  dryRun: false,
190
+ doctor: false,
191
+ doctorJson: false,
155
192
  help: false,
156
193
  version: false,
157
194
  codexArgs: [],
@@ -161,22 +198,37 @@ export function parseArguments(argv, env = process.env) {
161
198
  if (token === "--") { parsed.codexArgs = argv.slice(index + 1); break; }
162
199
  if (token === "-h" || token === "--help") { parsed.help = true; continue; }
163
200
  if (token === "-v" || token === "--version") { parsed.version = true; continue; }
201
+ if (token === "--controlled-test") { parsed.allowControlledTest = true; continue; }
202
+ if (token === "--v10-controlled-test") {
203
+ parsed.v10ControlledTest = true;
204
+ parsed.allowControlledTest = true;
205
+ continue;
206
+ }
207
+ if (token === "--v10-dynamic-provider-ai") {
208
+ parsed.v10DynamicProviderAi = true;
209
+ parsed.allowControlledTest = true;
210
+ continue;
211
+ }
164
212
  if (token === "--no-browser") { parsed.noBrowser = true; continue; }
165
213
  if (token === "--codex") { parsed.noCodex = false; continue; }
166
214
  if (token === "--no-codex") { parsed.noCodex = true; continue; }
167
215
  if (token === "--stop") { parsed.stop = true; continue; }
168
216
  if (token === "--reset-local") { parsed.resetLocal = true; continue; }
169
217
  if (token === "--dry-run") { parsed.dryRun = true; continue; }
218
+ if (token === "--doctor") { parsed.doctor = true; continue; }
219
+ if (token === "--doctor-json") { parsed.doctorJson = true; continue; }
170
220
  const separator = token.indexOf("=");
171
221
  const name = separator === -1 ? token : token.slice(0, separator);
172
222
  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"]);
223
+ const options = new Set(["--base-url", "--data-dir", "--relay", "--network-config", "--ca-file", "--proxy", "--codex-command", "--ready-timeout", "--host", "--port", "--max-fee"]);
174
224
  if (!options.has(name)) throw new ConsumerCliError(`unknown option: ${token}`, 2);
175
225
  if (value === undefined) { index += 1; value = argv[index]; }
176
226
  if (!value) throw new ConsumerCliError(`${name} requires a value`, 2);
177
227
  if (name === "--base-url") { parsed.baseUrl = value; parsed.baseUrlExplicit = true; }
178
228
  if (name === "--data-dir") parsed.dataDir = value;
179
- if (name === "--relay") parsed.relayUrls = value;
229
+ if (name === "--relay") { parsed.relayUrls = value; parsed.relayUrlsExplicit = true; }
230
+ if (name === "--network-config") parsed.networkConfig = value;
231
+ if (name === "--ca-file") parsed.caFile = value;
180
232
  if (name === "--proxy") parsed.proxy = value;
181
233
  if (name === "--codex-command") parsed.codexCommand = value;
182
234
  if (name === "--ready-timeout") parsed.readyTimeout = parsePositive(value, name, 86400);
@@ -184,11 +236,149 @@ export function parseArguments(argv, env = process.env) {
184
236
  if (name === "--port") parsed.port = parsePositive(value, name, 65535);
185
237
  if (name === "--max-fee") parsed.maxFeeUnits = parsePositive(value, name);
186
238
  }
239
+ if (parsed.v10ControlledTest && !parsed.networkConfig) {
240
+ parsed.networkConfig = env.MYCOMESH_V10_NETWORK_CONFIG || V10_CONTROLLED_TEST_NETWORK;
241
+ }
242
+ if (parsed.v10DynamicProviderAi && !parsed.networkConfig) {
243
+ parsed.networkConfig = env.MYCOMESH_V10_DYNAMIC_NETWORK_CONFIG || V10_DYNAMIC_PROVIDER_AI_NETWORK;
244
+ }
187
245
  if (!parsed.baseUrlExplicit) parsed.baseUrl = `http://${parsed.hostForUrl}:${parsed.port}/v1`;
188
246
  try { new URL(parsed.baseUrl); } catch { throw new ConsumerCliError("--base-url must be an absolute URL", 2); }
189
247
  return parsed;
190
248
  }
191
249
 
250
+ // Read-only preflight. This deliberately does not construct NativeConsumerState:
251
+ // its constructor creates a payment key on first run. A doctor command must be
252
+ // safe to run from installers, CI and support scripts without changing state.
253
+ async function consumerDoctor({ parsed, env, stdout, fetch: injectedFetch, json }) {
254
+ const checks = [];
255
+ const add = (id, status, message, details = undefined) => {
256
+ const check = { id, status, message };
257
+ if (details !== undefined) check.details = details;
258
+ checks.push(check);
259
+ };
260
+ const dataDir = parsed.dataDir;
261
+ if (existsSync(dataDir)) add("data_dir", "ready", "Consumer data directory exists", { path: dataDir });
262
+ else add("data_dir", "setup_required", "Consumer data directory has not been created", { path: dataDir });
263
+
264
+ const keyPath = join(dataDir, "payment-key");
265
+ const configuredKey = String(env.MYCOMESH_V8_PAYMENT_KEY || "").trim();
266
+ let paymentAddress;
267
+ try {
268
+ if (configuredKey) paymentAddress = paymentKeyAddress(configuredKey);
269
+ else if (existsSync(keyPath)) paymentAddress = paymentKeyAddress(readFileSync(keyPath, "utf8").trim());
270
+ else throw Object.assign(new Error("payment key is not initialized"), { code: "setup_required" });
271
+ add("payment_key", "ready", "Payment key is present and valid", {
272
+ source: configuredKey ? "environment" : "data_dir",
273
+ address: paymentAddress,
274
+ });
275
+ } catch (error) {
276
+ add("payment_key", error.code === "setup_required" ? "setup_required" : "blocked", error.message);
277
+ }
278
+
279
+ let network;
280
+ try {
281
+ network = parseNetworkConfig(parsed.networkConfig, { allowControlledTest: parsed.allowControlledTest });
282
+ add("network_manifest", "ready", parsed.networkConfig ? "Settlement network manifest is valid" : "Using built-in settlement network", {
283
+ path: parsed.networkConfig || null,
284
+ protocol_version: network.protocol_version,
285
+ chain_id: network.chain_id,
286
+ settlement_contract: network.settlement_contract,
287
+ });
288
+ } catch (error) {
289
+ add("network_manifest", "blocked", error.message, { path: parsed.networkConfig || null });
290
+ }
291
+
292
+ const relayUrls = network?.relay_urls?.length ? network.relay_urls : String(parsed.relayUrls || "")
293
+ .split(",").map((value) => value.trim()).filter(Boolean);
294
+ const validRelays = [];
295
+ for (const value of relayUrls) {
296
+ try {
297
+ const url = new URL(value);
298
+ 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");
299
+ validRelays.push(url.href.replace(/\/+$/, ""));
300
+ } catch (error) {
301
+ add("relay_urls", "blocked", `Invalid Relay URL: ${error.message}`, { url: value });
302
+ }
303
+ }
304
+ if (validRelays.length) add("relay_urls", "ready", `${validRelays.length} Relay URL${validRelays.length === 1 ? "" : "s"} configured`, { urls: validRelays });
305
+ else if (!checks.some((check) => check.id === "relay_urls")) add("relay_urls", "blocked", "No usable Relay URL is configured");
306
+
307
+ const pidPath = join(dataDir, "consumer.pid");
308
+ if (!existsSync(pidPath)) add("local_process", "ready", "No stale Consumer pid file found", { running: false });
309
+ else {
310
+ const pid = Number(readFileSync(pidPath, "utf8").trim());
311
+ let running = false;
312
+ try { if (Number.isInteger(pid) && pid > 1) { process.kill(pid, 0); running = true; } } catch (error) { if (error.code !== "ESRCH") running = true; }
313
+ add("local_process", running ? "ready" : "blocked", running ? "Consumer process is running" : "Consumer pid file is stale", { pid, running });
314
+ }
315
+
316
+ const fetchImpl = injectedFetch || ((url, options) => consumerDoctorFetch(url, options, parsed));
317
+ const health = [];
318
+ if (validRelays.length && typeof fetchImpl === "function") {
319
+ await Promise.all(validRelays.map(async (relay) => {
320
+ const controller = new AbortController();
321
+ const timer = setTimeout(() => controller.abort(), 1500);
322
+ try {
323
+ const response = await fetchImpl(`${relay}/health`, { method: "GET", redirect: "error", signal: controller.signal });
324
+ health.push({ url: relay, status: response.ok ? "ready" : "blocked", http_status: response.status });
325
+ await response.body?.cancel?.();
326
+ } catch (error) { health.push({ url: relay, status: "blocked", error: error.message }); }
327
+ finally { clearTimeout(timer); }
328
+ }));
329
+ const healthy = health.filter((item) => item.status === "ready").length;
330
+ const relayStatus = healthy === 0 ? "blocked" : healthy < health.length ? "degraded" : "ready";
331
+ add("relay_health", relayStatus, healthy ? `${healthy}/${health.length} Relay health checks passed` : "All Relay health checks failed", { relays: health });
332
+ } else add("relay_health", "setup_required", "Relay health was not checked because no usable Relay URL is configured");
333
+
334
+ const hasBlocked = checks.some((check) => check.status === "blocked");
335
+ const hasSetup = checks.some((check) => check.status === "setup_required");
336
+ const hasDegraded = checks.some((check) => check.status === "degraded");
337
+ const report = {
338
+ schema: "mycomesh.consumer.doctor.v1",
339
+ status: hasBlocked ? "blocked" : hasSetup ? "setup_required" : hasDegraded ? "degraded" : "ready",
340
+ release: { version: CONSUMER_RELEASE_VERSION },
341
+ config: { data_dir: dataDir, host: parsed.host, port: parsed.port, base_url: parsed.baseUrl },
342
+ checks,
343
+ };
344
+ if (json) stdout.write(`${JSON.stringify(report)}\n`);
345
+ else {
346
+ stdout.write(`Consumer doctor: ${report.status}\n`);
347
+ for (const check of checks) stdout.write(`${check.status === "ready" ? "✓" : check.status === "setup_required" ? "!" : check.status === "degraded" ? "~" : "✗"} ${check.id}: ${check.message}\n`);
348
+ }
349
+ return report.status === "blocked" ? 1 : 0;
350
+ }
351
+
352
+ function consumerDoctorFetch(url, options = {}, parsed) {
353
+ const target = new URL(url);
354
+ if (target.protocol !== "https:" || !parsed.caFile) return globalThis.fetch(url, options);
355
+ const ca = readFileSync(resolve(parsed.caFile));
356
+ return new Promise((resolvePromise, reject) => {
357
+ const request = httpsRequest({
358
+ protocol: target.protocol,
359
+ hostname: target.hostname,
360
+ port: target.port || 443,
361
+ path: `${target.pathname}${target.search}`,
362
+ method: options.method || "GET",
363
+ ca,
364
+ rejectUnauthorized: true,
365
+ }, (response) => {
366
+ response.resume();
367
+ resolvePromise({
368
+ ok: (response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300,
369
+ status: response.statusCode || 0,
370
+ body: { cancel: async () => {} },
371
+ });
372
+ });
373
+ const abort = () => request.destroy(options.signal?.reason || new Error("request aborted"));
374
+ options.signal?.addEventListener("abort", abort, { once: true });
375
+ request.setTimeout(1500, () => request.destroy(new Error("request timed out")));
376
+ request.once("error", reject);
377
+ request.once("close", () => options.signal?.removeEventListener("abort", abort));
378
+ request.end();
379
+ });
380
+ }
381
+
192
382
  function parsePositive(value, label, maximum = Number.MAX_SAFE_INTEGER) {
193
383
  if (!/^[1-9][0-9]*$/.test(String(value))) throw new ConsumerCliError(`${label} must be a positive integer`, 2);
194
384
  const parsed = Number(value);
@@ -213,7 +403,7 @@ export function isApiInvocation(argv) {
213
403
 
214
404
  async function waitUntilReady(state, timeoutSeconds, stdout) {
215
405
  const started = Date.now();
216
- stdout.write("Waiting for a healthy Settlement V8 Relay...\n");
406
+ stdout.write(`Waiting for a healthy Settlement V${state.network?.protocol_version || 8} Relay...\n`);
217
407
  while (true) {
218
408
  try { await state.chooseRelay(); return; } catch (error) {
219
409
  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.52";
4
+ export const PROVIDER_RELEASE_VERSION = "0.1.38";
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;