@tokagent/tokagentos 2.0.24 → 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.
@@ -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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "generatedAt": "2026-05-19T21:32:47.891Z",
3
+ "generatedAt": "2026-05-25T10:00:27.709Z",
4
4
  "repoUrl": "https://github.com/elizaos/eliza",
5
5
  "templates": [
6
6
  {