@iicp/client 0.7.87 → 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 +27 -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 +267 -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
  /**
@@ -172,9 +174,10 @@ function printHelp() {
172
174
  ` --model NAME IICP_BACKEND_MODEL — model name (e.g. qwen2.5:0.5b)\n` +
173
175
  ` (or --node NAME load both from ~/.iicp/nodes/<NAME>.json after \`iicp-node init\`)\n\n` +
174
176
  `serve optional:\n` +
175
- ` --backend-url URL IICP_BACKEND_URL — Ollama / vLLM / LM Studio (default http://localhost:11434; anthropic → https://api.anthropic.com)\n` +
176
- ` --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` +
177
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` +
178
181
  ` --policy-manifest FILE IICP_POLICY_MANIFEST_FILE — sign and advertise a public node-policy JSON document\n` +
179
182
  ` --public-endpoint URL IICP_PUBLIC_ENDPOINT — externally reachable URL of this node\n` +
180
183
  ` --directory-url URL IICP_DIRECTORY_URL (default https://iicp.network/api)\n` +
@@ -240,8 +243,9 @@ function printServeHelp() {
240
243
  ` --node NAME load ~/.iicp/nodes/<NAME>.json from \`iicp-node init\`\n\n` +
241
244
  `Core options:\n` +
242
245
  ` --backend-url URL IICP_BACKEND_URL — Ollama / vLLM / LM Studio (default http://localhost:11434; anthropic → https://api.anthropic.com)\n` +
243
- ` --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` +
244
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` +
245
249
  ` --policy-manifest FILE IICP_POLICY_MANIFEST_FILE — sign and advertise a public node-policy JSON document\n` +
246
250
  ` --public-endpoint URL IICP_PUBLIC_ENDPOINT — externally reachable URL of this node\n` +
247
251
  ` --directory-url URL IICP_DIRECTORY_URL (default https://iicp.network/api)\n` +
@@ -363,7 +367,7 @@ async function checkDependencies(backendUrl) {
363
367
  * else probe /api/version → ollama, else custom.
364
368
  */
