@iicp/client 0.7.86 → 0.7.88
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 +37 -16
- package/dist/backend_stability.d.ts.map +1 -1
- package/dist/backend_stability.js +26 -0
- package/dist/backend_stability.js.map +1 -1
- package/dist/backends/index.d.ts +1 -1
- package/dist/backends/index.d.ts.map +1 -1
- package/dist/backends/index.js +3 -1
- package/dist/backends/index.js.map +1 -1
- package/dist/backends/meshllm.d.ts +11 -0
- package/dist/backends/meshllm.d.ts.map +1 -0
- package/dist/backends/meshllm.js +18 -0
- package/dist/backends/meshllm.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +389 -9
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +4 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +53 -7
- package/dist/client.js.map +1 -1
- package/dist/delegation.js +1 -1
- package/dist/delegation.js.map +1 -1
- package/dist/dispatch_ticket.d.ts +13 -0
- package/dist/dispatch_ticket.d.ts.map +1 -0
- package/dist/dispatch_ticket.js +27 -0
- package/dist/dispatch_ticket.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +3 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +21 -4
- package/dist/node.js.map +1 -1
- package/dist/profile_compatibility.d.ts +12 -0
- package/dist/profile_compatibility.d.ts.map +1 -0
- package/dist/profile_compatibility.js +28 -0
- package/dist/profile_compatibility.js.map +1 -0
- package/dist/recovery.d.ts +6 -0
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +5 -0
- package/dist/recovery.js.map +1 -1
- package/dist/relay_worker_client.d.ts +3 -0
- package/dist/relay_worker_client.d.ts.map +1 -1
- package/dist/relay_worker_client.js +11 -0
- package/dist/relay_worker_client.js.map +1 -1
- package/dist/selection.d.ts +8 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/selection.js +20 -0
- package/dist/selection.js.map +1 -0
- package/dist/types.d.ts +33 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,8 @@ exports.endpointIsLocalOrPrivate = endpointIsLocalOrPrivate;
|
|
|
11
11
|
exports.directTunnelFallbackReason = directTunnelFallbackReason;
|
|
12
12
|
exports.applySavedNode = applySavedNode;
|
|
13
13
|
exports.verifyCreditAwards = verifyCreditAwards;
|
|
14
|
+
exports.handoffRestartDelay = handoffRestartDelay;
|
|
15
|
+
exports.completeHandoffForNode = completeHandoffForNode;
|
|
14
16
|
exports.main = main;
|
|
15
17
|
// SPDX-License-Identifier: Apache-2.0
|
|
16
18
|
/**
|
|
@@ -164,6 +166,7 @@ function printHelp() {
|
|
|
164
166
|
` service Generate/install OS supervisor units for unattended node serving\n` +
|
|
165
167
|
` update Check whether a newer @iicp/client release is available\n` +
|
|
166
168
|
` operator rename <name> Change your public display_name (signed by your operator key)\n` +
|
|
169
|
+
` operator dsr <action> Export, restrict, or anonymize your directory records\n` +
|
|
167
170
|
` operator encrypt Password-encrypt the operator secret at rest ($IICP_OPERATOR_PASSPHRASE)\n` +
|
|
168
171
|
` operator decrypt Remove at-rest encryption of the operator secret\n\n` +
|
|
169
172
|
`Run an IICP provider node backed by an OpenAI-compatible server.\n\n` +
|
|
@@ -171,9 +174,10 @@ function printHelp() {
|
|
|
171
174
|
` --model NAME IICP_BACKEND_MODEL — model name (e.g. qwen2.5:0.5b)\n` +
|
|
172
175
|
` (or --node NAME load both from ~/.iicp/nodes/<NAME>.json after \`iicp-node init\`)\n\n` +
|
|
173
176
|
`serve optional:\n` +
|
|
174
|
-
` --backend-url URL IICP_BACKEND_URL —
|
|
175
|
-
` --backend-type TYPE IICP_BACKEND_TYPE — openai_compat | vllm | llamacpp | anthropic (default openai_compat)\n` +
|
|
177
|
+
` --backend-url URL IICP_BACKEND_URL — local backend (MeshLLM → http://localhost:9337/v1)\n` +
|
|
178
|
+
` --backend-type TYPE IICP_BACKEND_TYPE — openai_compat | vllm | llamacpp | meshllm | anthropic (default openai_compat)\n` +
|
|
176
179
|
` --backend-api-key KEY IICP_BACKEND_API_KEY — Bearer key for an auth'd backend (LM Studio, hosted); anthropic uses it as x-api-key\n` +
|
|
180
|
+
` --experimental IICP_EXPERIMENTAL — enable an explicitly selected preview backend feature\n` +
|
|
177
181
|
` --policy-manifest FILE IICP_POLICY_MANIFEST_FILE — sign and advertise a public node-policy JSON document\n` +
|
|
178
182
|
` --public-endpoint URL IICP_PUBLIC_ENDPOINT — externally reachable URL of this node\n` +
|
|
179
183
|
` --directory-url URL IICP_DIRECTORY_URL (default https://iicp.network/api)\n` +
|
|
@@ -239,8 +243,9 @@ function printServeHelp() {
|
|
|
239
243
|
` --node NAME load ~/.iicp/nodes/<NAME>.json from \`iicp-node init\`\n\n` +
|
|
240
244
|
`Core options:\n` +
|
|
241
245
|
` --backend-url URL IICP_BACKEND_URL — Ollama / vLLM / LM Studio (default http://localhost:11434; anthropic → https://api.anthropic.com)\n` +
|
|
242
|
-
` --backend-type TYPE IICP_BACKEND_TYPE — openai_compat | vllm | llamacpp | anthropic\n` +
|
|
246
|
+
` --backend-type TYPE IICP_BACKEND_TYPE — openai_compat | vllm | llamacpp | meshllm | anthropic\n` +
|
|
243
247
|
` --backend-api-key KEY IICP_BACKEND_API_KEY — Bearer key for auth'd backends\n` +
|
|
248
|
+
` --experimental IICP_EXPERIMENTAL — enable an explicitly selected preview backend feature\n` +
|
|
244
249
|
` --policy-manifest FILE IICP_POLICY_MANIFEST_FILE — sign and advertise a public node-policy JSON document\n` +
|
|
245
250
|
` --public-endpoint URL IICP_PUBLIC_ENDPOINT — externally reachable URL of this node\n` +
|
|
246
251
|
` --directory-url URL IICP_DIRECTORY_URL (default https://iicp.network/api)\n` +
|
|
@@ -362,7 +367,7 @@ async function checkDependencies(backendUrl) {
|
|
|
362
367
|
* else probe /api/version → ollama, else custom.
|
|
363
368
|
*/
|
|
364
369
|
async function detectBackendFlavor(backendUrl, apiKey, backendType) {
|
|
365
|
-
if (backendType === "anthropic" || backendType === "vllm" || backendType === "llamacpp")
|
|
370
|
+
if (backendType === "anthropic" || backendType === "vllm" || backendType === "llamacpp" || backendType === "meshllm")
|
|
366
371
|
return backendType;
|
|
367
372
|
const base = backendUrl.replace(/\/$/, "");
|
|
368
373
|
const root = base.endsWith("/v1") ? base.slice(0, -3) : base;
|
|
@@ -702,9 +707,16 @@ async function runServe(opts) {
|
|
|
702
707
|
// so a bare `serve --model x` works without --backend-url. An `anthropic` backend
|
|
703
708
|
// defaults to the Anthropic API, not localhost Ollama. Mirrors Python cli.py ~704.
|
|
704
709
|
if (!opts.backendUrl) {
|
|
705
|
-
opts.backendUrl =
|
|
706
|
-
|
|
707
|
-
|
|
710
|
+
opts.backendUrl = opts.backendType === "anthropic"
|
|
711
|
+
? "https://api.anthropic.com"
|
|
712
|
+
: opts.backendType === "meshllm"
|
|
713
|
+
? "http://localhost:9337/v1"
|
|
714
|
+
: "http://localhost:11434";
|
|
715
|
+
}
|
|
716
|
+
// Resolve the public default only after a saved node has had the opportunity
|
|
717
|
+
// to supply its own directory URL. This preserves saved-node precedence.
|
|
718
|
+
if (!opts.directoryUrl)
|
|
719
|
+
opts.directoryUrl = "https://iicp.network/api";
|
|
708
720
|
// Onboarding: if no --model given, auto-select the first model the backend advertises
|
|
709
721
|
// (Ollama /api/tags) so a bare `iicp-node serve` just works (parity with Rust/Python).
|
|
710
722
|
if (!opts.model && opts.backendUrl) {
|
|
@@ -723,6 +735,32 @@ async function runServe(opts) {
|
|
|
723
735
|
catch {
|
|
724
736
|
// best-effort; the required-model check below surfaces a clear error
|
|
725
737
|
}
|
|
738
|
+
// MeshLLM is OpenAI-compatible rather than Ollama-compatible. Its model
|
|
739
|
+
// inventory decides routing, so never choose an arbitrary model when several
|
|
740
|
+
// are available.
|
|
741
|
+
if (!opts.model) {
|
|
742
|
+
try {
|
|
743
|
+
const root = opts.backendUrl.replace(/\/$/, "").replace(/\/v1$/, "");
|
|
744
|
+
const r = await fetch(`${root}/v1/models`, { signal: AbortSignal.timeout(3000) });
|
|
745
|
+
if (r.ok) {
|
|
746
|
+
const d = (await r.json());
|
|
747
|
+
const models = (d.data ?? []).map((m) => m.id).filter((id) => Boolean(id));
|
|
748
|
+
if (opts.backendType === "meshllm" && models.length > 1) {
|
|
749
|
+
process.stderr.write(`ERROR: MeshLLM advertises multiple models; select one with --model: ${models.join(", ")}\n`);
|
|
750
|
+
return 2;
|
|
751
|
+
}
|
|
752
|
+
if (models.length === 1) {
|
|
753
|
+
opts.model = models[0];
|
|
754
|
+
process.stderr.write(`[iicp-node] no --model given — auto-selected '${opts.model}' from ${opts.backendUrl}\n`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch { /* required-model check below remains authoritative */ }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (opts.backendType === "meshllm" && opts.model === "mesh" && !opts.experimental) {
|
|
762
|
+
process.stderr.write("ERROR: MeshLLM model 'mesh' is experimental; pass --experimental explicitly to enable it.\n");
|
|
763
|
+
return 2;
|
|
726
764
|
}
|
|
727
765
|
if (!opts.backendUrl || !opts.model) {
|
|
728
766
|
process.stderr.write("ERROR: --model is required (--backend-url defaults to http://localhost:11434). Set IICP_BACKEND_MODEL, or use --node NAME.\n");
|
|
@@ -1071,7 +1109,7 @@ async function runServe(opts) {
|
|
|
1071
1109
|
catch { /* best-effort */ }
|
|
1072
1110
|
return [];
|
|
1073
1111
|
})();
|
|
1074
|
-
const extra = allModels.filter((m) => m !== opts.model);
|
|
1112
|
+
const extra = allModels.filter((m) => m !== opts.model && (opts.backendType !== "meshllm" || opts.experimental || m !== "mesh"));
|
|
1075
1113
|
if (extra.length > 0) {
|
|
1076
1114
|
node["_cfg"].capabilities = extra;
|
|
1077
1115
|
// eslint-disable-next-line no-console
|
|
@@ -1081,6 +1119,9 @@ async function runServe(opts) {
|
|
|
1081
1119
|
catch {
|
|
1082
1120
|
// best-effort; no-op on error
|
|
1083
1121
|
}
|
|
1122
|
+
if (opts.backendType === "meshllm" && !opts.experimental) {
|
|
1123
|
+
node["_cfg"].excludedModels = ["mesh"];
|
|
1124
|
+
}
|
|
1084
1125
|
// NAT-4 guard: if endpoint is non-routable and no relay configured, skip
|
|
1085
1126
|
// registration to avoid a confusing 422 from the directory's RoutableEndpoint check.
|
|
1086
1127
|
const epLocal = endpointIsLocalOrPrivate(publicEndpoint);
|
|
@@ -1133,6 +1174,8 @@ async function runServe(opts) {
|
|
|
1133
1174
|
}
|
|
1134
1175
|
}
|
|
1135
1176
|
}
|
|
1177
|
+
if (opts.node)
|
|
1178
|
+
completeHandoffForNode(opts.node);
|
|
1136
1179
|
break;
|
|
1137
1180
|
}
|
|
1138
1181
|
catch (exc) {
|
|
@@ -1191,6 +1234,7 @@ async function runServe(opts) {
|
|
|
1191
1234
|
},
|
|
1192
1235
|
});
|
|
1193
1236
|
}
|
|
1237
|
+
const handoffTimer = scheduleSupervisedHandoffRestart(opts.node);
|
|
1194
1238
|
const stop = node.serve(handler, { host: opts.host, port: opts.port, nodeToken: token });
|
|
1195
1239
|
await new Promise((resolve) => {
|
|
1196
1240
|
const shutdown = async (sig) => {
|
|
@@ -1217,6 +1261,8 @@ async function runServe(opts) {
|
|
|
1217
1261
|
}
|
|
1218
1262
|
tunnelHandle?.close(); // #520 — tear the Quick Tunnel down with the node
|
|
1219
1263
|
stop();
|
|
1264
|
+
if (handoffTimer)
|
|
1265
|
+
clearTimeout(handoffTimer);
|
|
1220
1266
|
stopAutoUpdate();
|
|
1221
1267
|
instanceLock.release(); // #405 — free the pidfile on shutdown
|
|
1222
1268
|
resolve();
|
|
@@ -1984,12 +2030,340 @@ function printOperatorHelp() {
|
|
|
1984
2030
|
`Manage your operator identity.\n\n` +
|
|
1985
2031
|
`subcommands:\n` +
|
|
1986
2032
|
` rename <name> Change your public display_name (signed by your operator key)\n` +
|
|
2033
|
+
` key <rotate|revoke> Safely migrate or revoke your cryptographic operator key\n` +
|
|
2034
|
+
` dsr <action> Export, restrict, or anonymize your directory records\n` +
|
|
1987
2035
|
` encrypt Password-encrypt the operator secret at rest ($IICP_OPERATOR_PASSPHRASE)\n` +
|
|
1988
2036
|
` decrypt Remove at-rest encryption of the operator secret\n\n` +
|
|
1989
2037
|
`operator rename options:\n` +
|
|
1990
2038
|
` --directory-url URL IICP directory base URL (defaults to env / iicp.network)\n` +
|
|
1991
2039
|
` -h, --help Show this help and exit\n`);
|
|
1992
2040
|
}
|
|
2041
|
+
async function runOperatorDsr(argv) {
|
|
2042
|
+
const { values, positionals } = safeParseArgs({
|
|
2043
|
+
args: argv,
|
|
2044
|
+
options: {
|
|
2045
|
+
"directory-url": { type: "string" },
|
|
2046
|
+
"tracking-id": { type: "string" },
|
|
2047
|
+
output: { type: "string" },
|
|
2048
|
+
yes: { type: "boolean" },
|
|
2049
|
+
help: { type: "boolean", short: "h" },
|
|
2050
|
+
},
|
|
2051
|
+
allowPositionals: true,
|
|
2052
|
+
});
|
|
2053
|
+
if (values.help) {
|
|
2054
|
+
process.stdout.write("usage: iicp-node operator dsr <export|restrict|anonymize> [options]\n\n" +
|
|
2055
|
+
"The local operator key signs a one-use directory challenge. It never enters the browser or request body.\n\n" +
|
|
2056
|
+
" --output FILE Required for export; creates a new mode-0600 local file\n" +
|
|
2057
|
+
" --yes Confirm restrict or anonymize after the retained-record notice\n" +
|
|
2058
|
+
" --tracking-id ID Optional local reference\n" +
|
|
2059
|
+
" --directory-url URL IICP directory base URL\n");
|
|
2060
|
+
return 0;
|
|
2061
|
+
}
|
|
2062
|
+
const action = positionals[0];
|
|
2063
|
+
if (action !== "export" && action !== "restrict" && action !== "anonymize") {
|
|
2064
|
+
process.stderr.write("ERROR: usage: iicp-node operator dsr <export|restrict|anonymize> [options]\n");
|
|
2065
|
+
return 2;
|
|
2066
|
+
}
|
|
2067
|
+
const outputPath = values.output;
|
|
2068
|
+
if (action === "export" && !outputPath) {
|
|
2069
|
+
process.stderr.write("ERROR: export requires --output <new-file>; it is not printed to the terminal.\n");
|
|
2070
|
+
return 2;
|
|
2071
|
+
}
|
|
2072
|
+
if (outputPath && fs.existsSync(outputPath)) {
|
|
2073
|
+
process.stderr.write("ERROR: --output already exists; choose a new path to avoid replacing an export.\n");
|
|
2074
|
+
return 2;
|
|
2075
|
+
}
|
|
2076
|
+
if (action !== "export") {
|
|
2077
|
+
process.stderr.write("This action restricts or anonymizes directory records. Minimal signed ledger, security and accounting records may be retained and are explained in the receipt.\n");
|
|
2078
|
+
if (!values.yes) {
|
|
2079
|
+
process.stderr.write("Re-run with --yes to confirm.\n");
|
|
2080
|
+
return 2;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
const op = (0, identity_js_1.loadOperator)();
|
|
2084
|
+
if (!op) {
|
|
2085
|
+
process.stderr.write("ERROR: no operator identity — run `iicp-node init` first.\n");
|
|
2086
|
+
return 1;
|
|
2087
|
+
}
|
|
2088
|
+
if (!(0, identity_js_1.operatorIsKeyBacked)(op)) {
|
|
2089
|
+
process.stderr.write("ERROR: this legacy operator identity cannot sign a rights request; regenerate a key-backed identity.\n");
|
|
2090
|
+
return 1;
|
|
2091
|
+
}
|
|
2092
|
+
const directoryUrl = values["directory-url"] ?? process.env["IICP_DIRECTORY_URL"] ?? "https://iicp.network/api";
|
|
2093
|
+
const base = `${directoryUrl.replace(/\/+$/, "")}/v1/operator`;
|
|
2094
|
+
let response;
|
|
2095
|
+
try {
|
|
2096
|
+
const challenge = await fetch(`${base}/challenge`, {
|
|
2097
|
+
method: "POST",
|
|
2098
|
+
headers: { "Content-Type": "application/json" },
|
|
2099
|
+
body: JSON.stringify({ operator_pub: op.operator_id }),
|
|
2100
|
+
signal: AbortSignal.timeout(15000),
|
|
2101
|
+
});
|
|
2102
|
+
const challengeBody = (await challenge.json());
|
|
2103
|
+
if (!challenge.ok || typeof challengeBody.nonce !== "string") {
|
|
2104
|
+
throw new Error(challengeBody.error?.message ?? "directory challenge was incomplete");
|
|
2105
|
+
}
|
|
2106
|
+
const payload = {
|
|
2107
|
+
operator_pub: op.operator_id,
|
|
2108
|
+
nonce: challengeBody.nonce,
|
|
2109
|
+
ts: Math.floor(Date.now() / 1000),
|
|
2110
|
+
tracking_id: values["tracking-id"] ?? `dsr-${(0, node_crypto_1.randomUUID)()}`,
|
|
2111
|
+
};
|
|
2112
|
+
if (action !== "export")
|
|
2113
|
+
payload.confirm = true;
|
|
2114
|
+
payload.sig = (0, delegation_js_1.signOperatorSelfService)((0, identity_js_1.operatorSigningKey)(op), `dsr_${action}`, payload);
|
|
2115
|
+
response = await fetch(`${base}/dsr/${action}`, {
|
|
2116
|
+
method: "POST",
|
|
2117
|
+
headers: { "Content-Type": "application/json" },
|
|
2118
|
+
body: JSON.stringify(payload),
|
|
2119
|
+
signal: AbortSignal.timeout(15000),
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
catch (err) {
|
|
2123
|
+
process.stderr.write(`ERROR: rights request failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2124
|
+
return 1;
|
|
2125
|
+
}
|
|
2126
|
+
let body;
|
|
2127
|
+
try {
|
|
2128
|
+
body = (await response.json());
|
|
2129
|
+
}
|
|
2130
|
+
catch {
|
|
2131
|
+
body = {};
|
|
2132
|
+
}
|
|
2133
|
+
if (!response.ok) {
|
|
2134
|
+
const err = body.error;
|
|
2135
|
+
process.stderr.write(`ERROR: directory rejected rights request: ${err?.message ?? response.status}\n`);
|
|
2136
|
+
return 1;
|
|
2137
|
+
}
|
|
2138
|
+
if (action === "export" && outputPath) {
|
|
2139
|
+
try {
|
|
2140
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(body, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
2141
|
+
fs.chmodSync(outputPath, 0o600);
|
|
2142
|
+
}
|
|
2143
|
+
catch (err) {
|
|
2144
|
+
process.stderr.write(`ERROR: unable to save export safely: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2145
|
+
return 1;
|
|
2146
|
+
}
|
|
2147
|
+
process.stdout.write(`Saved redacted directory export to ${outputPath} (mode 0600).\n`);
|
|
2148
|
+
}
|
|
2149
|
+
else {
|
|
2150
|
+
process.stdout.write(`Directory ${action} request completed.\n`);
|
|
2151
|
+
}
|
|
2152
|
+
if (typeof body.tracking_id === "string")
|
|
2153
|
+
process.stdout.write(`Receipt reference: ${body.tracking_id}\n`);
|
|
2154
|
+
const retention = body.retention_notice ?? body.retention_reason;
|
|
2155
|
+
if (typeof retention === "string")
|
|
2156
|
+
process.stdout.write(`Retained records: ${retention}\n`);
|
|
2157
|
+
return 0;
|
|
2158
|
+
}
|
|
2159
|
+
function operatorArtifactPath(kind) {
|
|
2160
|
+
return path.join((0, identity_js_1.configDir)(), `operator-${kind}-${new Date().toISOString().replace(/[-:.]/g, "").replace("Z", "Z")}.json`);
|
|
2161
|
+
}
|
|
2162
|
+
function writePrivateJsonNew(file, value) {
|
|
2163
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
2164
|
+
fs.chmodSync(file, 0o600);
|
|
2165
|
+
}
|
|
2166
|
+
function writeOperatorHandoffMarker(nodeNames) {
|
|
2167
|
+
const stamp = Math.floor(Date.now() / 1000);
|
|
2168
|
+
const marker = path.join((0, identity_js_1.configDir)(), `operator-handoff-pending-${stamp}.json`);
|
|
2169
|
+
writePrivateJsonNew(marker, {
|
|
2170
|
+
schema: "iicp.operator-handoff.v1",
|
|
2171
|
+
action: "rotate",
|
|
2172
|
+
created_at_unix: stamp,
|
|
2173
|
+
grace_seconds: 300,
|
|
2174
|
+
affected_node_names: nodeNames,
|
|
2175
|
+
restart_requested_node_names: [],
|
|
2176
|
+
completed_node_names: [],
|
|
2177
|
+
});
|
|
2178
|
+
return marker;
|
|
2179
|
+
}
|
|
2180
|
+
function handoffMarkerPaths() {
|
|
2181
|
+
try {
|
|
2182
|
+
return fs.readdirSync((0, identity_js_1.configDir)())
|
|
2183
|
+
.filter((name) => /^operator-handoff-pending-.*\.json$/.test(name))
|
|
2184
|
+
.map((name) => path.join((0, identity_js_1.configDir)(), name));
|
|
2185
|
+
}
|
|
2186
|
+
catch {
|
|
2187
|
+
return [];
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
function readHandoffMarker(file) {
|
|
2191
|
+
try {
|
|
2192
|
+
const body = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2193
|
+
return body && typeof body === "object" ? body : null;
|
|
2194
|
+
}
|
|
2195
|
+
catch {
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/** Return remaining grace seconds; completed/restarted services never restart twice. */
|
|
2200
|
+
function handoffRestartDelay(marker, nodeName, now) {
|
|
2201
|
+
const names = (field) => new Set(Array.isArray(marker[field]) ? marker[field].filter((value) => typeof value === "string") : []);
|
|
2202
|
+
const affected = names("affected_node_names");
|
|
2203
|
+
const requested = names("restart_requested_node_names");
|
|
2204
|
+
const completed = names("completed_node_names");
|
|
2205
|
+
if (!affected.has(nodeName) || requested.has(nodeName) || completed.has(nodeName))
|
|
2206
|
+
return null;
|
|
2207
|
+
const created = marker.created_at_unix;
|
|
2208
|
+
const grace = marker.grace_seconds;
|
|
2209
|
+
if (typeof created !== "number")
|
|
2210
|
+
return null;
|
|
2211
|
+
return Math.max(0, created + (typeof grace === "number" && grace >= 0 ? grace : 300) - now);
|
|
2212
|
+
}
|
|
2213
|
+
function completeHandoffForNode(nodeName) {
|
|
2214
|
+
for (const file of handoffMarkerPaths()) {
|
|
2215
|
+
const marker = readHandoffMarker(file);
|
|
2216
|
+
if (!marker || !Array.isArray(marker.affected_node_names) || !marker.affected_node_names.includes(nodeName))
|
|
2217
|
+
continue;
|
|
2218
|
+
// A rotation can be written while this process is still completing its
|
|
2219
|
+
// initial registration. Only the successor that claimed a supervised
|
|
2220
|
+
// restart may complete the marker; do not erase an unclaimed handoff.
|
|
2221
|
+
const requested = new Set(marker.restart_requested_node_names ?? []);
|
|
2222
|
+
if (!requested.has(nodeName))
|
|
2223
|
+
continue;
|
|
2224
|
+
const affected = new Set(marker.affected_node_names ?? []);
|
|
2225
|
+
const completed = new Set(marker.completed_node_names ?? []);
|
|
2226
|
+
completed.add(nodeName);
|
|
2227
|
+
try {
|
|
2228
|
+
if ([...affected].every((name) => completed.has(name)))
|
|
2229
|
+
fs.unlinkSync(file);
|
|
2230
|
+
else
|
|
2231
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...marker, completed_node_names: [...completed] }, null, 2)}\n`, { mode: 0o600 });
|
|
2232
|
+
}
|
|
2233
|
+
catch { /* best effort only */ }
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
function scheduleSupervisedHandoffRestart(nodeName) {
|
|
2237
|
+
if (!nodeName || !envBool("IICP_SUPERVISED"))
|
|
2238
|
+
return null;
|
|
2239
|
+
let checking = false;
|
|
2240
|
+
const check = () => {
|
|
2241
|
+
if (checking)
|
|
2242
|
+
return;
|
|
2243
|
+
checking = true;
|
|
2244
|
+
try {
|
|
2245
|
+
for (const file of handoffMarkerPaths()) {
|
|
2246
|
+
const marker = readHandoffMarker(file);
|
|
2247
|
+
if (!marker || handoffRestartDelay(marker, nodeName, Math.floor(Date.now() / 1000)) !== 0)
|
|
2248
|
+
continue;
|
|
2249
|
+
const requested = new Set(marker.restart_requested_node_names ?? []);
|
|
2250
|
+
requested.add(nodeName);
|
|
2251
|
+
try {
|
|
2252
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...marker, restart_requested_node_names: [...requested] }, null, 2)}\n`, { mode: 0o600 });
|
|
2253
|
+
console.error(`[iicp-node] operator handoff grace period complete — exiting with code ${exports.TUNNEL_DEAD_EXIT_CODE} so the supervisor reloads the successor identity.`);
|
|
2254
|
+
process.exit(exports.TUNNEL_DEAD_EXIT_CODE);
|
|
2255
|
+
}
|
|
2256
|
+
catch { /* retry on the next monitor pass */ }
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
finally {
|
|
2260
|
+
checking = false;
|
|
2261
|
+
}
|
|
2262
|
+
};
|
|
2263
|
+
// The marker is written by a separate `operator key rotate` invocation,
|
|
2264
|
+
// so it can appear after `serve` has already started. Re-check redacted
|
|
2265
|
+
// local state rather than assuming startup saw every future handoff.
|
|
2266
|
+
check();
|
|
2267
|
+
return setInterval(check, 15_000);
|
|
2268
|
+
}
|
|
2269
|
+
async function runOperatorKey(argv) {
|
|
2270
|
+
const { values, positionals } = safeParseArgs({
|
|
2271
|
+
args: argv,
|
|
2272
|
+
options: { "directory-url": { type: "string" }, yes: { type: "boolean" }, help: { type: "boolean", short: "h" } },
|
|
2273
|
+
allowPositionals: true,
|
|
2274
|
+
});
|
|
2275
|
+
if (values.help) {
|
|
2276
|
+
process.stdout.write("usage: iicp-node operator key <rotate|revoke> --yes [--directory-url URL]\n");
|
|
2277
|
+
return 0;
|
|
2278
|
+
}
|
|
2279
|
+
const action = positionals[0];
|
|
2280
|
+
if (action !== "rotate" && action !== "revoke")
|
|
2281
|
+
return 2;
|
|
2282
|
+
if (!values.yes) {
|
|
2283
|
+
process.stderr.write("This changes the cryptographic operator identity used by saved nodes. Re-run with --yes.\n");
|
|
2284
|
+
return 2;
|
|
2285
|
+
}
|
|
2286
|
+
const stored = (0, identity_js_1.loadOperator)();
|
|
2287
|
+
if (!stored || !(0, identity_js_1.operatorIsKeyBacked)(stored)) {
|
|
2288
|
+
process.stderr.write("ERROR: a key-backed local operator identity is required.\n");
|
|
2289
|
+
return 1;
|
|
2290
|
+
}
|
|
2291
|
+
const unlock = (0, identity_js_1.operatorIsEncrypted)(stored) ? await operatorPassphrase("Operator passphrase: ", false) : undefined;
|
|
2292
|
+
let old;
|
|
2293
|
+
try {
|
|
2294
|
+
old = (0, identity_js_1.operatorIsEncrypted)(stored) ? (0, identity_js_1.operatorDecryptAtRest)(stored, unlock ?? "") : stored;
|
|
2295
|
+
}
|
|
2296
|
+
catch (e) {
|
|
2297
|
+
process.stderr.write(`ERROR: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
2298
|
+
return 1;
|
|
2299
|
+
}
|
|
2300
|
+
let successor;
|
|
2301
|
+
let backupPath;
|
|
2302
|
+
try {
|
|
2303
|
+
if (action === "rotate") {
|
|
2304
|
+
const backupPw = unlock ?? process.env["IICP_OPERATOR_BACKUP_PASSPHRASE"] ?? await operatorPassphrase("New backup passphrase: ", true);
|
|
2305
|
+
if (!backupPw)
|
|
2306
|
+
return 1;
|
|
2307
|
+
backupPath = operatorArtifactPath("before-rotation");
|
|
2308
|
+
writePrivateJsonNew(backupPath, (0, identity_js_1.operatorEncryptAtRest)(old, backupPw));
|
|
2309
|
+
successor = (0, identity_js_1.generateOperator)({ display_name: old.display_name, contact: old.contact });
|
|
2310
|
+
}
|
|
2311
|
+
const directoryUrl = values["directory-url"] ?? process.env["IICP_DIRECTORY_URL"] ?? "https://iicp.network/api";
|
|
2312
|
+
const base = `${directoryUrl.replace(/\/+$/, "")}/v1/operator`;
|
|
2313
|
+
const challenge = await fetch(`${base}/challenge`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operator_pub: old.operator_id }), signal: AbortSignal.timeout(15000) });
|
|
2314
|
+
const challengeBody = await challenge.json();
|
|
2315
|
+
if (!challenge.ok || typeof challengeBody.nonce !== "string")
|
|
2316
|
+
throw new Error(challengeBody.error?.message ?? "directory challenge was incomplete");
|
|
2317
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
2318
|
+
let payload;
|
|
2319
|
+
let response;
|
|
2320
|
+
if (action === "rotate") {
|
|
2321
|
+
if (!successor)
|
|
2322
|
+
throw new Error("successor identity missing");
|
|
2323
|
+
payload = { operator_pub: old.operator_id, new_operator_pub: successor.operator_id, nonce: challengeBody.nonce, ts, reason_class: "operator_rotation" };
|
|
2324
|
+
payload.sig = (0, delegation_js_1.signOperatorSelfService)((0, identity_js_1.operatorSigningKey)(old), "key_rotate", payload);
|
|
2325
|
+
payload.new_key_sig = (0, delegation_js_1.signOperatorSelfService)((0, identity_js_1.operatorSigningKey)(successor), "key_rotate_successor", { operator_pub: old.operator_id, new_operator_pub: successor.operator_id, nonce: challengeBody.nonce, ts, rotation_epoch: null });
|
|
2326
|
+
response = await fetch(`${base}/key/rotate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15000) });
|
|
2327
|
+
}
|
|
2328
|
+
else {
|
|
2329
|
+
payload = { operator_pub: old.operator_id, nonce: challengeBody.nonce, ts, confirm: true, reason_class: "operator_request" };
|
|
2330
|
+
payload.sig = (0, delegation_js_1.signOperatorSelfService)((0, identity_js_1.operatorSigningKey)(old), "key_revoke", payload);
|
|
2331
|
+
response = await fetch(`${base}/key/revoke`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15000) });
|
|
2332
|
+
}
|
|
2333
|
+
const body = await response.json();
|
|
2334
|
+
if (!response.ok)
|
|
2335
|
+
throw new Error(body.error?.message ?? `HTTP ${response.status}`);
|
|
2336
|
+
const receiptPath = operatorArtifactPath(`${action}-receipt`);
|
|
2337
|
+
writePrivateJsonNew(receiptPath, { schema: "iicp.operator-key-receipt.v1", action, status: body.status, operator_fingerprint: body.operator_fingerprint, linked_nodes: body.linked_nodes, receipt_id_prefix: body.receipt_id_prefix });
|
|
2338
|
+
if (action === "rotate" && successor) {
|
|
2339
|
+
(0, identity_js_1.saveOperator)(successor);
|
|
2340
|
+
let migrated = 0;
|
|
2341
|
+
const nodeNames = [];
|
|
2342
|
+
for (const node of (0, identity_js_1.listNodes)())
|
|
2343
|
+
if (node.operator_id === old.operator_id) {
|
|
2344
|
+
node.operator_id = successor.operator_id;
|
|
2345
|
+
nodeNames.push(node.name);
|
|
2346
|
+
(0, identity_js_1.saveNode)(node);
|
|
2347
|
+
migrated += 1;
|
|
2348
|
+
}
|
|
2349
|
+
const handoffPath = writeOperatorHandoffMarker(nodeNames);
|
|
2350
|
+
process.stdout.write(`Operator identity migrated; ${migrated} saved node(s) will re-register with the new key.\nEncrypted old-key backup: ${backupPath}\nRedacted receipt: ${receiptPath}\nSupervised handoff marker: ${handoffPath} (one restart pass after 5 minutes).\n`);
|
|
2351
|
+
}
|
|
2352
|
+
else
|
|
2353
|
+
process.stdout.write(`Operator identity revoked; redacted receipt: ${receiptPath}\n`);
|
|
2354
|
+
return 0;
|
|
2355
|
+
}
|
|
2356
|
+
catch (e) {
|
|
2357
|
+
if (backupPath) {
|
|
2358
|
+
try {
|
|
2359
|
+
fs.unlinkSync(backupPath);
|
|
2360
|
+
}
|
|
2361
|
+
catch { }
|
|
2362
|
+
}
|
|
2363
|
+
process.stderr.write(`ERROR: operator key ${action} failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
2364
|
+
return 1;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
1993
2367
|
function printOperatorEncryptHelp() {
|
|
1994
2368
|
process.stdout.write(`usage: iicp-node operator encrypt\n\n` +
|
|
1995
2369
|
`Password-encrypt the operator secret at rest. The command prompts for a passphrase\n` +
|
|
@@ -2030,6 +2404,10 @@ async function runOperator(argv) {
|
|
|
2030
2404
|
throw new CliError(`unknown operator decrypt option '${rest[0]}'`);
|
|
2031
2405
|
return runOperatorDecrypt();
|
|
2032
2406
|
}
|
|
2407
|
+
if (sub === "dsr")
|
|
2408
|
+
return runOperatorDsr(argv.slice(1));
|
|
2409
|
+
if (sub === "key")
|
|
2410
|
+
return runOperatorKey(argv.slice(1));
|
|
2033
2411
|
if (sub !== "rename") {
|
|
2034
2412
|
process.stderr.write(`unknown operator subcommand: ${sub}\n`);
|
|
2035
2413
|
printOperatorHelp();
|
|
@@ -2513,6 +2891,7 @@ async function dispatch(argv) {
|
|
|
2513
2891
|
"policy-manifest": { type: "string" },
|
|
2514
2892
|
"backend-type": { type: "string" },
|
|
2515
2893
|
"backend-api-key": { type: "string" },
|
|
2894
|
+
experimental: { type: "boolean" },
|
|
2516
2895
|
model: { type: "string" },
|
|
2517
2896
|
"public-endpoint": { type: "string" },
|
|
2518
2897
|
"directory-url": { type: "string" },
|
|
@@ -2551,10 +2930,11 @@ async function dispatch(argv) {
|
|
|
2551
2930
|
backendType: values["backend-type"] ??
|
|
2552
2931
|
envOr("IICP_BACKEND_TYPE", "openai_compat"),
|
|
2553
2932
|
backendApiKey: values["backend-api-key"] ?? envOr("IICP_BACKEND_API_KEY") ?? "",
|
|
2933
|
+
experimental: Boolean(values.experimental) || envBool("IICP_EXPERIMENTAL"),
|
|
2554
2934
|
model: values.model ?? envOr("IICP_BACKEND_MODEL") ?? "",
|
|
2555
2935
|
publicEndpoint: values["public-endpoint"] ?? envOr("IICP_PUBLIC_ENDPOINT") ?? "",
|
|
2556
2936
|
directoryUrl: values["directory-url"] ??
|
|
2557
|
-
envOr("IICP_DIRECTORY_URL"
|
|
2937
|
+
envOr("IICP_DIRECTORY_URL") ?? "",
|
|
2558
2938
|
region: values.region ?? envOr("IICP_REGION"),
|
|
2559
2939
|
intent: values.intent ?? envOr("IICP_INTENT", "urn:iicp:intent:llm:chat:v1"),
|
|
2560
2940
|
maxConcurrent: values["max-concurrent"] !== undefined
|