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.
- package/README.md +23 -5
- package/networks/v10-controlled-test.ca.crt +25 -0
- package/networks/v10-controlled-test.json +119 -0
- package/networks/v10-dynamic-20260926.ca.crt +25 -0
- package/networks/v10-dynamic-20260926.json +154 -0
- package/package.json +16 -2
- package/src/cli.mjs +2 -1
- package/src/consumer-discovery.mjs +355 -0
- package/src/consumer-history.mjs +287 -0
- package/src/consumer-request-journal.mjs +189 -0
- package/src/consumer-reserved.mjs +112 -0
- package/src/consumer-runtime.mjs +2687 -154
- package/src/consumer-sessions.mjs +205 -0
- package/src/consumer.mjs +206 -10
- package/src/release.mjs +12 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
56
|
-
|
|
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(
|
|
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}`);
|
package/src/release.mjs
ADDED
|
@@ -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;
|