mycomesh-consumer 0.1.50 → 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.50";
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,17 +58,26 @@ 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
- The browser page displays only the export URL/key, prepaid balance, key
56
- operations, and local consumption history. No browser conversation state is
57
- created.`;
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.
80
+ The page has no browser conversation state.`;
58
81
 
59
82
  class ConsumerCliError extends Error {
60
83
  constructor(message, exitCode = 1) {
@@ -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,
@@ -105,7 +134,7 @@ export async function main(argv, dependencies = {}) {
105
134
  const rootUrl = `${parsed.scheme}://${parsed.hostForUrl}:${parsed.port}`;
106
135
  const credentialsUrl = `${rootUrl}/`;
107
136
  writePid(parsed.dataDir);
108
- stdout.write(`MycoMesh Consumer credentials: ${credentialsUrl}\n`);
137
+ stdout.write(`MycoMesh Consumer setup: ${credentialsUrl}\n`);
109
138
  stdout.write(`OpenAI API: ${state.baseUrl}\n`);
110
139
  if (!parsed.noBrowser) openBrowser(credentialsUrl, stderr);
111
140
 
@@ -119,6 +148,7 @@ export async function main(argv, dependencies = {}) {
119
148
  return 0;
120
149
  }
121
150
  await waitUntilReady(state, parsed.readyTimeout, stdout);
151
+ await waitUntilUnlocked(state, parsed.readyTimeout, stdout);
122
152
  const code = await runCodex(parsed, state, dependencies.spawn ?? defaultSpawn, stdout, stderr);
123
153
  await shutdown();
124
154
  return code;
@@ -138,6 +168,9 @@ export function parseArguments(argv, env = process.env) {
138
168
  baseUrlExplicit: Boolean(env.MYCOMESH_CONSUMER_PUBLIC_BASE_URL),
139
169
  dataDir: env.MYCOMESH_CONSUMER_DATA_DIR || join(env.HOME || process.cwd(), ".mycomesh", "consumer"),
140
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,
141
174
  proxy: env.MYCOMESH_CONSUMER_PROXY || "",
142
175
  codexCommand: env.MYCOMESH_CODEX_COMMAND || "codex",
143
176
  readyTimeout: parsePositive(env.MYCOMESH_CONSUMER_READY_TIMEOUT_SECONDS || "1800", "ready timeout", 86400),
@@ -146,11 +179,16 @@ export function parseArguments(argv, env = process.env) {
146
179
  hostForUrl: env.MYCOMESH_CONSUMER_HOST || "127.0.0.1",
147
180
  port: parsePositive(env.MYCOMESH_CONSUMER_PORT || "8110", "port", 65535),
148
181
  scheme: "http",
182
+ allowControlledTest: false,
183
+ v10ControlledTest: false,
184
+ v10DynamicProviderAi: false,
149
185
  noBrowser: false,
150
186
  noCodex: env.MYCOMESH_CONSUMER_START_CODEX !== "1",
151
187
  stop: false,
152
188
  resetLocal: false,
153
189
  dryRun: false,
190
+ doctor: false,
191
+ doctorJson: false,
154
192
  help: false,
155
193
  version: false,
156
194
  codexArgs: [],
@@ -160,22 +198,37 @@ export function parseArguments(argv, env = process.env) {
160
198
  if (token === "--") { parsed.codexArgs = argv.slice(index + 1); break; }
161
199
  if (token === "-h" || token === "--help") { parsed.help = true; continue; }
162
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
+ }
163
212
  if (token === "--no-browser") { parsed.noBrowser = true; continue; }
164
213
  if (token === "--codex") { parsed.noCodex = false; continue; }
165
214
  if (token === "--no-codex") { parsed.noCodex = true; continue; }
166
215
  if (token === "--stop") { parsed.stop = true; continue; }
167
216
  if (token === "--reset-local") { parsed.resetLocal = true; continue; }
168
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; }
169
220
  const separator = token.indexOf("=");
170
221
  const name = separator === -1 ? token : token.slice(0, separator);
171
222
  let value = separator === -1 ? undefined : token.slice(separator + 1);
172
- 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"]);
173
224
  if (!options.has(name)) throw new ConsumerCliError(`unknown option: ${token}`, 2);
174
225
  if (value === undefined) { index += 1; value = argv[index]; }
175
226
  if (!value) throw new ConsumerCliError(`${name} requires a value`, 2);
176
227
  if (name === "--base-url") { parsed.baseUrl = value; parsed.baseUrlExplicit = true; }
177
228
  if (name === "--data-dir") parsed.dataDir = value;
178
- 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;
179
232
  if (name === "--proxy") parsed.proxy = value;
180
233
  if (name === "--codex-command") parsed.codexCommand = value;
181
234
  if (name === "--ready-timeout") parsed.readyTimeout = parsePositive(value, name, 86400);
@@ -183,11 +236,149 @@ export function parseArguments(argv, env = process.env) {
183
236
  if (name === "--port") parsed.port = parsePositive(value, name, 65535);
184
237
  if (name === "--max-fee") parsed.maxFeeUnits = parsePositive(value, name);
185
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
+ }
186
245
  if (!parsed.baseUrlExplicit) parsed.baseUrl = `http://${parsed.hostForUrl}:${parsed.port}/v1`;
187
246
  try { new URL(parsed.baseUrl); } catch { throw new ConsumerCliError("--base-url must be an absolute URL", 2); }
188
247
  return parsed;
189
248
  }
190
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
+
191
382
  function parsePositive(value, label, maximum = Number.MAX_SAFE_INTEGER) {
192
383
  if (!/^[1-9][0-9]*$/.test(String(value))) throw new ConsumerCliError(`${label} must be a positive integer`, 2);
193
384
  const parsed = Number(value);
@@ -212,7 +403,7 @@ export function isApiInvocation(argv) {
212
403
 
213
404
  async function waitUntilReady(state, timeoutSeconds, stdout) {
214
405
  const started = Date.now();
215
- 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`);
216
407
  while (true) {
217
408
  try { await state.chooseRelay(); return; } catch (error) {
218
409
  if (Date.now() - started >= timeoutSeconds * 1000) throw new ConsumerCliError(`timed out waiting for a healthy Relay: ${error.message}`);
@@ -221,6 +412,17 @@ async function waitUntilReady(state, timeoutSeconds, stdout) {
221
412
  }
222
413
  }
223
414
 
415
+ async function waitUntilUnlocked(state, timeoutSeconds, stdout) {
416
+ const started = Date.now();
417
+ stdout.write("Waiting for wallet verification in the local Consumer page...\n");
418
+ while (!state.paymentUnlocked) {
419
+ if (Date.now() - started >= timeoutSeconds * 1000) {
420
+ throw new ConsumerCliError("timed out waiting for the payment-key owner wallet to unlock Consumer");
421
+ }
422
+ await new Promise((resolve) => setTimeout(resolve, 1000));
423
+ }
424
+ }
425
+
224
426
  function runCodex(parsed, state, spawn, stdout, stderr) {
225
427
  return new Promise((resolve) => {
226
428
  const args = [
@@ -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;