@flop-labs/cli 0.4.0 → 0.4.1
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/CHANGELOG.md +26 -0
- package/README.md +6 -4
- package/dist/cli.js +467 -73
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] - 2026-11-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Gateway renewal follows the same operator-owned static-to-public handoffs as the gateway,
|
|
15
|
+
retaining admission expiry, identity checks and static budget ceilings.
|
|
16
|
+
|
|
17
|
+
- Gateway renewal consumes optional dynamic admissions, verifies their pinned identity and
|
|
18
|
+
uses channel-signed paid conformance probes without extending admission or location evidence.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Report session failures as structured nonzero errors with channel recovery guidance.
|
|
23
|
+
- Route package bug reports to the public network issue forms.
|
|
24
|
+
|
|
25
|
+
- Require a fresh dynamic admission before expiry-bound renewal probes, even when a static
|
|
26
|
+
route shadows that slot; retain operator handoff withdrawal checks.
|
|
27
|
+
|
|
28
|
+
- Bound unreachable RPC connections and release wallet-creation connections after errors.
|
|
29
|
+
- Report balance totals including reserves and expose frozen funds; retain the live validator
|
|
30
|
+
minimum in JSON, human and agent output, including an explicit offline state.
|
|
31
|
+
- Resolve exact model names before hash queries and carry the resolved hash into chat.
|
|
32
|
+
- Preflight caller-supplied HTLC appraisal commitments against finalized governed state;
|
|
33
|
+
missing profiles remain unavailable. Forward the appraisal tuple through chat's agent
|
|
34
|
+
inference handoff. Surface faucet readiness and server retry guidance.
|
|
35
|
+
|
|
10
36
|
## [0.4.0] - 2026-10-28
|
|
11
37
|
|
|
12
38
|
### Added
|
package/README.md
CHANGED
|
@@ -405,8 +405,8 @@ From a source checkout, replace `flop` with `node sdk/cli/dist/cli.js` after
|
|
|
405
405
|
the build above.
|
|
406
406
|
The alternate public hosted launcher requires managed-home preparation described
|
|
407
407
|
in the public guide; an immediate Desktop launch is not qualified. With the
|
|
408
|
-
tailnet `--gateway`,
|
|
409
|
-
|
|
408
|
+
tailnet `--gateway`, your Tailscale user ID must be approved first; request it with an
|
|
409
|
+
[access request](https://github.com/flop-labs/network/issues/new?template=access.yml). The CLI then obtains a seven-day, 25 FLOP model-scoped allowance and
|
|
410
410
|
stores the key in `~/.flop/devnet-agent-key.json` (mode 0600). Later launches
|
|
411
411
|
and other local agent clients check and reuse the cached key.
|
|
412
412
|
If the local key is lost or rejected, enrollment rotates the bearer while preserving
|
|
@@ -441,7 +441,7 @@ tailnet.
|
|
|
441
441
|
#### Local OpenAI Proxy
|
|
442
442
|
Expose a local OpenAI-compatible API (like LM Studio / Ollama's local server)
|
|
443
443
|
where **every request is submitted and settled on-chain under your signer**.
|
|
444
|
-
The proxy estimates G_n, submits the
|
|
444
|
+
The proxy estimates G_n, submits the PoI task, waits for a TEE miner to settle
|
|
445
445
|
it, then returns the result — all behind the OpenAI REST schema:
|
|
446
446
|
```bash
|
|
447
447
|
flop proxy # http://127.0.0.1:3201/v1
|
|
@@ -497,4 +497,6 @@ Operator images: use `ghcr.io/flop-labs/flop-core/cli:stable` (minimum 0.1.172),
|
|
|
497
497
|
`ghcr.io/flop-labs/flop-core/miner-worker:stable` (minimum 0.1.172), and
|
|
498
498
|
`ghcr.io/flop-labs/flop-core/miner-vllm-toploc:stable` (minimum 0.1.172).
|
|
499
499
|
For a reproducible pin, use the version and digest in [releases.json](https://flop.network/releases.json).
|
|
500
|
-
`just miner-pack /tmp/flop-miner-pack`
|
|
500
|
+
`just miner-pack /tmp/flop-miner-pack` checks that manifest against the minimums, copies it, and sets the images to `:stable`.
|
|
501
|
+
The pack is for the public path, without the tailnet: public RPC, mailbox and sponsor, and
|
|
502
|
+
`docker-compose.tls.yml` for the session endpoint.
|
package/dist/cli.js
CHANGED
|
@@ -5019,9 +5019,12 @@ function table(headers, rows) {
|
|
|
5019
5019
|
}
|
|
5020
5020
|
function inferError(error) {
|
|
5021
5021
|
const message = error instanceof Error ? error.message : String(error);
|
|
5022
|
-
if (/ECONNREFUSED|WebSocket|disconnected|connect.*(failed|error)|network error/i.test(message)) {
|
|
5022
|
+
if (/ECONNREFUSED|WebSocket|disconnected|RPC unreachable|connect.*(failed|error)|network error/i.test(message)) {
|
|
5023
5023
|
return { code: "RPC_UNREACHABLE", message, retryable: true };
|
|
5024
5024
|
}
|
|
5025
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
|
|
5026
|
+
return { code: error.code, message, retryable: false };
|
|
5027
|
+
}
|
|
5025
5028
|
if (/insufficient custody|shards confirmed/i.test(message)) {
|
|
5026
5029
|
return { code: "DA_CUSTODY_FAILED", message, retryable: true };
|
|
5027
5030
|
}
|
|
@@ -5119,13 +5122,14 @@ async function createClient(rpcUrl, signer) {
|
|
|
5119
5122
|
return FlopClient7.create(opts);
|
|
5120
5123
|
}
|
|
5121
5124
|
async function createReadOnlyClient(rpcUrl) {
|
|
5122
|
-
const
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5125
|
+
const { connectFlopApi, FlopClient: FlopClient7 } = await import("@flop-labs/sdk");
|
|
5126
|
+
const api = await connectFlopApi(rpcUrl);
|
|
5127
|
+
try {
|
|
5128
|
+
return FlopClient7.fromApiReadOnly(api);
|
|
5129
|
+
} catch (error) {
|
|
5130
|
+
await api.disconnect();
|
|
5131
|
+
throw error;
|
|
5132
|
+
}
|
|
5129
5133
|
}
|
|
5130
5134
|
|
|
5131
5135
|
// src/opts.ts
|
|
@@ -5208,21 +5212,26 @@ function parsePositiveInt(value, name) {
|
|
|
5208
5212
|
// src/commands/balance.ts
|
|
5209
5213
|
function balanceCmd() {
|
|
5210
5214
|
return new Command("balance").description("Check account balance").argument("<address>", "Account address (SS58)").option("--json", "Output as JSON").action(async (address, opts, command) => {
|
|
5215
|
+
let client;
|
|
5211
5216
|
try {
|
|
5212
5217
|
const rpc = getRpc(command);
|
|
5213
5218
|
const json = getJson(command);
|
|
5214
|
-
|
|
5219
|
+
client = await createClient(rpc);
|
|
5215
5220
|
const info = await client.api.query.system.account(address);
|
|
5221
|
+
const free = BigInt(info.data.free.toString());
|
|
5222
|
+
const reserved = BigInt(info.data.reserved.toString());
|
|
5216
5223
|
const result = {
|
|
5217
5224
|
address,
|
|
5218
5225
|
free: info.data.free.toString(),
|
|
5219
5226
|
reserved: info.data.reserved.toString(),
|
|
5220
|
-
total:
|
|
5227
|
+
total: (free + reserved).toString(),
|
|
5228
|
+
frozen: info.data.frozen.toString()
|
|
5221
5229
|
};
|
|
5222
5230
|
print(result, json);
|
|
5223
|
-
await client.disconnect();
|
|
5224
5231
|
} catch (e) {
|
|
5225
5232
|
commandError(e);
|
|
5233
|
+
} finally {
|
|
5234
|
+
await client?.disconnect();
|
|
5226
5235
|
}
|
|
5227
5236
|
});
|
|
5228
5237
|
}
|
|
@@ -5588,7 +5597,15 @@ function sessionRunCmd() {
|
|
|
5588
5597
|
completion_tokens: result.receipts[0].completionTokens?.toString()
|
|
5589
5598
|
}, getJson(command));
|
|
5590
5599
|
} catch (error) {
|
|
5591
|
-
|
|
5600
|
+
const channelId = error && typeof error === "object" && "channelId" in error && error.channelId instanceof Uint8Array ? `0x${Buffer.from(error.channelId).toString("hex")}` : null;
|
|
5601
|
+
printError({
|
|
5602
|
+
code: "SESSION_FAILED",
|
|
5603
|
+
message: error instanceof Error ? error.message : String(error),
|
|
5604
|
+
channel: channelId,
|
|
5605
|
+
recovery: channelId ? "inspect finalized channel and terminal events before retrying" : "no channel reported",
|
|
5606
|
+
...walletPath ? { wallet: walletPath } : {}
|
|
5607
|
+
}, "json");
|
|
5608
|
+
process.exitCode = 1;
|
|
5592
5609
|
} finally {
|
|
5593
5610
|
await client?.disconnect();
|
|
5594
5611
|
}
|
|
@@ -5599,10 +5616,11 @@ function sessionRunCmd() {
|
|
|
5599
5616
|
import { Command as Command11 } from "commander";
|
|
5600
5617
|
function walletAgentCreateCmd() {
|
|
5601
5618
|
return new Command11("create").description("Create an agent wallet").argument("<agent>", "Agent address (SS58)").option("--daily-cap <cap>", "Daily cap in FLOP", "50").option("--tx-limit <limit>", "Per-tx limit in FLOP", "5").option("--json", "Output as JSON").action(async (agent, opts, command) => {
|
|
5619
|
+
let client;
|
|
5602
5620
|
try {
|
|
5603
5621
|
const rpc = getRpc(command);
|
|
5604
5622
|
const { createAgentWallet: createAgentWallet2 } = await import("@flop-labs/sdk");
|
|
5605
|
-
|
|
5623
|
+
client = await createClient(rpc, getSigner(command));
|
|
5606
5624
|
const flopToRaw3 = (flop) => BigInt(Math.floor(parseFloat(flop) * 1e18));
|
|
5607
5625
|
const txHash = await createAgentWallet2(client.api, client.signer, {
|
|
5608
5626
|
agent,
|
|
@@ -5618,9 +5636,10 @@ function walletAgentCreateCmd() {
|
|
|
5618
5636
|
},
|
|
5619
5637
|
getJson(command)
|
|
5620
5638
|
);
|
|
5621
|
-
await client.disconnect();
|
|
5622
5639
|
} catch (e) {
|
|
5623
|
-
commandError(e, { next: "
|
|
5640
|
+
commandError(e, { next: "inspect finalized agentWallet.wallets for this agent before retrying creation" });
|
|
5641
|
+
} finally {
|
|
5642
|
+
await client?.disconnect();
|
|
5624
5643
|
}
|
|
5625
5644
|
});
|
|
5626
5645
|
}
|
|
@@ -6000,6 +6019,7 @@ import { constants, accessSync, existsSync } from "node:fs";
|
|
|
6000
6019
|
import http from "node:http";
|
|
6001
6020
|
import https from "node:https";
|
|
6002
6021
|
import path from "node:path";
|
|
6022
|
+
import { watchTransaction } from "@flop-labs/sdk";
|
|
6003
6023
|
function printRolePlan(plan, output) {
|
|
6004
6024
|
const mode = resolveOutputMode(output);
|
|
6005
6025
|
if (mode === "agent") {
|
|
@@ -6010,7 +6030,8 @@ function printRolePlan(plan, output) {
|
|
|
6010
6030
|
steps: plan.steps.map(({ phase, command, mutates }) => ({ phase, command, mutates })),
|
|
6011
6031
|
...extended.config_path ? { config_path: extended.config_path } : {},
|
|
6012
6032
|
...extended.mcp_config ? { mcp_config: extended.mcp_config } : {},
|
|
6013
|
-
...plan.gpu_inventory ? { gpu_inventory: plan.gpu_inventory } : {}
|
|
6033
|
+
...plan.gpu_inventory ? { gpu_inventory: plan.gpu_inventory } : {},
|
|
6034
|
+
...plan.role === "validator" ? { live_minimum_stake: plan.live_minimum_stake ?? null } : {}
|
|
6014
6035
|
}, mode);
|
|
6015
6036
|
return;
|
|
6016
6037
|
}
|
|
@@ -6020,6 +6041,7 @@ function printRolePlan(plan, output) {
|
|
|
6020
6041
|
}
|
|
6021
6042
|
console.log(`${plan.role} onboarding (${plan.mode})`);
|
|
6022
6043
|
console.log(plan.summary);
|
|
6044
|
+
if (plan.role === "validator") console.log(`Live minimum stake: ${plan.live_minimum_stake === void 0 ? "not queried (--offline)" : `${plan.live_minimum_stake} FLOP`}`);
|
|
6023
6045
|
for (const [index, step] of plan.steps.entries()) {
|
|
6024
6046
|
console.log(` ${index + 1}. ${step.phase}: ${step.command}`);
|
|
6025
6047
|
console.log(` ${step.purpose}${step.mutates ? " [changes state]" : ""}`);
|
|
@@ -6120,7 +6142,7 @@ async function probeHttpUrl(id, url, required = true, timeoutMs = 3e3) {
|
|
|
6120
6142
|
return;
|
|
6121
6143
|
}
|
|
6122
6144
|
const transport = parsed.protocol === "https:" ? https : http;
|
|
6123
|
-
const
|
|
6145
|
+
const request3 = transport.request(parsed, { method: "GET", timeout: timeoutMs }, (response) => {
|
|
6124
6146
|
response.resume();
|
|
6125
6147
|
const reachable = (response.statusCode ?? 500) < 500;
|
|
6126
6148
|
resolve9({
|
|
@@ -6131,8 +6153,8 @@ async function probeHttpUrl(id, url, required = true, timeoutMs = 3e3) {
|
|
|
6131
6153
|
...!reachable ? { next: `check ${url}; retry doctor` } : {}
|
|
6132
6154
|
});
|
|
6133
6155
|
});
|
|
6134
|
-
|
|
6135
|
-
|
|
6156
|
+
request3.on("timeout", () => request3.destroy(new Error("timeout")));
|
|
6157
|
+
request3.on("error", (error) => {
|
|
6136
6158
|
resolve9({
|
|
6137
6159
|
id,
|
|
6138
6160
|
status: required ? "fail" : "warn",
|
|
@@ -6141,7 +6163,7 @@ async function probeHttpUrl(id, url, required = true, timeoutMs = 3e3) {
|
|
|
6141
6163
|
next: `check ${url}; retry doctor`
|
|
6142
6164
|
});
|
|
6143
6165
|
});
|
|
6144
|
-
|
|
6166
|
+
request3.end();
|
|
6145
6167
|
});
|
|
6146
6168
|
}
|
|
6147
6169
|
async function probeRpc(api, rpc) {
|
|
@@ -6167,25 +6189,8 @@ async function probeRpc(api, rpc) {
|
|
|
6167
6189
|
}
|
|
6168
6190
|
}
|
|
6169
6191
|
async function submitExtrinsic(api, signer, extrinsic) {
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
extrinsic.signAndSend(signer, (result) => {
|
|
6173
|
-
if (!result.status?.isInBlock && !result.status?.isFinalized) return;
|
|
6174
|
-
unsubscribe?.();
|
|
6175
|
-
if (result.dispatchError) {
|
|
6176
|
-
if (result.dispatchError.isModule) {
|
|
6177
|
-
const decoded = api.registry.findMetaError(result.dispatchError.asModule);
|
|
6178
|
-
reject(new Error(`${decoded.section}.${decoded.name}`));
|
|
6179
|
-
} else {
|
|
6180
|
-
reject(new Error(result.dispatchError.toString()));
|
|
6181
|
-
}
|
|
6182
|
-
return;
|
|
6183
|
-
}
|
|
6184
|
-
resolve9((result.txHash ?? extrinsic.hash).toHex());
|
|
6185
|
-
}).then((fn) => {
|
|
6186
|
-
unsubscribe = fn;
|
|
6187
|
-
}).catch(reject);
|
|
6188
|
-
});
|
|
6192
|
+
const result = await watchTransaction(extrinsic, signer, { api });
|
|
6193
|
+
return (result.txHash ?? extrinsic.hash).toHex();
|
|
6189
6194
|
}
|
|
6190
6195
|
function unwrapStorage(raw) {
|
|
6191
6196
|
if (!raw || raw.isNone) return null;
|
|
@@ -9525,7 +9530,7 @@ function modelGetCmd() {
|
|
|
9525
9530
|
const sdk = await import("@flop-labs/sdk");
|
|
9526
9531
|
client = await createClient(getRpc(command));
|
|
9527
9532
|
const registry = new sdk.OnChainModelRegistry(client.api);
|
|
9528
|
-
const model = await registry.getModel(identifier)
|
|
9533
|
+
const model = /^0x[0-9a-f]{64}$/i.test(identifier) ? await registry.getModel(identifier) : await registry.getModelByName(identifier);
|
|
9529
9534
|
if (!model) throw new Error(`model not found: ${identifier}`);
|
|
9530
9535
|
const precision = opts.precision;
|
|
9531
9536
|
const [measuredRoot, sources] = await Promise.all([
|
|
@@ -9559,7 +9564,7 @@ function modelFetchCmd() {
|
|
|
9559
9564
|
const sdk = await import("@flop-labs/sdk");
|
|
9560
9565
|
client = await createClient(getRpc(command));
|
|
9561
9566
|
const registry = new sdk.OnChainModelRegistry(client.api);
|
|
9562
|
-
const model = await registry.getModel(identifier)
|
|
9567
|
+
const model = /^0x[0-9a-f]{64}$/i.test(identifier) ? await registry.getModel(identifier) : await registry.getModelByName(identifier);
|
|
9563
9568
|
if (!model) throw new Error(`model not found: ${identifier}`);
|
|
9564
9569
|
const expected = await sdk.readMeasuredRoot(client.api, model.hash, opts.precision);
|
|
9565
9570
|
if (!expected) {
|
|
@@ -9968,6 +9973,12 @@ function buildMinerInitPlan(options) {
|
|
|
9968
9973
|
summary: mode === "tdx" ? "Prepare the measured TDX guest, attest it, then activate the on-chain miner separately." : mode === "cloud" ? "Provision a non-attested inference backend, then connect miner-worker explicitly." : "Start a local measured-manifest simulation before using real stake or hardware.",
|
|
9969
9974
|
gpu_inventory: gpu,
|
|
9970
9975
|
steps: [
|
|
9976
|
+
{
|
|
9977
|
+
phase: "devnet host",
|
|
9978
|
+
command: "sudo ./install.sh <machine-name>",
|
|
9979
|
+
purpose: "On an Ubuntu 24.04 GPU host, use the operator-issued pack and https://flop.network/skills/flop-miner-devnet/SKILL.md. The pack pins the registered model; the following generic steps apply to other setups.",
|
|
9980
|
+
mutates: true
|
|
9981
|
+
},
|
|
9971
9982
|
{
|
|
9972
9983
|
phase: "inventory",
|
|
9973
9984
|
command: "flop gpu --json",
|
|
@@ -10024,7 +10035,7 @@ function minerInitCmd() {
|
|
|
10024
10035
|
"Expected GPU inventory; repeat for mixed-SKU hosts",
|
|
10025
10036
|
collectGpu,
|
|
10026
10037
|
[]
|
|
10027
|
-
).option("--model <id>", "Inference model", "Qwen/Qwen2.5-
|
|
10038
|
+
).option("--model <id>", "Inference model", "Qwen/Qwen2.5-0.5B-Instruct").option("--json", "Output as JSON").action((opts, command) => {
|
|
10028
10039
|
const instance = Number(opts.instance);
|
|
10029
10040
|
if (!Number.isInteger(instance) || instance < 0) {
|
|
10030
10041
|
commandError(new Error("--instance must be a non-negative integer"));
|
|
@@ -10303,7 +10314,7 @@ function validatorInitCmd() {
|
|
|
10303
10314
|
let client;
|
|
10304
10315
|
try {
|
|
10305
10316
|
let minimumStake;
|
|
10306
|
-
if (!
|
|
10317
|
+
if (!getCommandOption(command, "offline", false)) {
|
|
10307
10318
|
const rpc = getRpc(command);
|
|
10308
10319
|
client = await createClient(rpc);
|
|
10309
10320
|
const { getMinimumStake } = await import("@flop-labs/sdk");
|
|
@@ -10517,9 +10528,8 @@ import { Command as Command39 } from "commander";
|
|
|
10517
10528
|
|
|
10518
10529
|
// src/network.ts
|
|
10519
10530
|
async function probeRpcChain(network, rpc) {
|
|
10520
|
-
const {
|
|
10521
|
-
const
|
|
10522
|
-
const api = await ApiPromise2.create({ provider, noInitWarn: true });
|
|
10531
|
+
const { connectFlopApi } = await import("@flop-labs/sdk");
|
|
10532
|
+
const api = await connectFlopApi(rpc);
|
|
10523
10533
|
try {
|
|
10524
10534
|
const chain = (await api.rpc.system.chain()).toString();
|
|
10525
10535
|
return {
|
|
@@ -10539,7 +10549,14 @@ async function probeRpcChain(network, rpc) {
|
|
|
10539
10549
|
// src/commands/network-status.ts
|
|
10540
10550
|
function networkStatusCmd(probeRpc2 = probeRpcChain, probeFaucet = async (endpoint) => {
|
|
10541
10551
|
const { getFaucetChainInfo } = await import("@flop-labs/sdk");
|
|
10542
|
-
|
|
10552
|
+
const identity = await getFaucetChainInfo({ endpoint });
|
|
10553
|
+
try {
|
|
10554
|
+
const response = await fetch(`${endpoint.replace(/\/$/, "")}/health`, { signal: AbortSignal.timeout(15e3) });
|
|
10555
|
+
const health = await response.json();
|
|
10556
|
+
return { ...identity, ready: typeof health.ready === "boolean" ? health.ready : null, dependencies: health.dependencies };
|
|
10557
|
+
} catch (error) {
|
|
10558
|
+
return { ...identity, ready: null, readinessError: error instanceof Error ? error.message : String(error) };
|
|
10559
|
+
}
|
|
10543
10560
|
}) {
|
|
10544
10561
|
return new Command39("status").description("Show resolved network endpoints and live chain identity").option("--offline", "Resolve configured endpoints without connecting").option("--json", "Output as JSON").action(async (opts, command) => {
|
|
10545
10562
|
const resolved = resolveNetwork(command);
|
|
@@ -10582,6 +10599,9 @@ function networkStatusCmd(probeRpc2 = probeRpcChain, probeFaucet = async (endpoi
|
|
|
10582
10599
|
faucet: resolved.profile.faucet ? {
|
|
10583
10600
|
url: resolved.profile.faucet,
|
|
10584
10601
|
reachable: opts.offline ? null : faucetInfo !== null,
|
|
10602
|
+
ready: faucetInfo?.ready ?? null,
|
|
10603
|
+
dependencies: faucetInfo?.dependencies,
|
|
10604
|
+
readiness_error: faucetInfo?.readinessError,
|
|
10585
10605
|
network: faucetInfo?.network,
|
|
10586
10606
|
genesis_hash: faucetInfo?.genesisHash,
|
|
10587
10607
|
matches_rpc: rpcInfo && faucetInfo ? rpcInfo.genesisHash.toLowerCase() === faucetInfo.genesisHash.toLowerCase() : null,
|
|
@@ -10714,7 +10734,7 @@ function conversationPrompt(messages) {
|
|
|
10714
10734
|
|
|
10715
10735
|
// src/commands/chat.ts
|
|
10716
10736
|
function chatCmd() {
|
|
10717
|
-
return new Command41("chat").description("Start or resume a persistent FLOP chat, optionally through a bounded session").argument("[prompt]", "One-shot prompt; omit for interactive chat").option("--conversation <id>", "Resume a conversation ID").option("--model <id>", "
|
|
10737
|
+
return new Command41("chat").description("Start or resume a persistent FLOP chat, optionally through a bounded session").argument("[prompt]", "One-shot prompt; omit for interactive chat").option("--conversation <id>", "Resume a conversation ID").option("--model <id>", "32-byte model hash or exact registered name from flop model list").option("--session-owner <address>", "Use --signer as this owner's session delegate").option("--max-flop <n>", "Per-turn FLOP cap", "5").option("--tee-requirements <hex>", "Registered HTLC appraisal commitments (100-byte SCALE hex)", process.env.FLOP_HTLC_TEE_REQUIREMENTS).option("--skip-model-check", "Allow an unregistered model (local development only)").option("--history", "List stored conversations without connecting").option("--delete <id>", "Delete a stored conversation without connecting").option("--store <path>", "Conversation store path", join8(homedir4(), ".flop", "conversations.json")).option("--json", "Output as JSON").action(async (prompt, opts, command) => {
|
|
10718
10738
|
const store = new ConversationStore(opts.store);
|
|
10719
10739
|
let agent;
|
|
10720
10740
|
try {
|
|
@@ -10747,19 +10767,28 @@ function chatCmd() {
|
|
|
10747
10767
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
10748
10768
|
const conversation = existing ?? {
|
|
10749
10769
|
id: randomUUID4(),
|
|
10750
|
-
model: opts.model,
|
|
10770
|
+
model: opts.model ?? "",
|
|
10751
10771
|
sessionOwner: opts.sessionOwner,
|
|
10752
10772
|
createdAt: now,
|
|
10753
10773
|
updatedAt: now,
|
|
10754
10774
|
messages: []
|
|
10755
10775
|
};
|
|
10756
|
-
if (!
|
|
10776
|
+
if (!conversation.model) throw new Error("Supply --model with a hash or exact registered name from `flop model list`");
|
|
10777
|
+
{
|
|
10757
10778
|
const sdk = await import("@flop-labs/sdk");
|
|
10758
10779
|
const check = await createClient(getRpc(command));
|
|
10759
10780
|
try {
|
|
10760
|
-
|
|
10761
|
-
|
|
10762
|
-
|
|
10781
|
+
if (!opts.skipModelCheck) {
|
|
10782
|
+
const registry = new sdk.OnChainModelRegistry(check.api);
|
|
10783
|
+
const model = /^0x[0-9a-f]{64}$/i.test(conversation.model) ? await registry.getModel(conversation.model) : await registry.getModelByName(conversation.model);
|
|
10784
|
+
if (!model) throw new Error(`model not found: ${conversation.model}; use a hash or exact name from flop model list`);
|
|
10785
|
+
conversation.model = model.hash;
|
|
10786
|
+
}
|
|
10787
|
+
if (!opts.teeRequirements) throw new Error("HTLC chat requires --tee-requirements from a registered EconomicCritical profile; check the network's attestation prerequisites before funding or signing");
|
|
10788
|
+
if (/^0x[0-9a-f]{200}$/i.test(opts.teeRequirements) && /^0x[0-9a-f]{64}$/i.test(conversation.model) && opts.teeRequirements.slice(74, 138).toLowerCase() !== conversation.model.slice(2).toLowerCase()) {
|
|
10789
|
+
throw new Error("HTLC model commitment must match the resolved --model hash");
|
|
10790
|
+
}
|
|
10791
|
+
await sdk.assertHtlcAppraisalProfile(check.api, opts.teeRequirements);
|
|
10763
10792
|
} finally {
|
|
10764
10793
|
await check.disconnect();
|
|
10765
10794
|
}
|
|
@@ -10779,7 +10808,8 @@ function chatCmd() {
|
|
|
10779
10808
|
conversation.messages.push({ role: "user", content: input, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
10780
10809
|
const result = await agent.inference(conversationPrompt(conversation.messages), {
|
|
10781
10810
|
model: conversation.model,
|
|
10782
|
-
maxCost: maxFlop
|
|
10811
|
+
maxCost: maxFlop,
|
|
10812
|
+
teeRequirements: { appraisal: opts.teeRequirements }
|
|
10783
10813
|
});
|
|
10784
10814
|
conversation.messages.push({
|
|
10785
10815
|
role: "assistant",
|
|
@@ -11601,18 +11631,19 @@ function verificationStatus(model, modelJson) {
|
|
|
11601
11631
|
}
|
|
11602
11632
|
async function fetchMinerSessionInfo(endpoint, authToken, signal) {
|
|
11603
11633
|
const infoUrl = `${endpoint.replace(/\/+$/, "")}/session/info`;
|
|
11604
|
-
const anonymous = await fetch(infoUrl, { signal });
|
|
11634
|
+
const anonymous = await fetch(infoUrl, { signal, redirect: "error" });
|
|
11605
11635
|
await anonymous.body?.cancel();
|
|
11606
11636
|
if (anonymous.status !== 401) throw new Error("miner /session/info does not enforce bearer authentication");
|
|
11607
|
-
const response = await fetch(infoUrl, { headers: { Authorization: `Bearer ${authToken}` }, signal });
|
|
11637
|
+
const response = await fetch(infoUrl, { redirect: "error", headers: { Authorization: `Bearer ${authToken}` }, signal });
|
|
11608
11638
|
if (!response.ok) throw new Error(`miner /session/info returned HTTP ${response.status}`);
|
|
11609
|
-
|
|
11639
|
+
const { computeChannel: computeChannel7 } = await import("@flop-labs/sdk");
|
|
11640
|
+
return JSON.parse(await computeChannel7.readMinerResponse(response, 16 * 1024));
|
|
11610
11641
|
}
|
|
11611
|
-
async function readChainSnapshot(api, miner) {
|
|
11642
|
+
async function readChainSnapshot(api, miner, pinnedHead) {
|
|
11612
11643
|
const identity = miner.modelIdentity;
|
|
11613
11644
|
const precision = PRECISIONS4[identity.precision];
|
|
11614
11645
|
if (!precision) throw new Error(`miner precision is invalid: ${identity.precision}`);
|
|
11615
|
-
const finalizedHead = await api.rpc.chain.getFinalizedHead();
|
|
11646
|
+
const finalizedHead = pinnedHead ?? await api.rpc.chain.getFinalizedHead();
|
|
11616
11647
|
const at = await api.at(finalizedHead);
|
|
11617
11648
|
const modelRegistry = at.query.modelRegistry;
|
|
11618
11649
|
const hwCalibration = at.query.hwCalibration;
|
|
@@ -11688,8 +11719,8 @@ function gatewayAllowlistCmd() {
|
|
|
11688
11719
|
|
|
11689
11720
|
// src/commands/gateway-renew.ts
|
|
11690
11721
|
import { open as open3, readFile as readFile11, rename as rename9, rm as rm4, writeFile as writeFile12 } from "node:fs/promises";
|
|
11691
|
-
import { createHash as
|
|
11692
|
-
import { request } from "node:http";
|
|
11722
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
11723
|
+
import { request as request2 } from "node:http";
|
|
11693
11724
|
import { setTimeout as delay } from "node:timers/promises";
|
|
11694
11725
|
import { Keyring as Keyring5 } from "@polkadot/keyring";
|
|
11695
11726
|
import { decodeAddress as decodeAddress2, randomAsU8a } from "@polkadot/util-crypto";
|
|
@@ -11885,6 +11916,267 @@ function qualificationSignedToolCalls(frames) {
|
|
|
11885
11916
|
});
|
|
11886
11917
|
}
|
|
11887
11918
|
|
|
11919
|
+
// ../gateway-core/src/miner-admissions.ts
|
|
11920
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
11921
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
11922
|
+
var MAX_ADMITTED_MINERS = 256;
|
|
11923
|
+
var MAX_FILE_BYTES = 512 * 1024;
|
|
11924
|
+
var HASH4 = /^0x[0-9a-f]{64}$/;
|
|
11925
|
+
var minerSlot = (miner) => `${miner.minerAddress}:${miner.modelIdentity.requestedSlug}`;
|
|
11926
|
+
var staticMinerSha256 = (miner) => createHash5("sha256").update(JSON.stringify(miner)).digest("hex");
|
|
11927
|
+
var sameModel = (a, b) => Object.keys(a.modelIdentity).every((key) => a.modelIdentity[key] === b.modelIdentity[key]);
|
|
11928
|
+
var MinerRouting = class {
|
|
11929
|
+
constructor(statics, path5) {
|
|
11930
|
+
this.statics = statics;
|
|
11931
|
+
this.path = path5;
|
|
11932
|
+
}
|
|
11933
|
+
statics;
|
|
11934
|
+
path;
|
|
11935
|
+
stamp;
|
|
11936
|
+
entries = [];
|
|
11937
|
+
error;
|
|
11938
|
+
revision = 0;
|
|
11939
|
+
read(dynamic, now = Date.now()) {
|
|
11940
|
+
if (this.path) {
|
|
11941
|
+
let stamp;
|
|
11942
|
+
try {
|
|
11943
|
+
const stat = statSync(this.path);
|
|
11944
|
+
stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
11945
|
+
} catch {
|
|
11946
|
+
stamp = "missing";
|
|
11947
|
+
}
|
|
11948
|
+
if (stamp !== this.stamp) {
|
|
11949
|
+
this.stamp = stamp;
|
|
11950
|
+
this.revision++;
|
|
11951
|
+
this.entries = [];
|
|
11952
|
+
this.error = void 0;
|
|
11953
|
+
let fd;
|
|
11954
|
+
try {
|
|
11955
|
+
fd = openSync(this.path, "r");
|
|
11956
|
+
const stat = fstatSync(fd);
|
|
11957
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error();
|
|
11958
|
+
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
11959
|
+
const size = readSync(fd, buffer, 0, buffer.length, 0);
|
|
11960
|
+
if (size > MAX_FILE_BYTES) throw new Error();
|
|
11961
|
+
const value = JSON.parse(buffer.subarray(0, size).toString("utf8"));
|
|
11962
|
+
if (value.schema !== "flop-static-miner-handoffs/v1" || !Array.isArray(value.entries) || value.entries.length > MAX_ADMITTED_MINERS) throw new Error();
|
|
11963
|
+
const slots2 = /* @__PURE__ */ new Set();
|
|
11964
|
+
for (const entry of value.entries) {
|
|
11965
|
+
if (!entry || typeof entry.minerAddress !== "string" || !entry.minerAddress || typeof entry.requestedSlug !== "string" || !entry.requestedSlug || !/^[0-9a-f]{64}$/.test(entry.staticSha256)) throw new Error();
|
|
11966
|
+
const url = new URL(entry.endpoint);
|
|
11967
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error();
|
|
11968
|
+
const slot = `${entry.minerAddress}:${entry.requestedSlug}`;
|
|
11969
|
+
if (slots2.has(slot)) throw new Error();
|
|
11970
|
+
slots2.add(slot);
|
|
11971
|
+
}
|
|
11972
|
+
this.entries = value.entries;
|
|
11973
|
+
} catch {
|
|
11974
|
+
this.error = new Error("static miner handoffs file is missing or invalid; new routing is disabled");
|
|
11975
|
+
} finally {
|
|
11976
|
+
if (fd !== void 0) closeSync(fd);
|
|
11977
|
+
}
|
|
11978
|
+
}
|
|
11979
|
+
if (this.error) throw this.error;
|
|
11980
|
+
}
|
|
11981
|
+
const handed = /* @__PURE__ */ new Map();
|
|
11982
|
+
for (const entry of this.entries) {
|
|
11983
|
+
const slot = `${entry.minerAddress}:${entry.requestedSlug}`;
|
|
11984
|
+
const sources = this.statics.filter((miner) => minerSlot(miner) === slot);
|
|
11985
|
+
const source = sources[0];
|
|
11986
|
+
if (sources.length !== 1 || source.onDemand || source.admissionExpiresAt || staticMinerSha256(source) !== entry.staticSha256 || source.endpoint === entry.endpoint || !Number.isFinite(source.maxBudgetFlop) || source.maxBudgetFlop <= 0 || !Number.isSafeInteger(source.maxDurationBlocks) || source.maxDurationBlocks < 1) throw new Error("static miner handoff source differs; new routing is disabled");
|
|
11987
|
+
const target = dynamic.find((miner) => minerSlot(miner) === slot && miner.endpoint === entry.endpoint && miner.enclaveKey === source.enclaveKey && sameModel(source, miner) && miner.channelSigned && !!miner.admissionExpiresAt && Date.parse(miner.admissionExpiresAt) > now);
|
|
11988
|
+
handed.set(slot, target ? {
|
|
11989
|
+
...target,
|
|
11990
|
+
maxBudgetFlop: Math.min(source.maxBudgetFlop, target.maxBudgetFlop),
|
|
11991
|
+
maxDurationBlocks: Math.min(source.maxDurationBlocks, target.maxDurationBlocks)
|
|
11992
|
+
} : void 0);
|
|
11993
|
+
}
|
|
11994
|
+
const slots = new Set(this.statics.map(minerSlot));
|
|
11995
|
+
const selected = [];
|
|
11996
|
+
for (const miner of this.statics) {
|
|
11997
|
+
if (!handed.has(minerSlot(miner))) selected.push(miner);
|
|
11998
|
+
else {
|
|
11999
|
+
const target = handed.get(minerSlot(miner));
|
|
12000
|
+
if (target) selected.push(target);
|
|
12001
|
+
}
|
|
12002
|
+
}
|
|
12003
|
+
return [...selected, ...dynamic.filter((miner) => !slots.has(minerSlot(miner)))];
|
|
12004
|
+
}
|
|
12005
|
+
};
|
|
12006
|
+
var MinerAdmissions = class {
|
|
12007
|
+
constructor(path5, policy) {
|
|
12008
|
+
this.path = path5;
|
|
12009
|
+
this.policy = policy;
|
|
12010
|
+
}
|
|
12011
|
+
path;
|
|
12012
|
+
policy;
|
|
12013
|
+
stamp;
|
|
12014
|
+
entries = [];
|
|
12015
|
+
revision = 0;
|
|
12016
|
+
read(now = Date.now()) {
|
|
12017
|
+
let stamp;
|
|
12018
|
+
try {
|
|
12019
|
+
const stat = statSync(this.path);
|
|
12020
|
+
stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
12021
|
+
} catch {
|
|
12022
|
+
stamp = "missing";
|
|
12023
|
+
}
|
|
12024
|
+
if (stamp !== this.stamp) {
|
|
12025
|
+
this.stamp = stamp;
|
|
12026
|
+
this.revision++;
|
|
12027
|
+
this.entries = [];
|
|
12028
|
+
let fd;
|
|
12029
|
+
try {
|
|
12030
|
+
fd = openSync(this.path, "r");
|
|
12031
|
+
const stat = fstatSync(fd);
|
|
12032
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error("admissions file too large");
|
|
12033
|
+
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
12034
|
+
const size = readSync(fd, buffer, 0, buffer.length, 0);
|
|
12035
|
+
if (size > MAX_FILE_BYTES) throw new Error("admissions file too large");
|
|
12036
|
+
const file = JSON.parse(buffer.subarray(0, size).toString("utf8"));
|
|
12037
|
+
if (file.schema !== "flop-miner-admissions/v1" || !Number.isFinite(Date.parse(file.generatedAt)) || !Array.isArray(file.entries) || file.entries.length > MAX_ADMITTED_MINERS) throw new Error("invalid admissions snapshot");
|
|
12038
|
+
const slots = /* @__PURE__ */ new Set();
|
|
12039
|
+
const entries = file.entries.map((entry) => {
|
|
12040
|
+
const id = entry.modelIdentity;
|
|
12041
|
+
const url = new URL(entry.endpoint);
|
|
12042
|
+
const accepted = Date.parse(entry.adAcceptedAt), expiry = Date.parse(entry.admissionExpiresAt);
|
|
12043
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash || typeof entry.minerAddress !== "string" || !entry.minerAddress || !HASH4.test(entry.enclaveKey) || !id || ![id.modelHash, id.decodePolicyHash, id.measuredRoot].every((h) => HASH4.test(h)) || ![id.requestedSlug, id.registryModel, id.registryVersion, id.backendModelName].every((s) => typeof s === "string" && s.length > 0) || !Number.isInteger(id.precision) || id.precision < 0 || id.precision > 5 || !/^[1-9]\d*$/.test(entry.calibrationCapGflopsPerSec) || !Number.isFinite(accepted) || !Number.isFinite(expiry) || accepted > now || expiry <= accepted || expiry > accepted + 36e5 || entry.observation?.countryCode !== null && !/^[A-Z]{2}$/.test(entry.observation?.countryCode) || entry.observation?.regional !== false || entry.observation.observedAt !== entry.adAcceptedAt) throw new Error("invalid admission entry");
|
|
12044
|
+
requireWorkerMinimum(entry.worker?.version, entry.worker?.revision);
|
|
12045
|
+
const slot = minerSlot(entry);
|
|
12046
|
+
if (slots.has(slot)) throw new Error("duplicate admission slot");
|
|
12047
|
+
slots.add(slot);
|
|
12048
|
+
return {
|
|
12049
|
+
endpoint: entry.endpoint,
|
|
12050
|
+
authToken: "",
|
|
12051
|
+
channelSigned: true,
|
|
12052
|
+
minerAddress: entry.minerAddress,
|
|
12053
|
+
enclaveKey: entry.enclaveKey,
|
|
12054
|
+
modelIdentity: id,
|
|
12055
|
+
...this.policy,
|
|
12056
|
+
admissionExpiresAt: entry.admissionExpiresAt,
|
|
12057
|
+
deployment: {
|
|
12058
|
+
countryCode: entry.observation.countryCode ?? "ZZ",
|
|
12059
|
+
source: "mailbox-observed:non-regional",
|
|
12060
|
+
observedAt: entry.adAcceptedAt,
|
|
12061
|
+
expiresAt: entry.admissionExpiresAt
|
|
12062
|
+
}
|
|
12063
|
+
};
|
|
12064
|
+
});
|
|
12065
|
+
this.entries = entries;
|
|
12066
|
+
} catch {
|
|
12067
|
+
} finally {
|
|
12068
|
+
if (fd !== void 0) closeSync(fd);
|
|
12069
|
+
}
|
|
12070
|
+
}
|
|
12071
|
+
return this.entries.filter((entry) => Date.parse(entry.admissionExpiresAt) > now);
|
|
12072
|
+
}
|
|
12073
|
+
};
|
|
12074
|
+
|
|
12075
|
+
// ../gateway-core/src/miner-peer.ts
|
|
12076
|
+
import { BlockList, isIP } from "node:net";
|
|
12077
|
+
import { lookup } from "node:dns/promises";
|
|
12078
|
+
import { request } from "node:https";
|
|
12079
|
+
import { Readable } from "node:stream";
|
|
12080
|
+
var NOT_PUBLIC = new BlockList();
|
|
12081
|
+
for (const [net, bits] of [
|
|
12082
|
+
["0.0.0.0", 8],
|
|
12083
|
+
["10.0.0.0", 8],
|
|
12084
|
+
["100.64.0.0", 10],
|
|
12085
|
+
["127.0.0.0", 8],
|
|
12086
|
+
["169.254.0.0", 16],
|
|
12087
|
+
["172.16.0.0", 12],
|
|
12088
|
+
["192.0.0.0", 24],
|
|
12089
|
+
["192.0.2.0", 24],
|
|
12090
|
+
["192.168.0.0", 16],
|
|
12091
|
+
["198.18.0.0", 15],
|
|
12092
|
+
["198.51.100.0", 24],
|
|
12093
|
+
["203.0.113.0", 24],
|
|
12094
|
+
["224.0.0.0", 4],
|
|
12095
|
+
["240.0.0.0", 4]
|
|
12096
|
+
]) NOT_PUBLIC.addSubnet(net, bits, "ipv4");
|
|
12097
|
+
for (const [net, bits] of [
|
|
12098
|
+
// No ::ffff:0:0/96 rule: BlockList also checks IPv4 addresses against IPv4-mapped rules, so it
|
|
12099
|
+
// would match every IPv4 address. A mapped address is judged by its IPv4 part instead.
|
|
12100
|
+
["::", 128],
|
|
12101
|
+
["::1", 128],
|
|
12102
|
+
["64:ff9b::", 96],
|
|
12103
|
+
["100::", 64],
|
|
12104
|
+
["2001:db8::", 32],
|
|
12105
|
+
["fc00::", 7],
|
|
12106
|
+
["fe80::", 10],
|
|
12107
|
+
["ff00::", 8]
|
|
12108
|
+
]) NOT_PUBLIC.addSubnet(net, bits, "ipv6");
|
|
12109
|
+
function isPublicAddress(ip) {
|
|
12110
|
+
const family = isIP(ip);
|
|
12111
|
+
if (family === 0) return false;
|
|
12112
|
+
if (family === 6) {
|
|
12113
|
+
const canonical = new URL(`http://[${ip}]`).hostname.slice(1, -1);
|
|
12114
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(canonical);
|
|
12115
|
+
if (mapped) {
|
|
12116
|
+
const [high, low] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
|
|
12117
|
+
return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
12118
|
+
}
|
|
12119
|
+
}
|
|
12120
|
+
return !NOT_PUBLIC.check(ip, family === 4 ? "ipv4" : "ipv6");
|
|
12121
|
+
}
|
|
12122
|
+
var EndpointRefusal = class extends Error {
|
|
12123
|
+
};
|
|
12124
|
+
var systemResolve = (host) => lookup(host, { all: true, verbatim: true });
|
|
12125
|
+
async function resolvePublic(host, resolve9 = systemResolve) {
|
|
12126
|
+
const family = isIP(host);
|
|
12127
|
+
const addresses = family ? [{ address: host, family }] : await resolve9(host);
|
|
12128
|
+
if (addresses.length === 0 || !addresses.every((a) => isPublicAddress(a.address))) {
|
|
12129
|
+
throw new EndpointRefusal(`${host} does not resolve to public addresses only`);
|
|
12130
|
+
}
|
|
12131
|
+
return addresses[0];
|
|
12132
|
+
}
|
|
12133
|
+
function minerPeerFetch(endpoint, dynamic, before = () => {
|
|
12134
|
+
}, resolve9 = systemResolve, send2 = request) {
|
|
12135
|
+
return async (input, init = {}) => {
|
|
12136
|
+
before();
|
|
12137
|
+
const url = new URL(String(input));
|
|
12138
|
+
if (url.origin !== new URL(endpoint).origin) throw new EndpointRefusal("miner request escaped its origin");
|
|
12139
|
+
if (!dynamic) return fetch(input, { ...init, redirect: "error" });
|
|
12140
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new EndpointRefusal("dynamic miner requires HTTPS");
|
|
12141
|
+
const signal = init.signal ?? AbortSignal.timeout(3e4);
|
|
12142
|
+
const pinned = await new Promise((accept, reject) => {
|
|
12143
|
+
const abort = () => reject(signal.reason);
|
|
12144
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
12145
|
+
resolvePublic(url.hostname.replace(/^\[|\]$/g, ""), resolve9).then(accept, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
12146
|
+
if (signal.aborted) abort();
|
|
12147
|
+
});
|
|
12148
|
+
before();
|
|
12149
|
+
signal.throwIfAborted();
|
|
12150
|
+
return new Promise((accept, reject) => {
|
|
12151
|
+
const req = send2(url, {
|
|
12152
|
+
method: init.method ?? "GET",
|
|
12153
|
+
headers: Object.fromEntries(new Headers(init.headers).entries()),
|
|
12154
|
+
signal,
|
|
12155
|
+
agent: false,
|
|
12156
|
+
lookup: (_host, options, callback) => {
|
|
12157
|
+
if (options.all) callback(null, [pinned]);
|
|
12158
|
+
else callback(null, pinned.address, pinned.family);
|
|
12159
|
+
}
|
|
12160
|
+
}, (res) => {
|
|
12161
|
+
const status = res.statusCode;
|
|
12162
|
+
if (status >= 300 && status < 400) {
|
|
12163
|
+
res.destroy();
|
|
12164
|
+
req.destroy();
|
|
12165
|
+
reject(new EndpointRefusal("miner redirect refused"));
|
|
12166
|
+
return;
|
|
12167
|
+
}
|
|
12168
|
+
const headers = new Headers();
|
|
12169
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
12170
|
+
if (value !== void 0) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
12171
|
+
}
|
|
12172
|
+
accept(new Response([204, 205].includes(status) ? null : Readable.toWeb(res), { status, headers }));
|
|
12173
|
+
});
|
|
12174
|
+
req.on("error", (error) => reject(signal.aborted ? signal.reason : error));
|
|
12175
|
+
req.end(init.body);
|
|
12176
|
+
});
|
|
12177
|
+
};
|
|
12178
|
+
}
|
|
12179
|
+
|
|
11888
12180
|
// src/commands/gateway-renew.ts
|
|
11889
12181
|
import { Command as Command47 } from "commander";
|
|
11890
12182
|
|
|
@@ -12151,7 +12443,7 @@ function tailnetNodeRef(source) {
|
|
|
12151
12443
|
}
|
|
12152
12444
|
function readTailscaleStatus(socketPath) {
|
|
12153
12445
|
return new Promise((resolve9, reject) => {
|
|
12154
|
-
const req =
|
|
12446
|
+
const req = request2({
|
|
12155
12447
|
socketPath,
|
|
12156
12448
|
path: "/localapi/v0/status",
|
|
12157
12449
|
headers: { Host: "local-tailscaled.sock", "Sec-Tailscale": "localapi" },
|
|
@@ -12253,6 +12545,12 @@ async function renewGatewayEvidence(miners, records, observe) {
|
|
|
12253
12545
|
const node = tailnetNodeRef(declared.source);
|
|
12254
12546
|
let provider;
|
|
12255
12547
|
let session;
|
|
12548
|
+
if (miner.admissionExpiresAt) {
|
|
12549
|
+
await observe.miner(miner);
|
|
12550
|
+
if (Date.parse(miner.admissionExpiresAt) <= observe.now()) throw new Error("miner admission expired");
|
|
12551
|
+
observed.push(miner);
|
|
12552
|
+
continue;
|
|
12553
|
+
}
|
|
12256
12554
|
if (node) {
|
|
12257
12555
|
session = await observe.miner(miner);
|
|
12258
12556
|
provider = await observe.tailnet(node);
|
|
@@ -12467,11 +12765,75 @@ async function waitForSettlementAllowance(api, channelId, aggregateGn, deadline)
|
|
|
12467
12765
|
}
|
|
12468
12766
|
throw new Error("settlement allowance did not arrive before the probe deadline");
|
|
12469
12767
|
}
|
|
12768
|
+
async function renewalMinerInfo(api, miner, fetchImpl = fetch) {
|
|
12769
|
+
const head = await api.rpc.chain.getFinalizedHead();
|
|
12770
|
+
const at = await api.at(head);
|
|
12771
|
+
const registered = await at.query.minerStaking.miners(miner.minerAddress);
|
|
12772
|
+
const active = registered?.isSome && registered.unwrap().isActive;
|
|
12773
|
+
if (!(active?.isTrue ?? active === true)) throw new Error("miner is not registered and active at finality");
|
|
12774
|
+
const encoded = await api.rpc.state.call("TrustRootApi_accepted_calibration_cap", `0x80${hex3(decodeAddress2(miner.minerAddress)).slice(2)}`, head);
|
|
12775
|
+
const cap = encoded.toU8a(true);
|
|
12776
|
+
if (cap.length !== 9 || cap[0] !== 1 || new DataView(cap.buffer, cap.byteOffset + 1, 8).getBigUint64(0, true) <= 0n) throw new Error("miner has no finalized calibrated capacity");
|
|
12777
|
+
const identity = {
|
|
12778
|
+
...miner.modelIdentity,
|
|
12779
|
+
modelHash: bytes3(miner.modelIdentity.modelHash),
|
|
12780
|
+
decodePolicyHash: bytes3(miner.modelIdentity.decodePolicyHash),
|
|
12781
|
+
measuredRoot: bytes3(miner.modelIdentity.measuredRoot)
|
|
12782
|
+
};
|
|
12783
|
+
if (!miner.channelSigned) {
|
|
12784
|
+
const anonymous = await fetchImpl(`${miner.endpoint.replace(/\/$/, "")}/session/info`, { redirect: "error", signal: AbortSignal.timeout(3e4) });
|
|
12785
|
+
await anonymous.body?.cancel();
|
|
12786
|
+
if (anonymous.status !== 401) throw new Error("miner /session/info does not enforce authentication");
|
|
12787
|
+
}
|
|
12788
|
+
const info = miner.channelSigned ? await computeChannel6.fetchProvenSessionInfo(miner.endpoint, {
|
|
12789
|
+
genesisHash: api.genesisHash.toU8a(),
|
|
12790
|
+
account: decodeAddress2(miner.minerAddress),
|
|
12791
|
+
minerAddress: miner.minerAddress,
|
|
12792
|
+
enclaveKey: bytes3(miner.enclaveKey),
|
|
12793
|
+
modelIdentity: identity
|
|
12794
|
+
}, fetchImpl, AbortSignal.timeout(3e4)) : await computeChannel6.fetchMinerSessionInfo(
|
|
12795
|
+
miner.endpoint,
|
|
12796
|
+
{ Authorization: `Bearer ${miner.authToken}` },
|
|
12797
|
+
AbortSignal.timeout(3e4),
|
|
12798
|
+
fetchImpl
|
|
12799
|
+
);
|
|
12800
|
+
requireWorkerMinimum(info.image?.version, info.image?.revision);
|
|
12801
|
+
const wire = {
|
|
12802
|
+
...info,
|
|
12803
|
+
is_ready: info.isReady,
|
|
12804
|
+
enclaveKey: hex3(info.enclaveKey),
|
|
12805
|
+
modelHash: hex3(info.modelHash),
|
|
12806
|
+
modelIdentity: {
|
|
12807
|
+
...info.modelIdentity,
|
|
12808
|
+
modelHash: hex3(info.modelIdentity.modelHash),
|
|
12809
|
+
decodePolicyHash: hex3(info.modelIdentity.decodePolicyHash),
|
|
12810
|
+
measuredRoot: hex3(info.modelIdentity.measuredRoot)
|
|
12811
|
+
}
|
|
12812
|
+
};
|
|
12813
|
+
const snapshot = await readChainSnapshot(api, wire, head.toHex());
|
|
12814
|
+
if (miner.channelSigned && snapshot.calibration) wire.calibration = {
|
|
12815
|
+
...snapshot.calibration,
|
|
12816
|
+
ready: true,
|
|
12817
|
+
state: "active",
|
|
12818
|
+
currentBlock: snapshot.finalizedBlock
|
|
12819
|
+
};
|
|
12820
|
+
assertMinerMatchesChain(wire, snapshot);
|
|
12821
|
+
if (wire.minerAddress !== miner.minerAddress || wire.enclaveKey !== miner.enclaveKey || Object.keys(miner.modelIdentity).some((field2) => wire.modelIdentity[field2] !== miner.modelIdentity[field2])) throw new Error("miner no longer advertises its allowlisted identity");
|
|
12822
|
+
return { ...info, calibration: wire.calibration };
|
|
12823
|
+
}
|
|
12824
|
+
function renewalAdmissions(env, chainPriced) {
|
|
12825
|
+
if (!env.GATEWAY_ADMISSIONS_FILE) return void 0;
|
|
12826
|
+
const maxBudgetFlop = Number(env.GATEWAY_ADMISSION_MAX_BUDGET_FLOP), maxDurationBlocks = Number(env.GATEWAY_ADMISSION_MAX_DURATION_BLOCKS);
|
|
12827
|
+
if (!chainPriced || !Number.isFinite(maxBudgetFlop) || maxBudgetFlop <= 0 || !Number.isSafeInteger(maxDurationBlocks) || maxDurationBlocks <= 0) throw new Error("dynamic admission requires chain pricing and explicit gateway budget/duration policy");
|
|
12828
|
+
return new MinerAdmissions(env.GATEWAY_ADMISSIONS_FILE, { maxBudgetFlop, maxDurationBlocks });
|
|
12829
|
+
}
|
|
12470
12830
|
async function probeToolsConformance(input) {
|
|
12471
12831
|
const { api, chain, miner, record } = input;
|
|
12472
12832
|
const deadline = Date.now() + PROBE_TIMEOUT_MS;
|
|
12473
|
-
const
|
|
12474
|
-
const
|
|
12833
|
+
const fetchImpl = minerPeerFetch(miner.endpoint, !!miner.admissionExpiresAt, input.assertAdmission);
|
|
12834
|
+
const headers = miner.channelSigned ? {} : { Authorization: `Bearer ${miner.authToken}` };
|
|
12835
|
+
const info = await renewalMinerInfo(api, miner, fetchImpl);
|
|
12836
|
+
let requestHeaders;
|
|
12475
12837
|
const pinned = miner.modelIdentity;
|
|
12476
12838
|
const advertised = info.modelIdentity;
|
|
12477
12839
|
if (info.minerAddress !== miner.minerAddress || hex3(info.enclaveKey) !== miner.enclaveKey.toLowerCase() || !advertised || advertised.requestedSlug !== pinned.requestedSlug || advertised.registryModel !== pinned.registryModel || advertised.registryVersion !== pinned.registryVersion || hex3(advertised.modelHash) !== pinned.modelHash || advertised.precision !== pinned.precision || hex3(advertised.decodePolicyHash) !== pinned.decodePolicyHash || hex3(advertised.measuredRoot) !== pinned.measuredRoot || advertised.backendModelName !== pinned.backendModelName) {
|
|
@@ -12493,6 +12855,8 @@ async function probeToolsConformance(input) {
|
|
|
12493
12855
|
let sequence = 0;
|
|
12494
12856
|
const transport = new computeChannel6.HttpSessionTransport(miner.endpoint, PROBE_TIMEOUT_MS, {
|
|
12495
12857
|
headers,
|
|
12858
|
+
fetchImpl,
|
|
12859
|
+
requestHeaders: (method, path5, body) => requestHeaders?.(method, path5, body) ?? {},
|
|
12496
12860
|
onFrame: (frame) => {
|
|
12497
12861
|
if (frame.sequence !== sequence++) throw new Error("tools probe frame sequence mismatch");
|
|
12498
12862
|
if (frame.type === "chunk") frames.push({ kind: frame.kind, data: bytes3(frame.data) });
|
|
@@ -12500,6 +12864,7 @@ async function probeToolsConformance(input) {
|
|
|
12500
12864
|
});
|
|
12501
12865
|
const agentKey = computeChannel6.SessionKey.generate();
|
|
12502
12866
|
const identity = { ...pinned, modelHash: bytes3(pinned.modelHash), decodePolicyHash: bytes3(pinned.decodePolicyHash), measuredRoot: bytes3(pinned.measuredRoot) };
|
|
12867
|
+
input.assertAdmission?.();
|
|
12503
12868
|
const session = await computeChannel6.ChannelSession.open({
|
|
12504
12869
|
chain,
|
|
12505
12870
|
transport,
|
|
@@ -12526,7 +12891,15 @@ async function probeToolsConformance(input) {
|
|
|
12526
12891
|
let verdict;
|
|
12527
12892
|
let receipt;
|
|
12528
12893
|
try {
|
|
12529
|
-
|
|
12894
|
+
if (miner.channelSigned) requestHeaders = await computeChannel6.channelSessionAuth(
|
|
12895
|
+
miner.endpoint,
|
|
12896
|
+
session.channelId,
|
|
12897
|
+
agentKey,
|
|
12898
|
+
info,
|
|
12899
|
+
fetchImpl,
|
|
12900
|
+
AbortSignal.timeout(3e4)
|
|
12901
|
+
);
|
|
12902
|
+
await computeChannel6.notifyMinerOpen(miner.endpoint, session.channelId, agentKey.publicKey, headers, AbortSignal.timeout(3e4), requestHeaders, fetchImpl);
|
|
12530
12903
|
receipt = await session.chatTurn({
|
|
12531
12904
|
version,
|
|
12532
12905
|
model: record.openrouter_slug,
|
|
@@ -12549,7 +12922,7 @@ async function probeToolsConformance(input) {
|
|
|
12549
12922
|
try {
|
|
12550
12923
|
await waitForSettlementAllowance(api, session.channelId, receipt.gN, deadline);
|
|
12551
12924
|
const settled = await session.closeCooperative(async (claim) => {
|
|
12552
|
-
const wire = await computeChannel6.notifyMinerClose(miner.endpoint, claim, headers, AbortSignal.timeout(3e4));
|
|
12925
|
+
const wire = await computeChannel6.notifyMinerClose(miner.endpoint, claim, headers, AbortSignal.timeout(3e4), requestHeaders, fetchImpl);
|
|
12553
12926
|
return wire ? {
|
|
12554
12927
|
channelId: bytes3(wire.channelId),
|
|
12555
12928
|
minerPaid: BigInt(wire.minerPaid),
|
|
@@ -12595,7 +12968,7 @@ function gatewayRenewCmd() {
|
|
|
12595
12968
|
throw new Error("receipt-only mode requires the receipt/hash and cannot run direct probes");
|
|
12596
12969
|
}
|
|
12597
12970
|
const raw = await readFile11(opts.qualificationReceipt);
|
|
12598
|
-
if (raw.length > 8e6 ||
|
|
12971
|
+
if (raw.length > 8e6 || createHash6("sha256").update(raw).digest("hex") !== opts.qualificationReceiptSha256) throw new Error("qualification receipt bytes differ");
|
|
12599
12972
|
const value = JSON.parse(raw.toString("utf8"));
|
|
12600
12973
|
const records2 = parseCanonicalCatalog(envJson(opts.catalogEnv), { chainPriced: opts.catalogPricing === "chain" });
|
|
12601
12974
|
const record = records2.find((item) => item.openrouter_slug === value.model);
|
|
@@ -12612,7 +12985,12 @@ function gatewayRenewCmd() {
|
|
|
12612
12985
|
return;
|
|
12613
12986
|
}
|
|
12614
12987
|
if (!opts.allowlistEnv || !opts.providerKeyEnv || !opts.probeSignerEnv || !opts.probeEscrow) throw new Error("direct renewal requires allowlist, provider, signer and escrow options");
|
|
12615
|
-
const
|
|
12988
|
+
const staticMiners = envJson(opts.allowlistEnv);
|
|
12989
|
+
const admissions = renewalAdmissions(process.env, opts.catalogPricing === "chain");
|
|
12990
|
+
const handoffs = process.env.GATEWAY_STATIC_HANDOFFS_FILE?.trim();
|
|
12991
|
+
if (handoffs && !admissions) throw new Error("static handoffs require dynamic admissions");
|
|
12992
|
+
const routing = new MinerRouting(staticMiners, handoffs);
|
|
12993
|
+
const miners = routing.read(admissions?.read() ?? []);
|
|
12616
12994
|
if (!Array.isArray(miners) || miners.length === 0) throw new Error(`${opts.allowlistEnv} must list at least one miner`);
|
|
12617
12995
|
const records = parseCanonicalCatalog(envJson(opts.catalogEnv), { chainPriced: opts.catalogPricing === "chain" });
|
|
12618
12996
|
const providerKey = envSecret(opts.providerKeyEnv);
|
|
@@ -12626,24 +13004,40 @@ function gatewayRenewCmd() {
|
|
|
12626
13004
|
await computeChannel6.ready();
|
|
12627
13005
|
const signer = new Keyring5({ type: "sr25519" }).addFromUri(signerUri);
|
|
12628
13006
|
const chain = new computeChannel6.ComputeChannelChainClient(new computeChannel6.FlopChainSubmitter(api, signer, true));
|
|
13007
|
+
const assertAdmission = (miner) => {
|
|
13008
|
+
if (!miner.admissionExpiresAt) return;
|
|
13009
|
+
const dynamic = admissions?.read() ?? [];
|
|
13010
|
+
const matches = (item) => !!item && item.enclaveKey === miner.enclaveKey && item.endpoint === miner.endpoint && JSON.stringify(item.modelIdentity) === JSON.stringify(miner.modelIdentity);
|
|
13011
|
+
if (!matches(dynamic.find((item) => minerSlot(item) === minerSlot(miner))) || !matches(routing.read(dynamic).find((item) => minerSlot(item) === minerSlot(miner)))) throw new Error("miner admission expired or key revoked");
|
|
13012
|
+
};
|
|
12629
13013
|
const report = await renewGatewayEvidence(miners, records, {
|
|
12630
13014
|
now: Date.now,
|
|
12631
13015
|
provider: (ref) => observeGpuAiInstance(ref, providerKey),
|
|
12632
13016
|
tailnet: tailnetObserver(opts.tailscaleSocket),
|
|
12633
13017
|
miner: async (miner) => {
|
|
12634
|
-
const
|
|
13018
|
+
const proven = await renewalMinerInfo(api, miner, minerPeerFetch(miner.endpoint, !!miner.admissionExpiresAt, () => assertAdmission(miner)));
|
|
13019
|
+
const info = {
|
|
13020
|
+
...proven,
|
|
13021
|
+
is_ready: proven.isReady,
|
|
13022
|
+
enclaveKey: hex3(proven.enclaveKey),
|
|
13023
|
+
modelHash: hex3(proven.modelHash),
|
|
13024
|
+
modelIdentity: {
|
|
13025
|
+
...proven.modelIdentity,
|
|
13026
|
+
modelHash: hex3(proven.modelIdentity.modelHash),
|
|
13027
|
+
decodePolicyHash: hex3(proven.modelIdentity.decodePolicyHash),
|
|
13028
|
+
measuredRoot: hex3(proven.modelIdentity.measuredRoot)
|
|
13029
|
+
}
|
|
13030
|
+
};
|
|
12635
13031
|
const pinned = miner.modelIdentity;
|
|
12636
13032
|
const advertised = info.modelIdentity;
|
|
12637
13033
|
if (info.minerAddress !== miner.minerAddress || info.enclaveKey?.toLowerCase() !== miner.enclaveKey.toLowerCase() || !advertised || Object.keys(pinned).some((key) => advertised[key] !== pinned[key])) {
|
|
12638
13034
|
throw new Error("miner no longer advertises its allowlisted identity");
|
|
12639
13035
|
}
|
|
12640
|
-
|
|
12641
|
-
assertMinerMatchesChain(info, snapshot);
|
|
12642
|
-
return `authenticated /session/info matches its pin and finalized block ${snapshot.finalizedBlock}`;
|
|
13036
|
+
return "authenticated session identity matches its pin and finalized registry/calibration";
|
|
12643
13037
|
},
|
|
12644
13038
|
tools: async (miner, record) => {
|
|
12645
13039
|
await assertProbeSignerFunded(api, signer.address, escrow);
|
|
12646
|
-
return probeToolsConformance({ api, chain, miner, record, escrow });
|
|
13040
|
+
return probeToolsConformance({ api, chain, miner, record, escrow, assertAdmission: () => assertAdmission(miner) });
|
|
12647
13041
|
}
|
|
12648
13042
|
});
|
|
12649
13043
|
const exitCode = await finishRenewal(report, opts.out, getJson(command));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flop-labs/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "FLOP Network CLI — interact with FLOP from the terminal",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@flop-labs/client": "^0.2.
|
|
26
|
-
"@flop-labs/sdk": "^0.
|
|
25
|
+
"@flop-labs/client": "^0.2.2",
|
|
26
|
+
"@flop-labs/sdk": "^0.5.1",
|
|
27
27
|
"@polkadot/api": "^17.0.1",
|
|
28
28
|
"@polkadot/keyring": "^14.0.3",
|
|
29
29
|
"@polkadot/util-crypto": "^14.0.3",
|
|
@@ -42,12 +42,15 @@
|
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=22.0.0"
|
|
44
44
|
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/flop-labs/network/issues/new/choose"
|
|
47
|
+
},
|
|
45
48
|
"scripts": {
|
|
46
49
|
"build": "tsc --noEmit && node scripts/bundle.mjs",
|
|
47
50
|
"start": "node dist/cli.js",
|
|
48
51
|
"dev": "tsx src/cli.ts",
|
|
49
52
|
"typecheck": "tsc --noEmit",
|
|
50
|
-
"test:unit": "vitest run tests/unit",
|
|
53
|
+
"test:unit": "vitest run tests/unit --testTimeout=20000 --hookTimeout=20000",
|
|
51
54
|
"build:public": "node scripts/build-public.mjs",
|
|
52
55
|
"test:public": "node --test scripts/*.test.mjs",
|
|
53
56
|
"verify:public": "node scripts/verify-public.mjs"
|