365
369
  async function detectBackendFlavor(backendUrl, apiKey, backendType) {
366
- if (backendType === "anthropic" || backendType === "vllm" || backendType === "llamacpp")
370
+ if (backendType === "anthropic" || backendType === "vllm" || backendType === "llamacpp" || backendType === "meshllm")
367
371
  return backendType;
368
372
  const base = backendUrl.replace(/\/$/, "");
369
373
  const root = base.endsWith("/v1") ? base.slice(0, -3) : base;
@@ -703,9 +707,16 @@ async function runServe(opts) {
703
707
  // so a bare `serve --model x` works without --backend-url. An `anthropic` backend
704
708
  // defaults to the Anthropic API, not localhost Ollama. Mirrors Python cli.py ~704.
705
709
  if (!opts.backendUrl) {
706
- opts.backendUrl =
707
- opts.backendType === "anthropic" ? "https://api.anthropic.com" : "http://localhost:11434";
708
- }
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";
709
720
  // Onboarding: if no --model given, auto-select the first model the backend advertises
710
721
  // (Ollama /api/tags) so a bare `iicp-node serve` just works (parity with Rust/Python).
711
722
  if (!opts.model && opts.backendUrl) {
@@ -724,6 +735,32 @@ async function runServe(opts) {
724
735
  catch {
725
736
  // best-effort; the required-model check below surfaces a clear error
726
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;
727
764
  }
728
765
  if (!opts.backendUrl || !opts.model) {
729
766
  process.stderr.write("ERROR: --model is required (--backend-url defaults to http://localhost:11434). Set IICP_BACKEND_MODEL, or use --node NAME.\n");
@@ -1072,7 +1109,7 @@ async function runServe(opts) {
1072
1109
  catch { /* best-effort */ }
1073
1110
  return [];
1074
1111
  })();
1075
- const extra = allModels.filter((m) => m !== opts.model);
1112
+ const extra = allModels.filter((m) => m !== opts.model && (opts.backendType !== "meshllm" || opts.experimental || m !== "mesh"));
1076
1113
  if (extra.length > 0) {
1077
1114
  node["_cfg"].capabilities = extra;
1078
1115
  // eslint-disable-next-line no-console
@@ -1082,6 +1119,9 @@ async function runServe(opts) {
1082
1119
  catch {
1083
1120
  // best-effort; no-op on error
1084
1121
  }
1122
+ if (opts.backendType === "meshllm" && !opts.experimental) {
1123
+ node["_cfg"].excludedModels = ["mesh"];
1124
+ }
1085
1125
  // NAT-4 guard: if endpoint is non-routable and no relay configured, skip
1086
1126
  // registration to avoid a confusing 422 from the directory's RoutableEndpoint check.
1087
1127
  const epLocal = endpointIsLocalOrPrivate(publicEndpoint);
@@ -1134,6 +1174,8 @@ async function runServe(opts) {
1134
1174
  }
1135
1175
  }
1136
1176
  }
1177
+ if (opts.node)
1178
+ completeHandoffForNode(opts.node);
1137
1179
  break;
1138
1180
  }
1139
1181
  catch (exc) {
@@ -1192,6 +1234,7 @@ async function runServe(opts) {
1192
1234
  },
1193
1235
  });
1194
1236
  }
1237
+ const handoffTimer = scheduleSupervisedHandoffRestart(opts.node);
1195
1238
  const stop = node.serve(handler, { host: opts.host, port: opts.port, nodeToken: token });
1196
1239
  await new Promise((resolve) => {
1197
1240
  const shutdown = async (sig) => {
@@ -1218,6 +1261,8 @@ async function runServe(opts) {
1218
1261
  }
1219
1262
  tunnelHandle?.close(); // #520 — tear the Quick Tunnel down with the node
1220
1263
  stop();
1264
+ if (handoffTimer)
1265
+ clearTimeout(handoffTimer);
1221
1266
  stopAutoUpdate();
1222
1267
  instanceLock.release(); // #405 — free the pidfile on shutdown
1223
1268
  resolve();
@@ -1985,6 +2030,7 @@ function printOperatorHelp() {
1985
2030
  `Manage your operator identity.\n\n` +
1986
2031
  `subcommands:\n` +
1987
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` +
1988
2034
  ` dsr <action> Export, restrict, or anonymize your directory records\n` +
1989
2035
  ` encrypt Password-encrypt the operator secret at rest ($IICP_OPERATOR_PASSPHRASE)\n` +
1990
2036
  ` decrypt Remove at-rest encryption of the operator secret\n\n` +
@@ -2110,6 +2156,214 @@ async function runOperatorDsr(argv) {
2110
2156
  process.stdout.write(`Retained records: ${retention}\n`);
2111
2157
  return 0;
2112
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
+ }
2113
2367
  function printOperatorEncryptHelp() {
2114
2368
  process.stdout.write(`usage: iicp-node operator encrypt\n\n` +
2115
2369
  `Password-encrypt the operator secret at rest. The command prompts for a passphrase\n` +
@@ -2152,6 +2406,8 @@ async function runOperator(argv) {
2152
2406
  }
2153
2407
  if (sub === "dsr")
2154
2408
  return runOperatorDsr(argv.slice(1));
2409
+ if (sub === "key")
2410
+ return runOperatorKey(argv.slice(1));
2155
2411
  if (sub !== "rename") {
2156
2412
  process.stderr.write(`unknown operator subcommand: ${sub}\n`);
2157
2413
  printOperatorHelp();
@@ -2635,6 +2891,7 @@ async function dispatch(argv) {
2635
2891
  "policy-manifest": { type: "string" },
2636
2892
  "backend-type": { type: "string" },
2637
2893
  "backend-api-key": { type: "string" },
2894
+ experimental: { type: "boolean" },
2638
2895
  model: { type: "string" },
2639
2896
  "public-endpoint": { type: "string" },
2640
2897
  "directory-url": { type: "string" },
@@ -2673,10 +2930,11 @@ async function dispatch(argv) {
2673
2930
  backendType: values["backend-type"] ??
2674
2931
  envOr("IICP_BACKEND_TYPE", "openai_compat"),
2675
2932
  backendApiKey: values["backend-api-key"] ?? envOr("IICP_BACKEND_API_KEY") ?? "",
2933
+ experimental: Boolean(values.experimental) || envBool("IICP_EXPERIMENTAL"),
2676
2934
  model: values.model ?? envOr("IICP_BACKEND_MODEL") ?? "",
2677
2935
  publicEndpoint: values["public-endpoint"] ?? envOr("IICP_PUBLIC_ENDPOINT") ?? "",
2678
2936
  directoryUrl: values["directory-url"] ??
2679
- envOr("IICP_DIRECTORY_URL", "https://iicp.network/api"),
2937
+ envOr("IICP_DIRECTORY_URL") ?? "",
2680
2938
  region: values.region ?? envOr("IICP_REGION"),
2681
2939
  intent: values.intent ?? envOr("IICP_INTENT", "urn:iicp:intent:llm:chat:v1"),
2682
2940
  maxConcurrent: values["max-concurrent"] !== undefined