@tokenbuddy/tokenbuddy 1.0.26 → 1.0.27
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/bin/tb-clawtip-proof.js +2 -0
- package/dist/src/clawtip-bootstrap.d.ts +1 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -1
- package/dist/src/clawtip-bootstrap.js +1 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -1
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +172 -51
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +6 -0
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +562 -292
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/init-clawtip-activation.d.ts +5 -0
- package/dist/src/init-clawtip-activation.d.ts.map +1 -1
- package/dist/src/init-clawtip-activation.js +61 -1
- package/dist/src/init-clawtip-activation.js.map +1 -1
- package/dist/src/package-update.d.ts +60 -0
- package/dist/src/package-update.d.ts.map +1 -0
- package/dist/src/package-update.js +220 -0
- package/dist/src/package-update.js.map +1 -0
- package/dist/src/registry-trust.d.ts +7 -0
- package/dist/src/registry-trust.d.ts.map +1 -0
- package/dist/src/registry-trust.js +37 -0
- package/dist/src/registry-trust.js.map +1 -0
- package/dist/src/route-failover.d.ts +2 -2
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +11 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +20 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +41 -4
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-concurrency-limiter.d.ts +36 -0
- package/dist/src/seller-concurrency-limiter.d.ts.map +1 -0
- package/dist/src/seller-concurrency-limiter.js +126 -0
- package/dist/src/seller-concurrency-limiter.js.map +1 -0
- package/dist/src/seller-pool.d.ts +7 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +18 -0
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +21 -0
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +98 -20
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/tb-clawtip-proof.d.ts +3 -0
- package/dist/src/tb-clawtip-proof.d.ts.map +1 -0
- package/dist/src/tb-clawtip-proof.js +24 -0
- package/dist/src/tb-clawtip-proof.js.map +1 -0
- package/dist/src/tb-proxyd.js +45 -3
- package/dist/src/tb-proxyd.js.map +1 -1
- package/package.json +3 -2
- package/src/clawtip-bootstrap.ts +1 -0
- package/src/cli.ts +200 -47
- package/src/daemon.ts +347 -50
- package/src/init-clawtip-activation.ts +77 -1
- package/src/package-update.ts +313 -0
- package/src/registry-trust.ts +51 -0
- package/src/route-failover.ts +14 -2
- package/src/seller-catalog.ts +67 -4
- package/src/seller-concurrency-limiter.ts +161 -0
- package/src/seller-pool.ts +20 -0
- package/src/seller-route-planner.ts +142 -20
- package/src/tb-clawtip-proof.ts +28 -0
- package/src/tb-proxyd.ts +48 -3
- package/static/ui/assets/index-Bzbrp7Qe.css +1 -0
- package/static/ui/assets/index-UAfOhbwC.js +236 -0
- package/static/ui/assets/index-UAfOhbwC.js.map +1 -0
- package/static/ui/index.html +2 -2
- package/tests/cli-routing.test.ts +37 -4
- package/tests/control-plane-ui-endpoints.test.ts +7 -7
- package/tests/daemon-trusted-registry-cache.test.ts +132 -0
- package/tests/e2e.test.ts +14 -1
- package/tests/package-update.test.ts +132 -0
- package/tests/registry-trust.test.ts +28 -0
- package/tests/route-failover.test.ts +13 -0
- package/tests/seller-catalog-413.test.ts +60 -1
- package/tests/seller-concurrency-limiter.test.ts +83 -0
- package/tests/seller-pool.test.ts +23 -0
- package/tests/seller-route-planner.test.ts +78 -0
- package/tests/tokenbuddy.test.ts +316 -34
- package/static/ui/assets/index-1uuyCCzj.css +0 -1
- package/static/ui/assets/index-cm_EgQZ-.js +0 -236
- package/static/ui/assets/index-cm_EgQZ-.js.map +0 -1
|
@@ -88,6 +88,11 @@ export interface StartClawtipWalletBootstrapOptions {
|
|
|
88
88
|
runClawtipCommand?: (args: string[]) => Promise<string>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export interface ClawtipProofProviderPayload {
|
|
92
|
+
paymentInstructions?: unknown;
|
|
93
|
+
quote?: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
/**
|
|
92
97
|
* `checkOpenClawRuntime()` 的可注入依赖。
|
|
93
98
|
*/
|
|
@@ -301,7 +306,13 @@ function findClawtipFailureMessage(output: string): string | undefined {
|
|
|
301
306
|
if (trimmed.includes("商家信息有误")
|
|
302
307
|
|| trimmed.includes("商户信息有误")
|
|
303
308
|
|| trimmed.includes("支付失败")
|
|
304
|
-
|| trimmed.includes("下单失败")
|
|
309
|
+
|| trimmed.includes("下单失败")
|
|
310
|
+
|| trimmed.includes("收付款方账户不能相同")) {
|
|
311
|
+
return sanitizeClawtipOutput(trimmed);
|
|
312
|
+
}
|
|
313
|
+
const returnedMessage = trimmed.match(/^返回消息[::]\s*(.+)$/);
|
|
314
|
+
const returnedText = returnedMessage?.[1]?.trim();
|
|
315
|
+
if (returnedText && !returnedText.includes("成功")) {
|
|
305
316
|
return sanitizeClawtipOutput(trimmed);
|
|
306
317
|
}
|
|
307
318
|
}
|
|
@@ -431,6 +442,71 @@ export function readClawtipPayCredential(orderFile: string): string | undefined
|
|
|
431
442
|
return typeof credential === "string" && credential.trim() ? credential : undefined;
|
|
432
443
|
}
|
|
433
444
|
|
|
445
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
446
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
447
|
+
? value as Record<string, unknown>
|
|
448
|
+
: undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function stringField(source: Record<string, unknown>, key: string): string | undefined {
|
|
452
|
+
const value = source[key];
|
|
453
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function numberField(source: Record<string, unknown>, key: string): number | undefined {
|
|
457
|
+
const value = source[key];
|
|
458
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function findClawtipPayment(payload: ClawtipProofProviderPayload): ClawtipBootstrapPayment | undefined {
|
|
462
|
+
const instructions = asRecord(payload.paymentInstructions);
|
|
463
|
+
const direct = asRecord(instructions?.clawtip);
|
|
464
|
+
const option = Array.isArray(instructions?.options)
|
|
465
|
+
? instructions.options
|
|
466
|
+
.map(asRecord)
|
|
467
|
+
.find((entry) => entry?.method === "clawtip" && asRecord(entry.clawtip))
|
|
468
|
+
: undefined;
|
|
469
|
+
const clawtip = direct || asRecord(option?.clawtip);
|
|
470
|
+
if (!clawtip) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const orderNo = stringField(clawtip, "orderNo") || stringField(clawtip, "order_no");
|
|
475
|
+
const indicator = stringField(clawtip, "indicator");
|
|
476
|
+
const amountFen = numberField(clawtip, "amountFen") ?? numberField(clawtip, "amount");
|
|
477
|
+
if (!orderNo || !indicator || amountFen === undefined) {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
orderNo,
|
|
483
|
+
indicator,
|
|
484
|
+
amountFen,
|
|
485
|
+
payTo: stringField(clawtip, "payTo") || stringField(clawtip, "pay_to"),
|
|
486
|
+
encryptedData: stringField(clawtip, "encryptedData") || stringField(clawtip, "encrypted_data"),
|
|
487
|
+
slug: stringField(clawtip, "slug"),
|
|
488
|
+
skillId: stringField(clawtip, "skillId") || stringField(clawtip, "skill-id"),
|
|
489
|
+
description: stringField(clawtip, "description"),
|
|
490
|
+
resourceUrl: stringField(clawtip, "resourceUrl") || stringField(clawtip, "resource_url"),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function createClawtipPaymentProof(
|
|
495
|
+
payload: ClawtipProofProviderPayload,
|
|
496
|
+
options: StartClawtipWalletBootstrapOptions = {},
|
|
497
|
+
): Promise<string> {
|
|
498
|
+
const payment = findClawtipPayment(payload);
|
|
499
|
+
if (!payment) {
|
|
500
|
+
throw new Error("ClawTip proof payload is missing paymentInstructions.clawtip order data");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const result = await startClawtipWalletBootstrap(payment, options);
|
|
504
|
+
if (!result.payCredential) {
|
|
505
|
+
throw new Error(`ClawTip pay did not write payCredential to the order file: ${result.orderFile}`);
|
|
506
|
+
}
|
|
507
|
+
return result.payCredential;
|
|
508
|
+
}
|
|
509
|
+
|
|
434
510
|
/**
|
|
435
511
|
* 把 ClawTip 引导支付所需的所有字段写成本地 order JSON:
|
|
436
512
|
* `~/.openclaw/skills/orders/<indicator>/<orderNo>.json`。
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { execFileSync, spawn } from "child_process";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
export const TOKENBUDDY_LAUNCHD_LABEL = "com.tokenbuddy.proxyd";
|
|
8
|
+
const DEFAULT_PACKAGE_NAME = "tokenbuddy";
|
|
9
|
+
|
|
10
|
+
export interface InstalledPackageManifest {
|
|
11
|
+
name: string;
|
|
12
|
+
version: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PackageUpdateCheck {
|
|
16
|
+
packageName: string;
|
|
17
|
+
currentVersion: string;
|
|
18
|
+
latestVersion: string;
|
|
19
|
+
updateAvailable: boolean;
|
|
20
|
+
registryUrl: string;
|
|
21
|
+
installCommand: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PackageInstallResult {
|
|
25
|
+
attempted: boolean;
|
|
26
|
+
succeeded: boolean;
|
|
27
|
+
command: string;
|
|
28
|
+
args: string[];
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PackageRestartResult {
|
|
33
|
+
attempted: boolean;
|
|
34
|
+
restarted: boolean;
|
|
35
|
+
method: "launchd";
|
|
36
|
+
scheduled?: boolean;
|
|
37
|
+
plistPath?: string;
|
|
38
|
+
target?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PackageUpdateResult {
|
|
43
|
+
check: PackageUpdateCheck;
|
|
44
|
+
install: PackageInstallResult;
|
|
45
|
+
restart: PackageRestartResult;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CheckPackageUpdateDeps {
|
|
49
|
+
fetch?: typeof fetch;
|
|
50
|
+
env?: NodeJS.ProcessEnv;
|
|
51
|
+
argv?: string[];
|
|
52
|
+
cwd?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RunPackageUpdateDeps extends CheckPackageUpdateDeps {
|
|
56
|
+
npmCommand?: string;
|
|
57
|
+
runNpmInstall?: (command: string, args: string[]) => void;
|
|
58
|
+
restartProxyd?: (controlPort: number) => Promise<PackageRestartResult>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RunPackageUpdateOptions {
|
|
62
|
+
apply: boolean;
|
|
63
|
+
controlPort: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface RegistryDocument {
|
|
67
|
+
"dist-tags"?: {
|
|
68
|
+
latest?: unknown;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function currentModuleDir(): string {
|
|
73
|
+
if (typeof __dirname !== "undefined") {
|
|
74
|
+
return __dirname;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stack = new Error().stack || "";
|
|
78
|
+
const fileUrlMatch = stack.match(/(file:\/\/\/[^)\n]+\/package-update\.js):\d+:\d+/);
|
|
79
|
+
if (fileUrlMatch) {
|
|
80
|
+
return path.dirname(fileURLToPath(fileUrlMatch[1]));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const filePathMatch = stack.match(/(\/[^)\n]+\/package-update\.(?:js|ts)):\d+:\d+/);
|
|
84
|
+
if (filePathMatch) {
|
|
85
|
+
return path.dirname(filePathMatch[1]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return process.cwd();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function candidatePackageRoots(argv: string[] = process.argv, cwd = process.cwd()): string[] {
|
|
92
|
+
return [
|
|
93
|
+
argv[1],
|
|
94
|
+
path.join(cwd, "packages", "tokenbuddy-cli"),
|
|
95
|
+
path.resolve(currentModuleDir(), ".."),
|
|
96
|
+
cwd,
|
|
97
|
+
].filter((candidate): candidate is string => Boolean(candidate));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readInstalledPackageManifest(
|
|
101
|
+
argv: string[] = process.argv,
|
|
102
|
+
cwd = process.cwd(),
|
|
103
|
+
): InstalledPackageManifest {
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
for (const candidateRoot of candidatePackageRoots(argv, cwd)) {
|
|
106
|
+
let current = fs.existsSync(candidateRoot) ? fs.realpathSync(candidateRoot) : candidateRoot;
|
|
107
|
+
if (!fs.existsSync(current)) continue;
|
|
108
|
+
if (!fs.statSync(current).isDirectory()) {
|
|
109
|
+
current = path.dirname(current);
|
|
110
|
+
}
|
|
111
|
+
while (!seen.has(current)) {
|
|
112
|
+
seen.add(current);
|
|
113
|
+
const packageJsonPath = path.join(current, "package.json");
|
|
114
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
115
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { name?: unknown; version?: unknown };
|
|
116
|
+
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
|
|
117
|
+
const name = typeof packageJson.name === "string" && packageJson.name.length > 0
|
|
118
|
+
? packageJson.name
|
|
119
|
+
: DEFAULT_PACKAGE_NAME;
|
|
120
|
+
if (name === DEFAULT_PACKAGE_NAME || name === "@tokenbuddy/tokenbuddy") {
|
|
121
|
+
return { name, version: packageJson.version };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const parent = path.dirname(current);
|
|
126
|
+
if (parent === current) break;
|
|
127
|
+
current = parent;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { name: DEFAULT_PACKAGE_NAME, version: "0.0.0" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function registryUrl(packageName: string): string {
|
|
134
|
+
return `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function packageNameForUpdate(manifest: InstalledPackageManifest, env: NodeJS.ProcessEnv = process.env): string {
|
|
138
|
+
const override = env.TOKENBUDDY_UPDATE_PACKAGE_NAME?.trim();
|
|
139
|
+
if (override) {
|
|
140
|
+
return override;
|
|
141
|
+
}
|
|
142
|
+
return manifest.name === "@tokenbuddy/tokenbuddy"
|
|
143
|
+
? DEFAULT_PACKAGE_NAME
|
|
144
|
+
: manifest.name || DEFAULT_PACKAGE_NAME;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseSemver(value: string): [number, number, number] | null {
|
|
148
|
+
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
149
|
+
if (!match) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isVersionGreater(left: string, right: string): boolean {
|
|
156
|
+
const parsedLeft = parseSemver(left);
|
|
157
|
+
const parsedRight = parseSemver(right);
|
|
158
|
+
if (!parsedLeft || !parsedRight) {
|
|
159
|
+
return left !== right;
|
|
160
|
+
}
|
|
161
|
+
for (let index = 0; index < parsedLeft.length; index += 1) {
|
|
162
|
+
if (parsedLeft[index] > parsedRight[index]) return true;
|
|
163
|
+
if (parsedLeft[index] < parsedRight[index]) return false;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function checkPackageUpdate(deps: CheckPackageUpdateDeps = {}): Promise<PackageUpdateCheck> {
|
|
169
|
+
const env = deps.env ?? process.env;
|
|
170
|
+
const manifest = readInstalledPackageManifest(deps.argv, deps.cwd);
|
|
171
|
+
const packageName = packageNameForUpdate(manifest, env);
|
|
172
|
+
const url = registryUrl(packageName);
|
|
173
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
174
|
+
const res = await fetchImpl(url);
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
throw new Error(`npm registry returned HTTP ${res.status} for ${packageName}`);
|
|
177
|
+
}
|
|
178
|
+
const registry = await res.json() as RegistryDocument;
|
|
179
|
+
const latestVersion = registry["dist-tags"]?.latest;
|
|
180
|
+
if (typeof latestVersion !== "string" || latestVersion.length === 0) {
|
|
181
|
+
throw new Error(`npm registry response for ${packageName} is missing dist-tags.latest`);
|
|
182
|
+
}
|
|
183
|
+
const installCommand = `npm install -g ${packageName}@${latestVersion}`;
|
|
184
|
+
return {
|
|
185
|
+
packageName,
|
|
186
|
+
currentVersion: manifest.version,
|
|
187
|
+
latestVersion,
|
|
188
|
+
updateAvailable: isVersionGreater(latestVersion, manifest.version),
|
|
189
|
+
registryUrl: url,
|
|
190
|
+
installCommand,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function npmCommand(env: NodeJS.ProcessEnv, override?: string): string {
|
|
195
|
+
return override || env.TOKENBUDDY_UPDATE_NPM_COMMAND || "npm";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function defaultRunNpmInstall(command: string, args: string[]): void {
|
|
199
|
+
execFileSync(command, args, {
|
|
200
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
201
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function commandErrorMessage(error: unknown): string {
|
|
206
|
+
const withOutput = error as { message?: string; stderr?: Buffer; stdout?: Buffer };
|
|
207
|
+
const stderr = withOutput.stderr?.toString("utf8").trim();
|
|
208
|
+
const stdout = withOutput.stdout?.toString("utf8").trim();
|
|
209
|
+
return stderr || stdout || (error instanceof Error ? error.message : String(error));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function runPackageUpdate(
|
|
213
|
+
options: RunPackageUpdateOptions,
|
|
214
|
+
deps: RunPackageUpdateDeps = {},
|
|
215
|
+
): Promise<PackageUpdateResult> {
|
|
216
|
+
const check = await checkPackageUpdate(deps);
|
|
217
|
+
const command = npmCommand(deps.env ?? process.env, deps.npmCommand);
|
|
218
|
+
const args = ["install", "-g", `${check.packageName}@${check.latestVersion}`];
|
|
219
|
+
const install: PackageInstallResult = {
|
|
220
|
+
attempted: false,
|
|
221
|
+
succeeded: false,
|
|
222
|
+
command,
|
|
223
|
+
args,
|
|
224
|
+
};
|
|
225
|
+
const restart: PackageRestartResult = {
|
|
226
|
+
attempted: false,
|
|
227
|
+
restarted: false,
|
|
228
|
+
method: "launchd",
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (!options.apply || !check.updateAvailable) {
|
|
232
|
+
return {
|
|
233
|
+
check,
|
|
234
|
+
install,
|
|
235
|
+
restart,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
install.attempted = true;
|
|
240
|
+
try {
|
|
241
|
+
(deps.runNpmInstall ?? defaultRunNpmInstall)(command, args);
|
|
242
|
+
install.succeeded = true;
|
|
243
|
+
} catch (error: unknown) {
|
|
244
|
+
install.error = commandErrorMessage(error);
|
|
245
|
+
return { check, install, restart };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!deps.restartProxyd) {
|
|
249
|
+
restart.error = "tb-proxyd restart runner is not configured";
|
|
250
|
+
return { check, install, restart };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
check,
|
|
255
|
+
install,
|
|
256
|
+
restart: await deps.restartProxyd(options.controlPort),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function launchdUserDomain(): string {
|
|
261
|
+
if (typeof process.getuid === "function") {
|
|
262
|
+
return `gui/${process.getuid()}`;
|
|
263
|
+
}
|
|
264
|
+
return "gui/501";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function launchdServiceTarget(label: string): string {
|
|
268
|
+
return `${launchdUserDomain()}/${label}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function scheduleLaunchAgentRestart(
|
|
272
|
+
deps: {
|
|
273
|
+
platform?: NodeJS.Platform;
|
|
274
|
+
homeDir?: string;
|
|
275
|
+
existsSync?: (filePath: string) => boolean;
|
|
276
|
+
spawn?: typeof spawn;
|
|
277
|
+
} = {},
|
|
278
|
+
): PackageRestartResult {
|
|
279
|
+
const platform = deps.platform ?? process.platform;
|
|
280
|
+
const homeDir = deps.homeDir ?? os.homedir();
|
|
281
|
+
const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${TOKENBUDDY_LAUNCHD_LABEL}.plist`);
|
|
282
|
+
const base = {
|
|
283
|
+
attempted: false,
|
|
284
|
+
restarted: false,
|
|
285
|
+
method: "launchd" as const,
|
|
286
|
+
plistPath,
|
|
287
|
+
};
|
|
288
|
+
if (platform !== "darwin") {
|
|
289
|
+
return {
|
|
290
|
+
...base,
|
|
291
|
+
error: "tb-proxyd restart is only supported for the macOS LaunchAgent service.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (!(deps.existsSync ?? fs.existsSync)(plistPath)) {
|
|
295
|
+
return {
|
|
296
|
+
...base,
|
|
297
|
+
error: "LaunchAgent plist is missing. Run `tb init` to install tb-proxyd as a service first.",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const target = launchdServiceTarget(TOKENBUDDY_LAUNCHD_LABEL);
|
|
302
|
+
const child = (deps.spawn ?? spawn)("sh", ["-c", `sleep 0.35; launchctl kickstart -k ${target}`], {
|
|
303
|
+
detached: true,
|
|
304
|
+
stdio: "ignore",
|
|
305
|
+
});
|
|
306
|
+
child.unref();
|
|
307
|
+
return {
|
|
308
|
+
...base,
|
|
309
|
+
attempted: true,
|
|
310
|
+
scheduled: true,
|
|
311
|
+
target,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SELLER_REGISTRY_URL = "https://registry.tokenbuddy.ai/v1/registry.json";
|
|
4
|
+
export const DEFAULT_SELLER_REGISTRY_SIGNATURE_URL = "https://registry.tokenbuddy.ai/v1/registry.sig";
|
|
5
|
+
|
|
6
|
+
const TRUSTED_REGISTRY_KEYS: Record<string, string> = {
|
|
7
|
+
"registry-ed25519-2026-06": "MCowBQYDK2VwAyEAcPWdwqqycIHmhSBWmt+HgFgQEMNFJv2uEEcpxPzwgb0="
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function signatureUrlForRegistryUrl(registryUrl: string): string {
|
|
11
|
+
if (registryUrl === DEFAULT_SELLER_REGISTRY_URL) {
|
|
12
|
+
return DEFAULT_SELLER_REGISTRY_SIGNATURE_URL;
|
|
13
|
+
}
|
|
14
|
+
return registryUrl.replace(/\.json(?:\?.*)?$/, ".sig");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function shouldVerifyRegistry(registryUrl: string): boolean {
|
|
18
|
+
if (process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY === "1") {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return registryUrl === DEFAULT_SELLER_REGISTRY_URL || process.env.TB_PROXYD_REQUIRE_SIGNED_REGISTRY === "1";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function verifyTrustedRegistrySignature(registryBytes: string, signatureText: string): string {
|
|
25
|
+
return verifyRegistrySignatureWithKeys(registryBytes, signatureText, TRUSTED_REGISTRY_KEYS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function verifyRegistrySignatureWithKeys(
|
|
29
|
+
registryBytes: string,
|
|
30
|
+
signatureText: string,
|
|
31
|
+
trustedKeys: Record<string, string>
|
|
32
|
+
): string {
|
|
33
|
+
const signature = signatureText.trim();
|
|
34
|
+
for (const [keyId, publicKeyDerBase64] of Object.entries(trustedKeys)) {
|
|
35
|
+
const publicKey = crypto.createPublicKey({
|
|
36
|
+
key: Buffer.from(publicKeyDerBase64, "base64"),
|
|
37
|
+
format: "der",
|
|
38
|
+
type: "spki"
|
|
39
|
+
});
|
|
40
|
+
const ok = crypto.verify(
|
|
41
|
+
null,
|
|
42
|
+
Buffer.from(registryBytes, "utf8"),
|
|
43
|
+
publicKey,
|
|
44
|
+
Buffer.from(signature, "base64url")
|
|
45
|
+
);
|
|
46
|
+
if (ok) {
|
|
47
|
+
return keyId;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error("registry signature verification failed");
|
|
51
|
+
}
|
package/src/route-failover.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface FailoverDecision {
|
|
|
30
30
|
wastedCreditMicros?: number;
|
|
31
31
|
/** 失败发生在 fresh-purchase 窗口内(刚买不到 N 秒),用于触发软重试保护 */
|
|
32
32
|
freshPurchase: boolean;
|
|
33
|
-
/** 在切走这个 seller
|
|
33
|
+
/** 在切走这个 seller 之前的最后一次尝试索引(0-based,含本次失败) */
|
|
34
34
|
retryAttemptsBeforeFailover: number;
|
|
35
35
|
/** 当次会话是否已超出 auto-purchase 预算;true 时不再触发新一轮 buy */
|
|
36
36
|
budgetExceeded: boolean;
|
|
@@ -66,7 +66,7 @@ export interface DecideContext {
|
|
|
66
66
|
errorKind: FailureKind;
|
|
67
67
|
/** 人类可读错误描述(不携带敏感字段),用于日志/doctor */
|
|
68
68
|
errorMessage?: string;
|
|
69
|
-
/** 当前是该 seller 的第几次尝试(
|
|
69
|
+
/** 当前是该 seller 的第几次尝试(0-based;日志里同名字段保持该语义) */
|
|
70
70
|
attempt: number;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -170,6 +170,7 @@ export class RouteFailover {
|
|
|
170
170
|
const isHard = context.errorKind === "hard_4xx" || context.errorKind === "auth_invalid" || context.errorKind === "no_compatible";
|
|
171
171
|
const isSoft = context.errorKind === "soft_5xx" || context.errorKind === "deadline";
|
|
172
172
|
const isBusyCapacity = context.errorKind === "busy_capacity";
|
|
173
|
+
const isPurchaseFailure = context.errorKind === "purchase_failed";
|
|
173
174
|
const info = this.pool.inspect(context.sellerId);
|
|
174
175
|
const freshPurchase = info.freshPurchase;
|
|
175
176
|
const budgetExceeded = !this.creditTracker.canAutoPurchase(this.now());
|
|
@@ -190,6 +191,17 @@ export class RouteFailover {
|
|
|
190
191
|
};
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
if (isPurchaseFailure) {
|
|
195
|
+
return {
|
|
196
|
+
action: "failover_next",
|
|
197
|
+
reason: "purchase_failed",
|
|
198
|
+
wastedCreditMicros: this.creditTracker.getEntry(context.sellerId)?.currentBalanceMicros,
|
|
199
|
+
freshPurchase,
|
|
200
|
+
retryAttemptsBeforeFailover: context.attempt,
|
|
201
|
+
budgetExceeded
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
if (isHard) {
|
|
194
206
|
// Hard failures are not eligible for retry; the seller is wrong
|
|
195
207
|
// for this request. The pool has already transferred leftover
|
package/src/seller-catalog.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
shouldVerifyRegistry,
|
|
5
|
+
signatureUrlForRegistryUrl,
|
|
6
|
+
verifyTrustedRegistrySignature
|
|
7
|
+
} from "./registry-trust.js";
|
|
2
8
|
|
|
3
9
|
const logger = createModuleLogger("tb-proxyd");
|
|
4
10
|
|
|
@@ -46,6 +52,21 @@ export interface SellerRegistryDocument {
|
|
|
46
52
|
sellers: RegistrySeller[];
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
export interface SellerRegistryTrustMetadata {
|
|
56
|
+
registryUrl: string;
|
|
57
|
+
registrySha256: string;
|
|
58
|
+
verified: boolean;
|
|
59
|
+
signatureUrl?: string;
|
|
60
|
+
signature?: string;
|
|
61
|
+
signingKeyId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FetchedSellerRegistry {
|
|
65
|
+
registry: SellerRegistryDocument;
|
|
66
|
+
registryJson: string;
|
|
67
|
+
trust: SellerRegistryTrustMetadata;
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
/**
|
|
50
71
|
* Buyer 自动路由 / 模型目录可见性门禁。
|
|
51
72
|
* 新 registry 会显式写 `status`,只有 `active` 参与 buyer 可见路径;
|
|
@@ -174,10 +195,16 @@ export interface SellerCatalogResult {
|
|
|
174
195
|
version: number;
|
|
175
196
|
/** 默认 seller ID(来自 registry) */
|
|
176
197
|
defaultSeller?: string;
|
|
198
|
+
/** fetched registry document */
|
|
199
|
+
registry?: SellerRegistryDocument;
|
|
200
|
+
/** fetched registry bytes used for hashing/signature verification */
|
|
201
|
+
registryJson?: string;
|
|
177
202
|
/** 模型目录条目列表(去重后) */
|
|
178
203
|
models: ModelCatalogEntry[];
|
|
179
204
|
/** seller 元信息列表(拉取 manifest 后的快照) */
|
|
180
205
|
sellers: SellerCatalogEntry[];
|
|
206
|
+
/** registry trust metadata for the fetched registry document */
|
|
207
|
+
registryTrust?: SellerRegistryTrustMetadata;
|
|
181
208
|
}
|
|
182
209
|
|
|
183
210
|
/**
|
|
@@ -270,6 +297,10 @@ export class RegistryTooLargeError extends Error {
|
|
|
270
297
|
* @throws RegistryTooLargeError / Error
|
|
271
298
|
*/
|
|
272
299
|
export async function fetchSellerRegistry(registryUrl: string): Promise<SellerRegistryDocument> {
|
|
300
|
+
return (await fetchSellerRegistryWithTrust(registryUrl)).registry;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function fetchSellerRegistryWithTrust(registryUrl: string): Promise<FetchedSellerRegistry> {
|
|
273
304
|
const response = await fetch(registryUrl);
|
|
274
305
|
if (response.status === 413) {
|
|
275
306
|
// v1.2 §18.9: parse the structured 413 body so the caller can
|
|
@@ -292,11 +323,39 @@ export async function fetchSellerRegistry(registryUrl: string): Promise<SellerRe
|
|
|
292
323
|
if (!response.ok) {
|
|
293
324
|
throw new Error(`registry returned ${response.status}`);
|
|
294
325
|
}
|
|
295
|
-
const
|
|
326
|
+
const text = await response.text();
|
|
327
|
+
const trust: SellerRegistryTrustMetadata = {
|
|
328
|
+
registryUrl,
|
|
329
|
+
registrySha256: crypto.createHash("sha256").update(text).digest("hex"),
|
|
330
|
+
verified: false
|
|
331
|
+
};
|
|
332
|
+
if (shouldVerifyRegistry(registryUrl)) {
|
|
333
|
+
const signatureUrl = signatureUrlForRegistryUrl(registryUrl);
|
|
334
|
+
const signatureResponse = await fetch(signatureUrl);
|
|
335
|
+
if (!signatureResponse.ok) {
|
|
336
|
+
logger.warn("registry.signature.invalid", "registry signature fetch failed", {
|
|
337
|
+
registryUrl,
|
|
338
|
+
signatureUrl,
|
|
339
|
+
status: signatureResponse.status
|
|
340
|
+
});
|
|
341
|
+
throw new Error(`registry signature returned ${signatureResponse.status}`);
|
|
342
|
+
}
|
|
343
|
+
const signature = (await signatureResponse.text()).trim();
|
|
344
|
+
const signingKeyId = verifyTrustedRegistrySignature(text, signature);
|
|
345
|
+
trust.verified = true;
|
|
346
|
+
trust.signatureUrl = signatureUrl;
|
|
347
|
+
trust.signature = signature;
|
|
348
|
+
trust.signingKeyId = signingKeyId;
|
|
349
|
+
logger.info("registry.signature.verified", "registry signature verified", {
|
|
350
|
+
registryUrl,
|
|
351
|
+
signingKeyId
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const data = JSON.parse(text) as SellerRegistryDocument;
|
|
296
355
|
if (!data || !Array.isArray(data.sellers)) {
|
|
297
356
|
throw new Error("registry response missing sellers");
|
|
298
357
|
}
|
|
299
|
-
return data;
|
|
358
|
+
return { registry: data, registryJson: text, trust };
|
|
300
359
|
}
|
|
301
360
|
|
|
302
361
|
/**
|
|
@@ -325,7 +384,8 @@ export async function fetchSellerManifest(seller: RegistrySeller): Promise<Selle
|
|
|
325
384
|
* @returns 聚合后的目录
|
|
326
385
|
*/
|
|
327
386
|
export async function discoverSellerBackedModels(registryUrl: string): Promise<SellerCatalogResult> {
|
|
328
|
-
const
|
|
387
|
+
const fetched = await fetchSellerRegistryWithTrust(registryUrl);
|
|
388
|
+
const registry = fetched.registry;
|
|
329
389
|
const visibleSellers = registry.sellers.filter(isBuyerVisibleRegistrySeller);
|
|
330
390
|
const sellerResults = await Promise.all(visibleSellers.map(async (seller) => {
|
|
331
391
|
try {
|
|
@@ -380,8 +440,11 @@ export async function discoverSellerBackedModels(registryUrl: string): Promise<S
|
|
|
380
440
|
registryUrl,
|
|
381
441
|
version: registry.version,
|
|
382
442
|
defaultSeller: registry.defaultSeller,
|
|
443
|
+
registry,
|
|
444
|
+
registryJson: fetched.registryJson,
|
|
383
445
|
models: sellerResults.flatMap((entry) => entry.models),
|
|
384
|
-
sellers: sellerResults.map((entry) => entry.seller)
|
|
446
|
+
sellers: sellerResults.map((entry) => entry.seller),
|
|
447
|
+
registryTrust: fetched.trust
|
|
385
448
|
};
|
|
386
449
|
}
|
|
387
450
|
|