@tokagent/tokagentos 2.0.23 → 2.0.29
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/package.json +1 -1
- package/scaffold-patches/packages/app-core/src/api/automations-compat-routes.ts +924 -0
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/package.json +1 -1
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/__tests__/routes/estimate-routes.test.ts +5 -2
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/app.js +896 -19
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/index.html +280 -94
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/style.css +969 -235
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/keys-routes.ts +170 -0
- package/templates-manifest.json +1 -1
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Route, RouteRequest, RouteResponse, IAgentRuntime } from "@elizaos/core";
|
|
15
15
|
import type { IncomingMessage } from "node:http";
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import process from "node:process";
|
|
16
19
|
import {
|
|
17
20
|
mintApiKey,
|
|
18
21
|
listApiKeys,
|
|
@@ -184,6 +187,149 @@ async function handleRevokeKey(
|
|
|
184
187
|
}
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// POST /v1/keys/install — write BILLING_CHAT_KEY to project .env
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
//
|
|
194
|
+
// LOCAL ONLY. This endpoint runs on the user's local agent (whether it's
|
|
195
|
+
// configured as billing client or billing server) and:
|
|
196
|
+
// 1. Validates the request body has a syntactically valid `sk-ai-*` key
|
|
197
|
+
// 2. Atomically upserts `BILLING_CHAT_KEY=<key>` into `<cwd>/.env`
|
|
198
|
+
// (preserving all other entries; existing commented `# BILLING_CHAT_KEY`
|
|
199
|
+
// lines are replaced in place rather than duplicated)
|
|
200
|
+
// 3. Mirrors the new value into process.env immediately so in-flight
|
|
201
|
+
// chat calls pick it up without waiting for the restart
|
|
202
|
+
//
|
|
203
|
+
// The restart itself is DELEGATED to the existing `POST /api/restart`
|
|
204
|
+
// endpoint — the dashboard calls this install endpoint first, and then
|
|
205
|
+
// the restart endpoint second. Splitting them avoids duplicating restart
|
|
206
|
+
// strategy logic across runners (dev-ui in-process bounce, prod CLI
|
|
207
|
+
// supervisor catching exit 75, etc.) and keeps this route a pure
|
|
208
|
+
// "write the file" operation.
|
|
209
|
+
//
|
|
210
|
+
// AUTH: requires the same authenticated identity as the rest of /v1/keys/*
|
|
211
|
+
// (SIWE session OR existing API key). Format-validates `sk-ai-...` but does
|
|
212
|
+
// not verify the key was minted by this user — anyone with shell access to
|
|
213
|
+
// the user's machine could already edit .env directly, so the auth check
|
|
214
|
+
// is meant to guard against trivial CSRF, not a malicious LAN attacker.
|
|
215
|
+
const SK_AI_KEY_RE = /^sk-ai-[A-Za-z0-9_-]{16,128}$/;
|
|
216
|
+
|
|
217
|
+
async function readIfExists(filePath: string): Promise<string | null> {
|
|
218
|
+
try {
|
|
219
|
+
return await fs.readFile(filePath, "utf8");
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Atomically upsert `KEY=VALUE` in a project-root .env file.
|
|
228
|
+
*
|
|
229
|
+
* - If the key already exists on a line (commented or not), replace that
|
|
230
|
+
* line with the new uncommented `KEY=VALUE`.
|
|
231
|
+
* - Otherwise append `KEY=VALUE` to the end (with one preceding blank line
|
|
232
|
+
* if the file ends with non-empty content).
|
|
233
|
+
*
|
|
234
|
+
* Atomicity: write to `<filePath>.tmp` then rename. The rename is atomic
|
|
235
|
+
* on POSIX. We do NOT keep a `.bak` for the project .env because users
|
|
236
|
+
* version-control their .env templates separately and the .env itself is
|
|
237
|
+
* gitignored — a `.bak` would just be visual noise.
|
|
238
|
+
*
|
|
239
|
+
* Values are written verbatim (no quoting). sk-ai-* keys are URL-safe
|
|
240
|
+
* base64 (`/^sk-ai-[A-Za-z0-9_-]+$/`) so they never need quoting; callers
|
|
241
|
+
* MUST validate before invoking this function.
|
|
242
|
+
*/
|
|
243
|
+
async function upsertDotenvLine(
|
|
244
|
+
filePath: string,
|
|
245
|
+
key: string,
|
|
246
|
+
value: string,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const existing = (await readIfExists(filePath)) ?? "";
|
|
249
|
+
const lines = existing.length === 0 ? [] : existing.split(/\r?\n/);
|
|
250
|
+
// dotenv-style split leaves a trailing empty element for files ending in
|
|
251
|
+
// newline. Strip it so we can manage trailing newlines explicitly.
|
|
252
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
253
|
+
lines.pop();
|
|
254
|
+
}
|
|
255
|
+
const re = new RegExp(`^\\s*#?\\s*${key.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\s*=`);
|
|
256
|
+
let updatedAt = -1;
|
|
257
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
258
|
+
if (re.test(lines[i] ?? "")) {
|
|
259
|
+
lines[i] = `${key}=${value}`;
|
|
260
|
+
updatedAt = i;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (updatedAt < 0) {
|
|
265
|
+
if (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() !== "") {
|
|
266
|
+
lines.push("");
|
|
267
|
+
}
|
|
268
|
+
lines.push(`${key}=${value}`);
|
|
269
|
+
}
|
|
270
|
+
const nextContents = `${lines.join("\n")}\n`;
|
|
271
|
+
const tmp = `${filePath}.tmp`;
|
|
272
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
273
|
+
const handle = await fs.open(tmp, "w", 0o600);
|
|
274
|
+
try {
|
|
275
|
+
await handle.writeFile(nextContents, "utf8");
|
|
276
|
+
await handle.sync();
|
|
277
|
+
} finally {
|
|
278
|
+
await handle.close();
|
|
279
|
+
}
|
|
280
|
+
await fs.rename(tmp, filePath);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function handleInstallKey(
|
|
284
|
+
req: RouteRequest,
|
|
285
|
+
res: RouteResponse,
|
|
286
|
+
_runtime: IAgentRuntime,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
// Auth check. In client-mode, `resolveBillingIdentity` returns null (no
|
|
289
|
+
// local DB / authSecret) — but the local user is the one running the
|
|
290
|
+
// server, and we serve this from localhost only, so we accept the request
|
|
291
|
+
// unconditionally in client-mode as long as the body is well-formed.
|
|
292
|
+
// In server-mode, require a valid identity.
|
|
293
|
+
const identity = await resolveBillingIdentity(toIncomingMessage(req));
|
|
294
|
+
const billingState = getBillingState();
|
|
295
|
+
const isClientMode = billingState.config.billingMode === "client";
|
|
296
|
+
if (!identity && !isClientMode) {
|
|
297
|
+
res.status(401).json({ error: "Authentication required." });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const body = req.body as Record<string, unknown> | undefined;
|
|
302
|
+
const key = typeof body?.["key"] === "string" ? body["key"].trim() : "";
|
|
303
|
+
if (!SK_AI_KEY_RE.test(key)) {
|
|
304
|
+
res.status(400).json({
|
|
305
|
+
error: "Invalid key format — expected sk-ai-... (16+ url-safe chars).",
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const envPath = path.join(process.cwd(), ".env");
|
|
311
|
+
try {
|
|
312
|
+
await upsertDotenvLine(envPath, "BILLING_CHAT_KEY", key);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
315
|
+
res.status(500).json({ error: `Failed to write .env: ${message}` });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Update in-flight env so subsequent chat calls work even before restart.
|
|
320
|
+
// configureBillingChatMirror() at startup mirrors BILLING_CHAT_KEY → OPENAI_API_KEY,
|
|
321
|
+
// but the OpenAI plugin may cache its key at init — restart is still the safe
|
|
322
|
+
// path. The dashboard calls POST /api/restart after this returns 200.
|
|
323
|
+
process.env["BILLING_CHAT_KEY"] = key;
|
|
324
|
+
process.env["OPENAI_API_KEY"] = key;
|
|
325
|
+
|
|
326
|
+
res.status(200).json({
|
|
327
|
+
ok: true,
|
|
328
|
+
envPath,
|
|
329
|
+
message: "Key saved to .env. Call POST /api/restart to apply.",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
187
333
|
// ---------------------------------------------------------------------------
|
|
188
334
|
// Route definitions
|
|
189
335
|
// ---------------------------------------------------------------------------
|
|
@@ -197,6 +343,18 @@ export const keysRoutes: Route[] = [
|
|
|
197
343
|
name: "billing-keys-mint",
|
|
198
344
|
handler: handleMintKey,
|
|
199
345
|
},
|
|
346
|
+
// MUST be registered BEFORE the /v1/keys/:id DELETE route in the array
|
|
347
|
+
// (routes are matched in registration order on rawPath: true with
|
|
348
|
+
// params). `install` is a string literal that could otherwise match the
|
|
349
|
+
// `:id` param and route the install POST through revoke handling.
|
|
350
|
+
{
|
|
351
|
+
type: "POST",
|
|
352
|
+
path: "/v1/keys/install",
|
|
353
|
+
rawPath: true,
|
|
354
|
+
public: true,
|
|
355
|
+
name: "billing-keys-install",
|
|
356
|
+
handler: handleInstallKey,
|
|
357
|
+
},
|
|
200
358
|
{
|
|
201
359
|
type: "GET",
|
|
202
360
|
path: "/v1/keys",
|
|
@@ -235,6 +393,18 @@ function clientKeysRoutes(): Route[] {
|
|
|
235
393
|
);
|
|
236
394
|
},
|
|
237
395
|
},
|
|
396
|
+
// /v1/keys/install is LOCAL on both modes (writes the local agent's
|
|
397
|
+
// own .env), so it uses the same direct handler as server-mode.
|
|
398
|
+
// Must precede /v1/keys/:id in this array for the same param-matching
|
|
399
|
+
// reason explained on the server-mode array.
|
|
400
|
+
{
|
|
401
|
+
type: "POST",
|
|
402
|
+
path: "/v1/keys/install",
|
|
403
|
+
rawPath: true,
|
|
404
|
+
public: true,
|
|
405
|
+
name: "billing-keys-install",
|
|
406
|
+
handler: handleInstallKey,
|
|
407
|
+
},
|
|
238
408
|
{
|
|
239
409
|
type: "GET",
|
|
240
410
|
path: "/v1/keys",
|