@ukiyocompute/mcp 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +35 -2
- package/dist/library.js +805 -0
- package/dist/main.js +807 -0
- package/package.json +39 -4
package/dist/main.js
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// packages/machine-client/src/state.ts
|
|
18
|
+
var state_exports = {};
|
|
19
|
+
__export(state_exports, {
|
|
20
|
+
withMachineState: () => withMachineState
|
|
21
|
+
});
|
|
22
|
+
import { DatabaseSync } from "node:sqlite";
|
|
23
|
+
import { mkdir, open, readFile, rename, chmod, lstat } from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
async function withMachineState(directory2, fn) {
|
|
27
|
+
await mkdir(directory2, { recursive: true, mode: 448 });
|
|
28
|
+
const dir = await lstat(directory2);
|
|
29
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid?.()) throw Error("State directory must be an owned directory, not a symlink");
|
|
30
|
+
await chmod(directory2, 448);
|
|
31
|
+
for (const name of ["lock.sqlite", "state.json"]) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await lstat(join(directory2, name));
|
|
34
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.()) throw Error("Unsafe machine state file");
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code !== "ENOENT") throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const lock = new DatabaseSync(join(directory2, "lock.sqlite"), { timeout: 1e3 });
|
|
40
|
+
try {
|
|
41
|
+
lock.exec("CREATE TABLE IF NOT EXISTS client_lock (id INTEGER PRIMARY KEY); BEGIN IMMEDIATE;");
|
|
42
|
+
const file = join(directory2, "state.json");
|
|
43
|
+
let state;
|
|
44
|
+
try {
|
|
45
|
+
state = JSON.parse(await readFile(file, "utf8"));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (e.code !== "ENOENT") throw e;
|
|
48
|
+
state = { reservedMinor: 0, purchases: {} };
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isSafeInteger(state.reservedMinor) || state.reservedMinor < 0 || !state.purchases) throw Error("Invalid machine client state; refusing to reset spend history");
|
|
51
|
+
const save = async () => {
|
|
52
|
+
const temporary = join(directory2, `state-${randomUUID()}.tmp`), f = await open(temporary, "wx", 384);
|
|
53
|
+
try {
|
|
54
|
+
await f.writeFile(JSON.stringify(state));
|
|
55
|
+
await f.sync();
|
|
56
|
+
} finally {
|
|
57
|
+
await f.close();
|
|
58
|
+
}
|
|
59
|
+
await rename(temporary, file);
|
|
60
|
+
const d = await open(directory2, "r");
|
|
61
|
+
try {
|
|
62
|
+
await d.sync();
|
|
63
|
+
} finally {
|
|
64
|
+
await d.close();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return await fn(state, save);
|
|
68
|
+
} finally {
|
|
69
|
+
try {
|
|
70
|
+
lock.exec("ROLLBACK");
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
lock.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
var init_state = __esm({
|
|
77
|
+
"packages/machine-client/src/state.ts"() {
|
|
78
|
+
"use strict";
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// apps/mcp/src/main.ts
|
|
83
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
84
|
+
|
|
85
|
+
// packages/machine-client/src/index.ts
|
|
86
|
+
import { readFile as readFile2, lstat as lstat2 } from "node:fs/promises";
|
|
87
|
+
import { resolve } from "node:path";
|
|
88
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
89
|
+
import { createKeyPairSignerFromBytes } from "@solana/kit";
|
|
90
|
+
import { x402Client } from "@x402/fetch";
|
|
91
|
+
import { registerExactEvmScheme } from "@x402/evm/exact/client";
|
|
92
|
+
import { Mppx, tempo } from "mppx/client";
|
|
93
|
+
import { Challenge } from "mppx";
|
|
94
|
+
import { solana } from "@solana/mpp/client";
|
|
95
|
+
import { generateKeyPair, exportJWK, importJWK, compactDecrypt } from "jose";
|
|
96
|
+
|
|
97
|
+
// packages/core/src/machine-client-policy.ts
|
|
98
|
+
import { createHash } from "node:crypto";
|
|
99
|
+
|
|
100
|
+
// packages/core/src/domain.ts
|
|
101
|
+
var DomainError = class extends Error {
|
|
102
|
+
constructor(code, message, details) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.code = code;
|
|
105
|
+
this.details = details;
|
|
106
|
+
this.name = "DomainError";
|
|
107
|
+
}
|
|
108
|
+
code;
|
|
109
|
+
details;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// packages/core/src/machine-client-policy.ts
|
|
113
|
+
function canonical(v) {
|
|
114
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") return JSON.stringify(v);
|
|
115
|
+
if (typeof v === "number" && Number.isSafeInteger(v)) return String(v);
|
|
116
|
+
if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
|
|
117
|
+
if (v && typeof v === "object") return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
|
|
118
|
+
throw new DomainError("INVALID_QUOTE", "Invalid quote JSON");
|
|
119
|
+
}
|
|
120
|
+
function validateMachineQuote(envelope, request, policy) {
|
|
121
|
+
const e = envelope, q = e?.quote;
|
|
122
|
+
const fail = () => {
|
|
123
|
+
throw new DomainError("QUOTE_MISMATCH", "Quote differs from the requested purchase or local payment policy");
|
|
124
|
+
};
|
|
125
|
+
if (!q || typeof e.digest !== "string" || createHash("sha256").update(canonical(q)).digest("hex") !== e.digest) fail();
|
|
126
|
+
if (q.origin !== policy.origin || canonical(q.request) !== canonical(request) || !Number.isSafeInteger(q.amountMinor) || q.amountMinor <= 0 || !Number.isFinite(Date.parse(q.expiresAt)) || Date.parse(q.expiresAt) <= Date.now()) fail();
|
|
127
|
+
if (![policy.perPaymentMinor, policy.remainingMinor].every((x) => Number.isSafeInteger(x) && x >= 0) || q.amountMinor > policy.perPaymentMinor || q.amountMinor > policy.remainingMinor || q.amountMinor > q.request.maxPriceMinor) throw new DomainError("SPEND_CAP_EXCEEDED", "Payment exceeds a local cap");
|
|
128
|
+
const r = q.requirements;
|
|
129
|
+
if (q.request.method === "base") {
|
|
130
|
+
if (r?.scheme !== "exact" || r.network !== policy.network || String(r.asset).toLowerCase() !== policy.asset.toLowerCase() || String(r.payTo).toLowerCase() !== policy.recipient.toLowerCase() || r.amount !== (BigInt(q.amountMinor) * 10000n).toString()) fail();
|
|
131
|
+
} else {
|
|
132
|
+
if (!["tempo", "solana"].includes(q.request.method)) fail();
|
|
133
|
+
const details = r?.methodDetails;
|
|
134
|
+
const allowed = q.request.method === "tempo" ? ["chainId", "feePayer"] : ["network", "decimals", "tokenProgram", "recentBlockhash"];
|
|
135
|
+
if (!details || Object.keys(details).some((k) => !allowed.includes(k)) || details.feePayer === true || r.splits || r.decimals !== void 0 && r.decimals !== 6) fail();
|
|
136
|
+
if (q.request.method === "solana" && (details.decimals !== 6 || details.tokenProgram && details.tokenProgram !== "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")) fail();
|
|
137
|
+
const chain = q.request.method === "tempo" ? `eip155:${r?.methodDetails?.chainId}` : r?.methodDetails?.network === "devnet" ? "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" : r?.methodDetails?.network === "mainnet" ? "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" : "";
|
|
138
|
+
if (chain !== policy.network || r?.currency !== policy.asset || r?.recipient !== policy.recipient || r?.amount !== (BigInt(q.amountMinor) * 10000n).toString()) fail();
|
|
139
|
+
}
|
|
140
|
+
return { digest: e.digest, amountMinor: q.amountMinor, quote: q };
|
|
141
|
+
}
|
|
142
|
+
var machineQuoteDigest = (value) => createHash("sha256").update(canonical(value)).digest("hex");
|
|
143
|
+
|
|
144
|
+
// packages/machine-client/src/account-topups.ts
|
|
145
|
+
var FundingError = class extends Error {
|
|
146
|
+
constructor(code, message, status = 0, body = null) {
|
|
147
|
+
super(message);
|
|
148
|
+
this.code = code;
|
|
149
|
+
this.status = status;
|
|
150
|
+
this.body = body;
|
|
151
|
+
}
|
|
152
|
+
code;
|
|
153
|
+
status;
|
|
154
|
+
body;
|
|
155
|
+
};
|
|
156
|
+
async function fundingRequest(origin, token, path, init = {}, fetcher = fetch) {
|
|
157
|
+
const url = new URL(path, origin);
|
|
158
|
+
if (url.origin !== new URL(origin).origin || !url.pathname.startsWith("/api/v1/")) throw new FundingError("INVALID_URL", "Cross-origin funding request refused");
|
|
159
|
+
const response = await fetcher(url, { ...init, redirect: "error", signal: AbortSignal.timeout(3e4), headers: { authorization: `Bearer ${token}`, ...init.body ? { "content-type": "application/json" } : {}, ...init.headers } });
|
|
160
|
+
const body = await response.json();
|
|
161
|
+
if (!response.ok) throw new FundingError(body?.error?.code ?? `HTTP_${response.status}`, body?.error?.message ?? JSON.stringify(body), response.status, body);
|
|
162
|
+
return body.settlementPending ? { ...body.data, settlementPending: true, message: "Settlement pending; do not pay again." } : body.data ?? body;
|
|
163
|
+
}
|
|
164
|
+
async function createAccountTopup(origin, token, amountMinor, idempotencyKey, fetcher = fetch) {
|
|
165
|
+
if (!idempotencyKey?.trim()) throw new FundingError("IDEMPOTENCY_KEY_REQUIRED", "Supply one stable top-up key and reuse it on retries");
|
|
166
|
+
const terms = await fundingRequest(origin, token, "/api/v1/terms", {}, fetcher);
|
|
167
|
+
if (!terms?.version || !/^[a-f0-9]{64}$/.test(terms.sha256 ?? "") || !/^https:\/\//.test(terms.url ?? "")) throw new FundingError("TERMS_UNAVAILABLE", "Read current approved Terms before authorizing this top-up");
|
|
168
|
+
return fundingRequest(origin, token, "/api/v1/billing/machine-topups", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ amountMinor, termsAcceptance: { accepted: true, version: terms.version, sha256: terms.sha256 } }) }, fetcher);
|
|
169
|
+
}
|
|
170
|
+
async function topupChallenge(origin, token, topup, method, fetcher = fetch) {
|
|
171
|
+
const selected = topup.paymentMethods?.find((m) => m.method === method);
|
|
172
|
+
if (!selected) throw new FundingError("PAYMENT_METHOD_DISABLED", `Payment method ${method} disabled. Enabled rails: ${(topup.paymentMethods ?? []).map((m) => m.method).join(", ")}`);
|
|
173
|
+
const expected = `/api/v1/billing/machine-topups/${encodeURIComponent(topup.topUpId)}/pay${method === "tempo" ? "" : "/" + method}`;
|
|
174
|
+
const url = new URL(selected.url);
|
|
175
|
+
if (url.username || url.password || url.origin !== new URL(origin).origin || url.pathname !== expected || url.search || url.hash) throw new FundingError("INVALID_URL", "Unexpected top-up payment URL");
|
|
176
|
+
const response = await fetcher(url, { method: "POST", headers: { "x-ukiyo-api-key": token }, redirect: "error", signal: AbortSignal.timeout(3e4) });
|
|
177
|
+
const body = await response.json();
|
|
178
|
+
if (response.status === 202 || response.ok) return { topup: body.data ?? topup, settlementPending: response.status === 202 || body.settlementPending === true, message: response.status === 202 ? "Settlement pending; do not pay again." : void 0 };
|
|
179
|
+
if (response.status !== 402) throw new FundingError(body?.error?.code ?? `HTTP_${response.status}`, body?.error?.message ?? "Top-up challenge failed", response.status, body);
|
|
180
|
+
return { topup, paymentUrl: url.href, method, protocol: selected.protocol, challenge: body, paymentRequired: response.headers.get("payment-required"), wwwAuthenticate: response.headers.get("www-authenticate") };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// packages/machine-client/src/index.ts
|
|
184
|
+
async function withMachineState2(directory2, fn) {
|
|
185
|
+
if (Number(process.versions.node.split(".")[0]) < 24) throw Error("Wallet purchase commands require Node.js 24 or newer");
|
|
186
|
+
return (await Promise.resolve().then(() => (init_state(), state_exports))).withMachineState(directory2, fn);
|
|
187
|
+
}
|
|
188
|
+
async function loadMachineClient(path) {
|
|
189
|
+
const cfg = JSON.parse(await readFile2(path, "utf8"));
|
|
190
|
+
if (!["base", "solana", "tempo"].includes(cfg.method) || !Number.isSafeInteger(cfg.perPaymentMinor) || !Number.isSafeInteger(cfg.totalMinor) || cfg.perPaymentMinor <= 0 || cfg.totalMinor < cfg.perPaymentMinor) throw Error("Explicit positive payment and cumulative caps are required");
|
|
191
|
+
const url = new URL(cfg.origin);
|
|
192
|
+
if (url.origin !== cfg.origin || url.protocol !== "https:" && !(url.protocol === "http:" && url.hostname === "127.0.0.1")) throw Error("Configured origin must be HTTPS (loopback allowed for tests)");
|
|
193
|
+
for (const p of [cfg.walletFile, cfg.stateDirectory]) if (typeof p !== "string" || resolve(p) !== p) throw Error("Wallet and state paths must be absolute");
|
|
194
|
+
const info = await lstat2(cfg.walletFile);
|
|
195
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || info.mode & 63) throw Error("Wallet file must be private (0600)");
|
|
196
|
+
const key = (await readFile2(cfg.walletFile, "utf8")).trim();
|
|
197
|
+
let signer;
|
|
198
|
+
try {
|
|
199
|
+
signer = cfg.method === "solana" ? await createKeyPairSignerFromBytes(Buffer.from(key, "base64")) : privateKeyToAccount(key);
|
|
200
|
+
} catch {
|
|
201
|
+
throw Error("Invalid private wallet file");
|
|
202
|
+
}
|
|
203
|
+
return new MachinePurchaseClient(cfg, signer);
|
|
204
|
+
}
|
|
205
|
+
var MachinePurchaseClient = class {
|
|
206
|
+
constructor(config, signer, fetcher = fetch) {
|
|
207
|
+
this.config = config;
|
|
208
|
+
this.signer = signer;
|
|
209
|
+
this.fetcher = fetcher;
|
|
210
|
+
}
|
|
211
|
+
config;
|
|
212
|
+
signer;
|
|
213
|
+
fetcher;
|
|
214
|
+
async post(path, body, headers = {}) {
|
|
215
|
+
const u = new URL(path, this.config.origin);
|
|
216
|
+
if (u.origin !== this.config.origin) throw Error("Cross-origin payment request refused");
|
|
217
|
+
const r = await this.fetcher(u, { method: "POST", redirect: "error", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(3e4) });
|
|
218
|
+
const value = await r.json();
|
|
219
|
+
if (![200, 202, 402].includes(r.status)) throw Error(value?.error?.code ?? `HTTP ${r.status}`);
|
|
220
|
+
return { status: r.status, value, headers: r.headers };
|
|
221
|
+
}
|
|
222
|
+
async recover(p) {
|
|
223
|
+
const r = await this.fetcher(`${this.config.origin}/api/machine/quotes/${p.envelope.digest}/recovery`, { redirect: "error" });
|
|
224
|
+
if (!r.ok) throw Error("Purchase recovery unavailable");
|
|
225
|
+
const value = await r.json();
|
|
226
|
+
if (!value.encrypted) throw Error("Payment still pending; retry original payment");
|
|
227
|
+
const plain = await compactDecrypt(value.encrypted, await importJWK(p.privateKey, "ECDH-ES"));
|
|
228
|
+
const result = JSON.parse(Buffer.from(plain.plaintext).toString());
|
|
229
|
+
if (result.purchaseId !== p.envelope.digest) throw Error("Purchase mismatch");
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
async action(id, action) {
|
|
233
|
+
return withMachineState2(this.config.stateDirectory, async (state, save) => {
|
|
234
|
+
const p = state.purchases[id];
|
|
235
|
+
if (!p?.envelope) throw Error("Saved purchase is required for recovery");
|
|
236
|
+
if (p.binding !== machineQuoteDigest({ ...this.config, signer: this.signer.address })) throw Error("Wallet policy mismatch");
|
|
237
|
+
const current = p.result?.accessToken ? p.result : await this.recover(p);
|
|
238
|
+
const r = await this.post(`/api/machine/quotes/${p.envelope.digest}/actions/${action}`, {}, { authorization: `Bearer ${current.accessToken}` });
|
|
239
|
+
if (r.value.purchaseId !== p.envelope.digest) throw Error("Purchase mismatch");
|
|
240
|
+
p.result = r.value;
|
|
241
|
+
await save();
|
|
242
|
+
return r.value;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** Account funding uses the same signer, policy and durable payment store as purchases. */
|
|
246
|
+
async fundTopup(topup, token, method = this.config.method) {
|
|
247
|
+
if (method !== this.config.method) throw new FundingError("WALLET_POLICY_MISMATCH", "Requested rail differs from configured wallet");
|
|
248
|
+
if (!/^[a-f0-9-]{36}$/i.test(topup.topUpId ?? "") || !Number.isSafeInteger(topup.amountMinor) || topup.amountMinor <= 0) throw new FundingError("INVALID_TOPUP", "Invalid account top-up");
|
|
249
|
+
return withMachineState2(this.config.stateDirectory, async (state, save) => {
|
|
250
|
+
const id = "topup:" + topup.topUpId, binding = machineQuoteDigest({ ...this.config, signer: this.signer.address });
|
|
251
|
+
let p = state.purchases[id];
|
|
252
|
+
if (p && (p.binding !== binding || p.amountMinor !== topup.amountMinor)) throw new FundingError("WALLET_POLICY_MISMATCH", "Saved top-up conflicts with wallet policy");
|
|
253
|
+
if (p?.pending || p?.complete) {
|
|
254
|
+
const result2 = await fundingRequest(this.config.origin, token, `/api/v1/billing/machine-topups/${topup.topUpId}`, {}, this.fetcher);
|
|
255
|
+
return { topup: result2, settlementPending: result2.status !== "SUCCEEDED", message: result2.status !== "SUCCEEDED" ? "Settlement pending; do not pay again." : void 0 };
|
|
256
|
+
}
|
|
257
|
+
if (!p) {
|
|
258
|
+
if (topup.amountMinor > this.config.perPaymentMinor || topup.amountMinor > this.config.totalMinor - state.reservedMinor) throw new FundingError("SPEND_CAP_EXCEEDED", "Top-up exceeds local wallet spending caps");
|
|
259
|
+
p = state.purchases[id] = { binding, amountMinor: topup.amountMinor };
|
|
260
|
+
state.reservedMinor += topup.amountMinor;
|
|
261
|
+
await save();
|
|
262
|
+
}
|
|
263
|
+
if (!p.payment) {
|
|
264
|
+
const q = await topupChallenge(this.config.origin, token, topup, method, this.fetcher);
|
|
265
|
+
if (!q.paymentUrl) {
|
|
266
|
+
p.pending = q.settlementPending;
|
|
267
|
+
p.complete = !q.settlementPending;
|
|
268
|
+
await save();
|
|
269
|
+
return q;
|
|
270
|
+
}
|
|
271
|
+
p.url = q.paymentUrl;
|
|
272
|
+
const required = method === "base" ? JSON.parse(Buffer.from(q.paymentRequired ?? "", "base64").toString()) : void 0;
|
|
273
|
+
const challenge = method === "base" ? void 0 : Challenge.fromResponse(new Response(null, { status: 402, headers: { "www-authenticate": q.wwwAuthenticate ?? "" } }));
|
|
274
|
+
const requirements = method === "base" ? required.accepts?.[0] : { ...challenge.request };
|
|
275
|
+
if (method === "base" && (required.resource?.url !== p.url || required.accepts?.length !== 1)) throw new FundingError("QUOTE_MISMATCH", "Unexpected funding resource");
|
|
276
|
+
if (challenge && (challenge.method !== method || challenge.intent !== "charge")) throw new FundingError("QUOTE_MISMATCH", "Unexpected funding challenge");
|
|
277
|
+
if (method !== "base") delete requirements.externalId;
|
|
278
|
+
const request = { method, maxPriceMinor: topup.amountMinor };
|
|
279
|
+
const quote = { origin: this.config.origin, request, amountMinor: topup.amountMinor, expiresAt: topup.expiresAt, requirements };
|
|
280
|
+
validateMachineQuote({ quote, digest: machineQuoteDigest(quote) }, request, { ...this.config, remainingMinor: topup.amountMinor });
|
|
281
|
+
if (method === "base") {
|
|
282
|
+
const client = registerExactEvmScheme(new x402Client().setSpendControls({ maxAmountPerPayment: `$${(this.config.perPaymentMinor / 100).toFixed(2)}` }), { signer: this.signer });
|
|
283
|
+
p.payment = await client.createPaymentPayload(required);
|
|
284
|
+
} else {
|
|
285
|
+
const methods = method === "solana" ? [solana.charge({ signer: this.signer, rpcUrl: this.config.network === "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" ? "https://api.devnet.solana.com" : "https://api.mainnet-beta.solana.com" })] : [...tempo({ account: this.signer })];
|
|
286
|
+
const client = Mppx.create({ methods, polyfill: false, fetch: async (i, init) => {
|
|
287
|
+
const credential = new Request(i, init).headers.get("authorization");
|
|
288
|
+
if (credential) {
|
|
289
|
+
p.payment = credential;
|
|
290
|
+
return Response.json({ captured: true });
|
|
291
|
+
}
|
|
292
|
+
return new Response(null, { status: 402, headers: { "www-authenticate": Challenge.serialize(challenge) } });
|
|
293
|
+
} });
|
|
294
|
+
await client.fetch(p.url, { method: "POST" });
|
|
295
|
+
if (!p.payment) throw new FundingError("SIGNING_FAILED", "Payment signer returned no credential");
|
|
296
|
+
}
|
|
297
|
+
await save();
|
|
298
|
+
}
|
|
299
|
+
const headers = { "x-ukiyo-api-key": token, ...method === "base" ? { "payment-signature": Buffer.from(JSON.stringify(p.payment)).toString("base64") } : { authorization: p.payment } };
|
|
300
|
+
const response = await this.fetcher(p.url, { method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(3e4) });
|
|
301
|
+
const body = await response.json().catch(() => null);
|
|
302
|
+
if (response.status === 202 || body?.settlementPending) {
|
|
303
|
+
p.pending = true;
|
|
304
|
+
await save();
|
|
305
|
+
return { topup: body?.data ?? topup, settlementPending: true, message: "Settlement pending; do not pay again." };
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) throw new FundingError(body?.error?.code ?? `HTTP_${response.status}`, body?.error?.message ?? "Funding unresolved; retry only this same top-up", response.status, body);
|
|
308
|
+
const result = await fundingRequest(this.config.origin, token, `/api/v1/billing/machine-topups/${topup.topUpId}`, {}, this.fetcher);
|
|
309
|
+
p.complete = result.status === "SUCCEEDED";
|
|
310
|
+
p.pending = !p.complete;
|
|
311
|
+
await save();
|
|
312
|
+
return { topup: result, settlementPending: p.pending, ...p.pending ? { message: "Settlement pending; do not pay again." } : {} };
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
async purchase(id, input, serviceCredit = false) {
|
|
316
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(id)) throw Error("Supply a stable purchase id for retries");
|
|
317
|
+
return withMachineState2(this.config.stateDirectory, async (state, save) => {
|
|
318
|
+
const binding = machineQuoteDigest({ ...this.config, signer: this.signer.address });
|
|
319
|
+
let p = state.purchases[id];
|
|
320
|
+
if (p && (p.binding !== binding || p.serviceCredit !== serviceCredit || canonical(p.input) !== canonical(input))) throw Error("Purchase id conflicts with saved request or wallet policy");
|
|
321
|
+
if (!p) {
|
|
322
|
+
const R = await generateKeyPair("ECDH-ES", { extractable: true });
|
|
323
|
+
p = state.purchases[id] = { binding, input, serviceCredit, privateKey: await exportJWK(R.privateKey), publicKey: await exportJWK(R.publicKey) };
|
|
324
|
+
await save();
|
|
325
|
+
}
|
|
326
|
+
const request = { ...input, requestId: id, method: this.config.method, recoveryKey: p.publicKey };
|
|
327
|
+
if (!p.envelope) {
|
|
328
|
+
const q = await this.post(`/api/machine/${input.kind === "gpu-job" ? "gpu-jobs" : "gpu-blocks"}`, request);
|
|
329
|
+
if (q.status !== 402) throw Error("Expected immutable payment quote");
|
|
330
|
+
validateMachineQuote(q.value, request, { ...this.config, remainingMinor: this.config.totalMinor - state.reservedMinor });
|
|
331
|
+
p.envelope = q.value;
|
|
332
|
+
p.required = q.headers.get("payment-required");
|
|
333
|
+
state.reservedMinor += q.value.quote.amountMinor;
|
|
334
|
+
await save();
|
|
335
|
+
}
|
|
336
|
+
const e = p.envelope;
|
|
337
|
+
if (serviceCredit) {
|
|
338
|
+
const source = Object.values(state.purchases).find((v) => v !== p && v.binding === binding && v.result?.accessToken);
|
|
339
|
+
if (!source) throw Error("A saved source purchase token is required to reuse service credit");
|
|
340
|
+
const paid2 = await this.post(`/api/machine/quotes/${e.digest}/credits`, { sourcePurchaseId: source.result.purchaseId }, { authorization: `Bearer ${source.result.accessToken}` });
|
|
341
|
+
if (paid2.value.purchaseId !== e.digest) throw Error("Purchase mismatch");
|
|
342
|
+
p.result = paid2.value;
|
|
343
|
+
await save();
|
|
344
|
+
return { pending: paid2.status === 202, purchaseId: e.digest, result: p.result };
|
|
345
|
+
}
|
|
346
|
+
if (!p.payment) {
|
|
347
|
+
validateMachineQuote(e, request, { ...this.config, remainingMinor: e.quote.amountMinor });
|
|
348
|
+
if (this.config.method === "base") {
|
|
349
|
+
const client = registerExactEvmScheme(new x402Client().setSpendControls({ maxAmountPerPayment: `$${(this.config.perPaymentMinor / 100).toFixed(2)}` }), { signer: this.signer });
|
|
350
|
+
const required = JSON.parse(Buffer.from(p.required ?? "", "base64").toString());
|
|
351
|
+
if (required.resource?.url !== `${this.config.origin}/api/machine/${input.kind === "gpu-job" ? "gpu-jobs" : "gpu-blocks"}` || canonical(required.accepts) !== canonical([e.quote.requirements])) throw Error("x402 wire requirements mismatch");
|
|
352
|
+
p.payment = await client.createPaymentPayload(required);
|
|
353
|
+
} else {
|
|
354
|
+
const challenge = e.paymentChallenge, normalized = { ...challenge?.request };
|
|
355
|
+
delete normalized.externalId;
|
|
356
|
+
if (canonical(normalized) !== canonical(e.quote.requirements) || challenge.method !== this.config.method || this.config.method === "solana" && challenge.request.externalId !== e.digest) throw Error("MPP challenge does not match immutable quote");
|
|
357
|
+
const methods = this.config.method === "solana" ? [solana.charge({ signer: this.signer, rpcUrl: this.config.network === "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" ? "https://api.devnet.solana.com" : "https://api.mainnet-beta.solana.com" })] : [...tempo({ account: this.signer })];
|
|
358
|
+
const client = Mppx.create({ methods, polyfill: false, fetch: async (i, init) => {
|
|
359
|
+
const r = new Request(i, init);
|
|
360
|
+
const credential = r.headers.get("authorization");
|
|
361
|
+
if (credential) {
|
|
362
|
+
p.payment = credential;
|
|
363
|
+
return Response.json({ captured: true });
|
|
364
|
+
}
|
|
365
|
+
return new Response(null, { status: 402, headers: { "www-authenticate": Challenge.serialize(challenge) } });
|
|
366
|
+
} });
|
|
367
|
+
await client.fetch(`${this.config.origin}/api/machine/${input.kind === "gpu-job" ? "gpu-jobs" : "gpu-blocks"}`, { method: "POST", body: JSON.stringify(request) });
|
|
368
|
+
if (!p.payment) throw Error("Payment signer returned no credential");
|
|
369
|
+
}
|
|
370
|
+
await save();
|
|
371
|
+
}
|
|
372
|
+
const headers = this.config.method === "base" ? { "payment-signature": Buffer.from(JSON.stringify(p.payment)).toString("base64") } : { authorization: p.payment };
|
|
373
|
+
const paid = await this.post(`/api/machine/${input.kind === "gpu-job" ? "gpu-jobs" : "gpu-blocks"}`, request, headers);
|
|
374
|
+
if (paid.value.purchaseId !== e.digest) throw Error("Purchase mismatch");
|
|
375
|
+
p.result = paid.value;
|
|
376
|
+
await save();
|
|
377
|
+
return { pending: paid.status === 202, purchaseId: e.digest, result: p.result };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// apps/cli/src/client.ts
|
|
383
|
+
var ApiError = class extends Error {
|
|
384
|
+
constructor(status, code, message, body) {
|
|
385
|
+
super(message);
|
|
386
|
+
this.status = status;
|
|
387
|
+
this.code = code;
|
|
388
|
+
this.body = body;
|
|
389
|
+
this.name = "ApiError";
|
|
390
|
+
this.requestId = body?.error?.requestId ?? body?.requestId ?? body?.meta?.requestId;
|
|
391
|
+
}
|
|
392
|
+
status;
|
|
393
|
+
code;
|
|
394
|
+
body;
|
|
395
|
+
context = {};
|
|
396
|
+
requestId;
|
|
397
|
+
};
|
|
398
|
+
var UkiyoClient = class {
|
|
399
|
+
baseUrl;
|
|
400
|
+
token;
|
|
401
|
+
fetcher;
|
|
402
|
+
constructor(options = {}) {
|
|
403
|
+
this.baseUrl = (options.baseUrl ?? "https://pay.u-kiyo.ai").replace(/\/+$/, "");
|
|
404
|
+
this.token = options.token;
|
|
405
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
406
|
+
}
|
|
407
|
+
async request(path, init = {}) {
|
|
408
|
+
const { authenticated = true, headers, ...rest } = init;
|
|
409
|
+
if (authenticated && !this.token) {
|
|
410
|
+
throw new ApiError(0, "UNAUTHENTICATED", "No API token. New account: ukiyo signup --json. Existing human-owned account: ukiyo connect. Never give an agent an owner token.", null);
|
|
411
|
+
}
|
|
412
|
+
const signal = rest.signal ?? AbortSignal.timeout(15e3);
|
|
413
|
+
let response;
|
|
414
|
+
try {
|
|
415
|
+
response = await this.fetcher(`${this.baseUrl}/api/v1${path}`, {
|
|
416
|
+
...rest,
|
|
417
|
+
signal,
|
|
418
|
+
redirect: "error",
|
|
419
|
+
headers: {
|
|
420
|
+
// Only declare a JSON body when there is one. A bodyless POST that
|
|
421
|
+
// still claims application/json is rejected before it reaches a route.
|
|
422
|
+
...rest.body === void 0 ? {} : { "content-type": "application/json" },
|
|
423
|
+
...authenticated && this.token ? { authorization: `Bearer ${this.token}` } : {},
|
|
424
|
+
...headers
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
} catch {
|
|
428
|
+
throw new ApiError(0, "NETWORK_ERROR", "API request failed or timed out. For a write, retry only with the same Idempotency-Key and original parameters.", null);
|
|
429
|
+
}
|
|
430
|
+
const body = await response.json().catch(() => null);
|
|
431
|
+
if (signal.aborted) throw new ApiError(0, "NETWORK_ERROR", "API response timed out. Retry writes only with the same Idempotency-Key and original parameters.", null);
|
|
432
|
+
if (!response.ok) {
|
|
433
|
+
const code = body?.error?.code ?? body?.code ?? `HTTP_${response.status}`;
|
|
434
|
+
const message = body?.error?.message ?? body?.message ?? describe(response.status);
|
|
435
|
+
throw new ApiError(response.status, String(code), String(message), body);
|
|
436
|
+
}
|
|
437
|
+
if (body === null) throw new ApiError(response.status, "INVALID_RESPONSE", "API did not return a JSON response.", null);
|
|
438
|
+
return body?.data ?? body;
|
|
439
|
+
}
|
|
440
|
+
offers() {
|
|
441
|
+
return this.request("/offers", { authenticated: false });
|
|
442
|
+
}
|
|
443
|
+
me(signal) {
|
|
444
|
+
return this.request("/me", { signal: signal ?? null });
|
|
445
|
+
}
|
|
446
|
+
balance() {
|
|
447
|
+
return this.request("/billing/balance");
|
|
448
|
+
}
|
|
449
|
+
topup(amountMinor, idempotencyKey) {
|
|
450
|
+
return this.request("/billing/topups", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ amountMinor }) });
|
|
451
|
+
}
|
|
452
|
+
async topupStatus(id) {
|
|
453
|
+
if (!this.token) throw new ApiError(401, "UNAUTHENTICATED", "An account token is required", null);
|
|
454
|
+
try {
|
|
455
|
+
return await fundingRequest(this.baseUrl, this.token, `/api/v1/billing/machine-topups/${encodeURIComponent(id)}`, {}, this.fetcher);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof FundingError) throw new ApiError(error.status, error.code, error.message, error.body);
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async topupRails(amountMinor, key, method, plain = false) {
|
|
462
|
+
if (!this.token) throw new ApiError(401, "UNAUTHENTICATED", "An account token is required", null);
|
|
463
|
+
if (!plain && !process.env.UKIYO_MACHINE_CONFIG) throw new ApiError(0, "WALLET_CONFIG_REQUIRED", "Set UKIYO_MACHINE_CONFIG or use --plain for an unpaid challenge", null);
|
|
464
|
+
try {
|
|
465
|
+
const wallet = plain ? void 0 : await loadMachineClient(process.env.UKIYO_MACHINE_CONFIG);
|
|
466
|
+
if (wallet && wallet.config.origin !== this.baseUrl) throw new ApiError(0, "INVALID_URL", "Wallet and API origins must match", null);
|
|
467
|
+
const topup = await createAccountTopup(this.baseUrl, this.token, amountMinor, key, this.fetcher);
|
|
468
|
+
const rail = method ?? wallet?.config.method ?? topup.paymentMethods?.[0]?.method;
|
|
469
|
+
if (!rail) throw new ApiError(0, "PAYMENT_METHOD_DISABLED", "No funding rails are enabled", null);
|
|
470
|
+
return plain ? topupChallenge(this.baseUrl, this.token, topup, rail, this.fetcher) : wallet.fundTopup(topup, this.token, rail);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (error instanceof FundingError) throw new ApiError(error.status, error.code, error.message, error.body);
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
rent(offerId, budgetMinor, idempotencyKey, signal) {
|
|
477
|
+
return this.request("/rentals", { signal: signal ?? null, method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, ...budgetMinor === void 0 ? {} : { budgetMinor } }) });
|
|
478
|
+
}
|
|
479
|
+
rentGpu(input, idempotencyKey, signal) {
|
|
480
|
+
return this.request("/rentals", { signal: signal ?? null, method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify(input) });
|
|
481
|
+
}
|
|
482
|
+
rental(id, signal) {
|
|
483
|
+
return this.request(`/rentals/${encodeURIComponent(id)}`, signal ? { signal } : {});
|
|
484
|
+
}
|
|
485
|
+
deployments() {
|
|
486
|
+
return this.request("/deployments");
|
|
487
|
+
}
|
|
488
|
+
deployment(id, signal) {
|
|
489
|
+
return this.request(`/deployments/${encodeURIComponent(id)}`, signal ? { signal } : {});
|
|
490
|
+
}
|
|
491
|
+
credentials(id) {
|
|
492
|
+
return this.request(`/deployments/${encodeURIComponent(id)}/credentials/reveal`, { method: "POST" });
|
|
493
|
+
}
|
|
494
|
+
terminate(id, signal) {
|
|
495
|
+
return this.request(`/deployments/${encodeURIComponent(id)}/terminate`, { signal: signal ?? null, method: "POST", body: JSON.stringify({ reason: "USER_REQUESTED" }) });
|
|
496
|
+
}
|
|
497
|
+
checkout(offerId, budgetMinor, idempotencyKey) {
|
|
498
|
+
return this.request("/orders/checkout", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ offerId, budgetMinor }) });
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
function describe(status) {
|
|
502
|
+
if (status === 401) return "Invalid or expired API token. Check the saved credential and API origin. Existing-account agents can reconnect with owner approval; do not create a new account to recover a funded balance.";
|
|
503
|
+
if (status === 404) return "Not found.";
|
|
504
|
+
if (status === 429) return "Rate limited. Try again shortly.";
|
|
505
|
+
return `Request failed with status ${status}.`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// apps/cli/src/credentials.ts
|
|
509
|
+
import { constants as constants2, closeSync as closeSync2, fsyncSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
510
|
+
import { join as join3 } from "node:path";
|
|
511
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
512
|
+
|
|
513
|
+
// apps/cli/src/storage.ts
|
|
514
|
+
import { execFileSync } from "node:child_process";
|
|
515
|
+
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
516
|
+
import { homedir } from "node:os";
|
|
517
|
+
import { join as join2 } from "node:path";
|
|
518
|
+
var isWindows = () => process.platform === "win32";
|
|
519
|
+
var credentialDirectory = () => join2(homedir(), ".ukiyo");
|
|
520
|
+
var WINDOWS_ACL = `
|
|
521
|
+
$ErrorActionPreference = 'Stop'
|
|
522
|
+
$env:PSModulePath = Join-Path $PSHOME 'Modules'
|
|
523
|
+
$p = $env:UKIYO_STORAGE_PATH
|
|
524
|
+
$item = Get-Item -LiteralPath $p -Force
|
|
525
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse point' }
|
|
526
|
+
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
527
|
+
$acl = Get-Acl -LiteralPath $p
|
|
528
|
+
if ($env:UKIYO_STORAGE_ACTION -eq 'protect') {
|
|
529
|
+
if ($item.PSIsContainer) {
|
|
530
|
+
$acl = New-Object Security.AccessControl.DirectorySecurity
|
|
531
|
+
$inherit = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
532
|
+
} else {
|
|
533
|
+
$acl = New-Object Security.AccessControl.FileSecurity
|
|
534
|
+
$inherit = [Security.AccessControl.InheritanceFlags]::None
|
|
535
|
+
}
|
|
536
|
+
$acl.SetOwner($sid)
|
|
537
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
538
|
+
$rule = New-Object Security.AccessControl.FileSystemAccessRule($sid, [Security.AccessControl.FileSystemRights]::FullControl, $inherit, [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)
|
|
539
|
+
$acl.AddAccessRule($rule)
|
|
540
|
+
Set-Acl -LiteralPath $p -AclObject $acl
|
|
541
|
+
$acl = Get-Acl -LiteralPath $p
|
|
542
|
+
}
|
|
543
|
+
if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or -not $acl.AreAccessRulesProtected) { throw 'Owner or inheritance' }
|
|
544
|
+
$own = $false
|
|
545
|
+
foreach ($r in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
|
|
546
|
+
if ($r.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow) {
|
|
547
|
+
if ($r.IdentityReference.Value -ne $sid.Value) { throw 'Other principal has access' }
|
|
548
|
+
if (($r.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq [Security.AccessControl.FileSystemRights]::FullControl) { $own = $true }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (-not $own) { throw 'Missing owner access' }
|
|
552
|
+
exit 0
|
|
553
|
+
`;
|
|
554
|
+
function windowsAcl(path, action) {
|
|
555
|
+
try {
|
|
556
|
+
const env = { UKIYO_STORAGE_PATH: path, UKIYO_STORAGE_ACTION: action };
|
|
557
|
+
for (const name of ["SystemRoot", "WINDIR", "USERPROFILE", "LOCALAPPDATA", "APPDATA", "PATH", "TEMP", "TMP", "PSModulePath", "PSModuleAnalysisCachePath", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData", "ALLUSERSPROFILE", "USERDOMAIN", "USERNAME", "COMPUTERNAME", "ComSpec", "OS", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER", "NUMBER_OF_PROCESSORS"]) if (process.env[name]) env[name] = process.env[name];
|
|
558
|
+
env.PSModulePath = join2(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "Modules");
|
|
559
|
+
execFileSync(
|
|
560
|
+
join2(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
561
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(WINDOWS_ACL, "utf16le").toString("base64")],
|
|
562
|
+
{ env, stdio: ["ignore", "pipe", "pipe"], timeout: 3e4, windowsHide: true }
|
|
563
|
+
);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
throw new Error(`Ukiyo storage requires user-only Windows access controls (ACLs), an owned regular path, and Windows PowerShell (${error.code === "ETIMEDOUT" ? "access-control check timed out" : "access-control check failed"}). No credential was exposed.`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function validateStorageStat(stat, dir) {
|
|
569
|
+
if ((dir ? !stat.isDirectory() : !stat.isFile()) || stat.isSymbolicLink()) throw new Error("Ukiyo credential storage must be a regular owned path, not a symlink.");
|
|
570
|
+
if (!isWindows() && ((stat.mode & 63) !== 0 || process.getuid && stat.uid !== process.getuid()))
|
|
571
|
+
throw new Error("Ukiyo credential storage must be owned by you, private (0700 directory / 0600 file), and not a symlink.");
|
|
572
|
+
}
|
|
573
|
+
function validateStoragePath(path, dir) {
|
|
574
|
+
validateStorageStat(lstatSync(path), dir);
|
|
575
|
+
if (isWindows()) windowsAcl(path, "validate");
|
|
576
|
+
}
|
|
577
|
+
function ensureCredentialDirectory() {
|
|
578
|
+
const path = credentialDirectory();
|
|
579
|
+
let created = false;
|
|
580
|
+
try {
|
|
581
|
+
mkdirSync(path, isWindows() ? {} : { mode: 448 });
|
|
582
|
+
created = true;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (error.code !== "EEXIST") throw error;
|
|
585
|
+
}
|
|
586
|
+
validateStorageStat(lstatSync(path), true);
|
|
587
|
+
if (isWindows()) windowsAcl(path, created ? "protect" : "validate");
|
|
588
|
+
return path;
|
|
589
|
+
}
|
|
590
|
+
function openPrivateFile(path, flags, create = false) {
|
|
591
|
+
const noFollow = isWindows() ? 0 : constants.O_NOFOLLOW;
|
|
592
|
+
let fd;
|
|
593
|
+
if (create) fd = openSync(path, flags | constants.O_CREAT | constants.O_EXCL | noFollow, isWindows() ? void 0 : 384);
|
|
594
|
+
else {
|
|
595
|
+
validateStoragePath(path, false);
|
|
596
|
+
fd = openSync(path, flags | noFollow);
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
validateStorageStat(fstatSync(fd), false);
|
|
600
|
+
if (isWindows()) windowsAcl(path, create ? "protect" : "validate");
|
|
601
|
+
return fd;
|
|
602
|
+
} catch (error) {
|
|
603
|
+
closeSync(fd);
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// apps/cli/src/credentials.ts
|
|
609
|
+
var DEFAULT_API_URL = "https://pay.u-kiyo.ai";
|
|
610
|
+
function apiOrigin(value = DEFAULT_API_URL) {
|
|
611
|
+
const url = new URL(value);
|
|
612
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || !(url.protocol === "https:" || url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) {
|
|
613
|
+
throw new Error("Use an HTTPS API origin (HTTP is allowed only on loopback for local testing).");
|
|
614
|
+
}
|
|
615
|
+
return url.origin;
|
|
616
|
+
}
|
|
617
|
+
var directory = credentialDirectory;
|
|
618
|
+
var filename = () => join3(directory(), "credentials.json");
|
|
619
|
+
function savedCredential(baseUrl) {
|
|
620
|
+
try {
|
|
621
|
+
validateStoragePath(directory(), true);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (error.code === "ENOENT") return void 0;
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
let fd;
|
|
627
|
+
try {
|
|
628
|
+
fd = openPrivateFile(filename(), constants2.O_RDONLY);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (error.code === "ENOENT") return void 0;
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
const data = JSON.parse(readFileSync(fd, "utf8"));
|
|
635
|
+
if (data.apiUrl !== apiOrigin(baseUrl)) throw new Error("Saved credential belongs to a different API origin.");
|
|
636
|
+
if (typeof data.token !== "string" || !/^uk_[a-f0-9]{12}\.[A-Za-z0-9_-]{32}$/.test(data.token)) throw new Error("Invalid saved Ukiyo credential.");
|
|
637
|
+
return data.token;
|
|
638
|
+
} finally {
|
|
639
|
+
closeSync2(fd);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function signupAgent(limits, baseUrl = DEFAULT_API_URL, fetcher = fetch, acceptedTermsVersion) {
|
|
643
|
+
const apiUrl = apiOrigin(baseUrl);
|
|
644
|
+
if (process.env.UKIYO_API_TOKEN) throw new Error("Unset UKIYO_API_TOKEN before signup; signup creates a new account, never attaches to an existing one.");
|
|
645
|
+
ensureCredentialDirectory();
|
|
646
|
+
const fd = openPrivateFile(filename(), constants2.O_WRONLY, true);
|
|
647
|
+
let stored = false;
|
|
648
|
+
try {
|
|
649
|
+
let agreedTerms;
|
|
650
|
+
if (acceptedTermsVersion !== void 0) {
|
|
651
|
+
const response2 = await fetcher(`${apiUrl}/api/v1/terms`, { redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
652
|
+
const body2 = await response2.json();
|
|
653
|
+
if (!response2.ok) throw new ApiError(response2.status, body2?.error?.code ?? "TERMS_UNAVAILABLE", body2?.error?.message ?? "Approved Terms are unavailable.", body2);
|
|
654
|
+
const document = body2?.data;
|
|
655
|
+
if (!document || typeof document.version !== "string" || !/^https:\/\//.test(document.url ?? "") || !/^[a-f0-9]{64}$/.test(document.sha256 ?? "")) throw new ApiError(0, "TERMS_UNAVAILABLE", "Approved Terms are not configured. Signup was not attempted.", null);
|
|
656
|
+
if (document.version !== acceptedTermsVersion) throw new ApiError(0, "TERMS_VERSION_MISMATCH", "Review GET /api/v1/terms and explicitly supply its current version with --accept-terms. Signup was not attempted.", null);
|
|
657
|
+
agreedTerms = { version: document.version, sha256: document.sha256 };
|
|
658
|
+
}
|
|
659
|
+
const response = await fetcher(`${apiUrl}/api/v1/signup`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
redirect: "error",
|
|
662
|
+
signal: AbortSignal.timeout(15e3),
|
|
663
|
+
headers: { "content-type": "application/json", "idempotency-key": randomUUID2() },
|
|
664
|
+
body: JSON.stringify({ ...limits, ...agreedTerms ? { termsAcceptance: { ...agreedTerms, accepted: true } } : {} })
|
|
665
|
+
});
|
|
666
|
+
const body = await response.json();
|
|
667
|
+
if (!response.ok) throw new ApiError(response.status, body?.error?.code ?? "SIGNUP_FAILED", body?.error?.message ?? "Signup failed.", body);
|
|
668
|
+
const data = body.data;
|
|
669
|
+
if (data?.key?.kind !== "AGENT" || !/^uk_[a-f0-9]{12}\.[A-Za-z0-9_-]{32}$/.test(data?.token ?? "") || JSON.stringify([...data?.key?.scopes ?? []].sort()) !== JSON.stringify(["billing", "manage", "read", "rent"])) throw new Error("Unexpected signup credential.");
|
|
670
|
+
writeFileSync(fd, JSON.stringify({ apiUrl, token: data.token, keyId: data.key.id }) + "\n");
|
|
671
|
+
fsyncSync(fd);
|
|
672
|
+
stored = true;
|
|
673
|
+
const me = await fetcher(`${apiUrl}/api/v1/me`, { redirect: "error", signal: AbortSignal.timeout(15e3), headers: { authorization: `Bearer ${data.token}` } });
|
|
674
|
+
if (!me.ok) throw new Error("Credential saved, but verification failed. Retry ukiyo me --json; do not sign up again.");
|
|
675
|
+
const identity = (await me.json()).data;
|
|
676
|
+
if (identity?.user?.id !== data.account.id || identity?.key?.id !== data.key.id || identity?.key?.kind !== "AGENT") throw new Error("Saved credential identity did not match signup.");
|
|
677
|
+
return { account: data.account, key: data.key, credentialFile: filename(), ...agreedTerms ? { termsAcceptance: agreedTerms } : {} };
|
|
678
|
+
} finally {
|
|
679
|
+
closeSync2(fd);
|
|
680
|
+
if (!stored) unlinkSync(filename());
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// apps/cli/src/output.ts
|
|
685
|
+
function redact(text, secrets = []) {
|
|
686
|
+
let result = text;
|
|
687
|
+
for (const secret of secrets) if (secret) result = result.split(secret).join("[REDACTED]");
|
|
688
|
+
return result.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED PRIVATE KEY]").replace(/uk_[a-f0-9]{12}\.[A-Za-z0-9_-]{32,}/g, "[REDACTED TOKEN]");
|
|
689
|
+
}
|
|
690
|
+
function safeOutput(value) {
|
|
691
|
+
if (typeof value === "string") return redact(value, [process.env.UKIYO_API_TOKEN ?? ""]);
|
|
692
|
+
if (Array.isArray(value)) return value.map(safeOutput);
|
|
693
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, /^(?:privateKey|private_key|token|secret|password|authorization)$/i.test(key) ? "[REDACTED]" : safeOutput(item)]));
|
|
694
|
+
return value;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// apps/mcp/src/server.ts
|
|
698
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
699
|
+
import { z } from "zod";
|
|
700
|
+
var DEFAULT_API_URL2 = "https://pay.u-kiyo.ai";
|
|
701
|
+
var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/offers"]);
|
|
702
|
+
function createApiCall(env = process.env, fetcher = fetch) {
|
|
703
|
+
return async (path, init) => {
|
|
704
|
+
const url = env.UKIYO_API_URL ?? DEFAULT_API_URL2;
|
|
705
|
+
const token = env.UKIYO_API_TOKEN ?? (env === process.env ? savedCredential(apiOrigin(url)) : void 0);
|
|
706
|
+
if (!token && !PUBLIC_PATHS.has(path)) throw new Error("UKIYO_API_TOKEN is required: run signup_account or ukiyo signup for headless signup. Telegram is an optional existing-owner account route.");
|
|
707
|
+
const response = await fetcher(`${url.replace(/\/+$/, "")}/api/v1${path}`, {
|
|
708
|
+
...init,
|
|
709
|
+
redirect: "error",
|
|
710
|
+
signal: AbortSignal.timeout(3e4),
|
|
711
|
+
headers: {
|
|
712
|
+
...token ? { authorization: `Bearer ${token}` } : {},
|
|
713
|
+
// A bodyless POST must not claim application/json; the server rejects it.
|
|
714
|
+
...init?.body === void 0 ? {} : { "content-type": "application/json" },
|
|
715
|
+
...init?.headers
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
const text = await response.text();
|
|
719
|
+
if (!response.ok) {
|
|
720
|
+
let body;
|
|
721
|
+
try {
|
|
722
|
+
body = JSON.parse(text || "{}");
|
|
723
|
+
} catch {
|
|
724
|
+
throw new ApiError(response.status, "INVALID_RESPONSE", "API returned invalid JSON", null);
|
|
725
|
+
}
|
|
726
|
+
throw new ApiError(response.status, body?.error?.code ?? `HTTP_${response.status}`, body?.error?.message ?? text, body);
|
|
727
|
+
}
|
|
728
|
+
return text;
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
var deploymentId = z.string().uuid();
|
|
732
|
+
function registerTools(server, call) {
|
|
733
|
+
const reply = async (path, init) => {
|
|
734
|
+
try {
|
|
735
|
+
const result = await call(path, init);
|
|
736
|
+
return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] };
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (error instanceof ApiError) return { isError: true, content: [{ type: "text", text: JSON.stringify(safeOutput({ error: { code: error.code, status: error.status, message: error.message, requestId: error.requestId }, details: error.body })) }] };
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
server.tool("list_compute_offers", "List currently available Ukiyo compute", {}, () => reply("/offers"));
|
|
743
|
+
server.tool("list_deployments", "List the authenticated user's deployments", {}, () => reply("/deployments"));
|
|
744
|
+
server.tool("get_deployment_status", "Get one deployment", { deploymentId }, ({ deploymentId: id }) => reply(`/deployments/${encodeURIComponent(id)}`));
|
|
745
|
+
server.tool(
|
|
746
|
+
"get_deployment_credentials",
|
|
747
|
+
"Reveal SSH access credentials for one active deployment. Returns a private key, so the result must be handled as a secret: never echo it into a shared transcript, log, or file the user has not asked for. Each call is audited.",
|
|
748
|
+
{ deploymentId },
|
|
749
|
+
({ deploymentId: id }) => reply(`/deployments/${encodeURIComponent(id)}/credentials/reveal`, { method: "POST" })
|
|
750
|
+
);
|
|
751
|
+
server.tool(
|
|
752
|
+
"terminate_deployment",
|
|
753
|
+
"Terminate a deployment. This is irreversible and destroys instance data. Confirm with the user before calling. Listed-runtime rentals return the final usage charge; legacy prepaid rentals forfeit remaining runtime.",
|
|
754
|
+
{ deploymentId },
|
|
755
|
+
({ deploymentId: id }) => reply(`/deployments/${encodeURIComponent(id)}/terminate`, { method: "POST", body: JSON.stringify({ reason: "USER_REQUESTED" }) })
|
|
756
|
+
);
|
|
757
|
+
const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(safeOutput(value)) }] });
|
|
758
|
+
const action = async (fn) => {
|
|
759
|
+
try {
|
|
760
|
+
return json(await fn());
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof ApiError) return { isError: true, ...json({ error: { code: error.code, status: error.status, message: error.message, requestId: error.requestId }, details: error.body }) };
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
const accountClient = () => {
|
|
767
|
+
const baseUrl = apiOrigin(process.env.UKIYO_API_URL);
|
|
768
|
+
const token = process.env.UKIYO_API_TOKEN ?? savedCredential(baseUrl);
|
|
769
|
+
return new UkiyoClient({ baseUrl, ...token ? { token } : {} });
|
|
770
|
+
};
|
|
771
|
+
const key = z.string().min(1).max(128);
|
|
772
|
+
server.tool("signup_account", "Create a headless account after explicitly accepting the current Terms URL/version from GET /api/v1/terms. Stores a scoped credential securely locally; never returns the token. No browser or Telegram required.", {
|
|
773
|
+
maxPerRentalMinor: z.number().int().positive(),
|
|
774
|
+
budgetLimitMinor: z.number().int().positive(),
|
|
775
|
+
maxConcurrentDeployments: z.number().int().positive(),
|
|
776
|
+
acceptedTermsVersion: z.string().min(1),
|
|
777
|
+
acceptTerms: z.literal(true)
|
|
778
|
+
}, async ({ acceptedTermsVersion, acceptTerms, ...limits }) => action(() => signupAgent(limits, apiOrigin(process.env.UKIYO_API_URL), fetch, acceptedTermsVersion)));
|
|
779
|
+
server.tool("get_account_balance", "Read available account credit and key limits", {}, () => reply("/billing/balance"));
|
|
780
|
+
server.tool("rent_gpu", "Spends account credit to rent a GPU type; Ukiyo selects the machine. Supply a stable idempotencyKey and reuse it for retries. Retain rentalId/deploymentId; timeout never means create another rental.", {
|
|
781
|
+
gpuModel: z.string().min(1),
|
|
782
|
+
gpuCount: z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).default(1),
|
|
783
|
+
maxHourlyPriceMinor: z.number().int().positive().optional(),
|
|
784
|
+
budgetMinor: z.number().int().positive().optional(),
|
|
785
|
+
idempotencyKey: key
|
|
786
|
+
}, ({ idempotencyKey, ...input }) => reply("/rentals", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify(input) }));
|
|
787
|
+
server.tool("get_rental_status", "Read the existing rental and deployment IDs; wait for ACTIVE, never start a replacement on a timeout.", { rentalId: deploymentId }, ({ rentalId }) => reply(`/rentals/${encodeURIComponent(rentalId)}`));
|
|
788
|
+
server.tool("topup_account_with_card", "Create a hosted card funding link, not a rental. Reuse the caller-supplied idempotencyKey and amount on retries.", { amountMinor: z.number().int().positive(), idempotencyKey: key }, ({ amountMinor, idempotencyKey }) => reply("/billing/topups", { method: "POST", headers: { "idempotency-key": idempotencyKey }, body: JSON.stringify({ amountMinor }) }));
|
|
789
|
+
server.tool("topup_account_with_wallet", "Fund account credit using the configured wallet. Calling authorizes current API Terms consent and spending within local wallet caps. Reuse idempotencyKey and amount; pending means do not pay again.", { amountMinor: z.number().int().positive(), method: z.enum(["base", "solana", "tempo"]).optional(), idempotencyKey: key }, async ({ amountMinor, method, idempotencyKey }) => {
|
|
790
|
+
if (!process.env.UKIYO_MACHINE_CONFIG) throw new Error("Local machine wallet/cap configuration is not enabled");
|
|
791
|
+
return action(() => accountClient().topupRails(amountMinor, idempotencyKey, method));
|
|
792
|
+
});
|
|
793
|
+
server.tool("get_topup_status", "Poll existing funding; settlementPending means do not pay again.", { topupId: deploymentId }, ({ topupId }) => reply(`/billing/machine-topups/${encodeURIComponent(topupId)}`));
|
|
794
|
+
const machine = async () => {
|
|
795
|
+
if (!process.env.UKIYO_MACHINE_CONFIG) throw new Error("Local machine wallet/cap configuration is not enabled");
|
|
796
|
+
return loadMachineClient(process.env.UKIYO_MACHINE_CONFIG);
|
|
797
|
+
};
|
|
798
|
+
server.tool("purchase_compute_with_wallet", "Purchase a bounded GPU block or job without prior signup. Uses the locally configured wallet, immutable quote validation and persistent spend caps. Supply explicit current Terms and Privacy acceptance.", { purchaseId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/), request: z.record(z.string(), z.unknown()), serviceCredit: z.boolean().default(false) }, async ({ purchaseId, request, serviceCredit }) => ({ content: [{ type: "text", text: JSON.stringify(await (await machine()).purchase(purchaseId, request, serviceCredit)) }] }));
|
|
799
|
+
for (const action2 of ["status", "terminate"]) server.tool(`machine_purchase_${action2}`, action2 === "status" ? "Recover the saved wallet purchase status and output. May return SSH access secrets; never echo secrets to shared transcripts or logs." : "Terminate the saved wallet purchase and trigger verified provider cleanup.", { purchaseId: z.string() }, async ({ purchaseId }) => ({ content: [{ type: "text", text: JSON.stringify(await (await machine()).action(purchaseId, action2)) }] }));
|
|
800
|
+
return server;
|
|
801
|
+
}
|
|
802
|
+
function buildServer(call) {
|
|
803
|
+
return registerTools(new McpServer({ name: "ukiyo", version: "0.2.1" }), call);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// apps/mcp/src/main.ts
|
|
807
|
+
await buildServer(createApiCall()).connect(new StdioServerTransport());
|