@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.
Files changed (53) hide show
  1. package/README.md +37 -16
  2. package/dist/backend_stability.d.ts.map +1 -1
  3. package/dist/backend_stability.js +26 -0
  4. package/dist/backend_stability.js.map +1 -1
  5. package/dist/backends/index.d.ts +1 -1
  6. package/dist/backends/index.d.ts.map +1 -1
  7. package/dist/backends/index.js +3 -1
  8. package/dist/backends/index.js.map +1 -1
  9. package/dist/backends/meshllm.d.ts +11 -0
  10. package/dist/backends/meshllm.d.ts.map +1 -0
  11. package/dist/backends/meshllm.js +18 -0
  12. package/dist/backends/meshllm.js.map +1 -0
  13. package/dist/cli.d.ts +11 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +389 -9
  16. package/dist/cli.js.map +1 -1
  17. package/dist/client.d.ts +4 -1
  18. package/dist/client.d.ts.map +1 -1
  19. package/dist/client.js +53 -7
  20. package/dist/client.js.map +1 -1
  21. package/dist/delegation.js +1 -1
  22. package/dist/delegation.js.map +1 -1
  23. package/dist/dispatch_ticket.d.ts +13 -0
  24. package/dist/dispatch_ticket.d.ts.map +1 -0
  25. package/dist/dispatch_ticket.js +27 -0
  26. package/dist/dispatch_ticket.js.map +1 -0
  27. package/dist/index.d.ts +4 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +6 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/node.d.ts +3 -0
  32. package/dist/node.d.ts.map +1 -1
  33. package/dist/node.js +21 -4
  34. package/dist/node.js.map +1 -1
  35. package/dist/profile_compatibility.d.ts +12 -0
  36. package/dist/profile_compatibility.d.ts.map +1 -0
  37. package/dist/profile_compatibility.js +28 -0
  38. package/dist/profile_compatibility.js.map +1 -0
  39. package/dist/recovery.d.ts +6 -0
  40. package/dist/recovery.d.ts.map +1 -1
  41. package/dist/recovery.js +5 -0
  42. package/dist/recovery.js.map +1 -1
  43. package/dist/relay_worker_client.d.ts +3 -0
  44. package/dist/relay_worker_client.d.ts.map +1 -1
  45. package/dist/relay_worker_client.js +11 -0
  46. package/dist/relay_worker_client.js.map +1 -1
  47. package/dist/selection.d.ts +8 -0
  48. package/dist/selection.d.ts.map +1 -0
  49. package/dist/selection.js +20 -0
  50. package/dist/selection.js.map +1 -0
  51. package/dist/types.d.ts +33 -2
  52. package/dist/types.d.ts.map +1 -1
  53. 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 — Ollama / vLLM / LM Studio (default http://localhost:11434; anthropic → https://api.anthropic.com)\n` +
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
- opts.backendType === "anthropic" ? "https://api.anthropic.com" : "http://localhost:11434";
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", "https://iicp.network/api"),
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