diff --git a/app/api/cron/cleanup-stuck-provisioning/route.ts b/app/api/cron/cleanup-stuck-provisioning/route.ts
new file mode 100644
index 000000000..c22fac959
--- /dev/null
+++ b/app/api/cron/cleanup-stuck-provisioning/route.ts
@@ -0,0 +1,166 @@
+/**
+ * Cleanup Stuck Provisioning Cron
+ *
+ * Detects and recovers agents that are stuck in "provisioning" status with no
+ * active job to drive them forward. This happens when:
+ *
+ * 1. A container crashes while the agent is running, and something (e.g.
+ * the Next.js sync provision path) sets status = 'provisioning' but
+ * never creates a jobs-table record.
+ * 2. A provision job is enqueued but the worker invocation dies before it
+ * can claim the record — in this case the job-recovery logic in
+ * process-provisioning-jobs will already handle it, but we add a belt-
+ * and-suspenders check here for the no-job case.
+ *
+ * Criteria for "stuck":
+ * - status = 'provisioning'
+ * - updated_at < NOW() - 10 minutes (well beyond any normal provision time)
+ * - no jobs row in ('pending', 'in_progress') whose data->>'agentId' matches
+ *
+ * Action: set status = 'error', write a descriptive error_message so the user
+ * can see what happened and re-provision.
+ *
+ * Schedule: every 5 minutes ("* /5 * * * *" in vercel.json)
+ * Protected by CRON_SECRET.
+ */
+
+import { and, eq, lt, sql } from "drizzle-orm";
+import { NextRequest, NextResponse } from "next/server";
+import { dbWrite } from "@/db/client";
+import { jobs } from "@/db/schemas/jobs";
+import { miladySandboxes } from "@/db/schemas/milady-sandboxes";
+import { verifyCronSecret } from "@/lib/api/cron-auth";
+import { logger } from "@/lib/utils/logger";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+/** How long an agent must be stuck before we reset it (ms). */
+const STUCK_THRESHOLD_MINUTES = 10;
+
+interface CleanupResult {
+ agentId: string;
+ agentName: string | null;
+ organizationId: string;
+ stuckSinceMinutes: number;
+}
+
+async function handleCleanupStuckProvisioning(request: NextRequest) {
+ try {
+ const authError = verifyCronSecret(request, "[Cleanup Stuck Provisioning]");
+ if (authError) return authError;
+
+ logger.info("[Cleanup Stuck Provisioning] Starting scan");
+
+ const cutoff = new Date(Date.now() - STUCK_THRESHOLD_MINUTES * 60 * 1000);
+
+ /**
+ * Single UPDATE … RETURNING query:
+ *
+ * UPDATE milady_sandboxes
+ * SET status = 'error',
+ * error_message = '...',
+ * updated_at = NOW()
+ * WHERE status = 'provisioning'
+ * AND updated_at < :cutoff
+ * AND NOT EXISTS (
+ * SELECT 1 FROM jobs
+ * WHERE jobs.data->>'agentId' = milady_sandboxes.id::text
+ * AND jobs.status IN ('pending', 'in_progress')
+ * )
+ * RETURNING id, agent_name, organization_id, updated_at
+ *
+ * We run this inside dbWrite so it lands on the primary replica and is
+ * subject to the write path's connection pool.
+ */
+ const stuckAgents = await dbWrite
+ .update(miladySandboxes)
+ .set({
+ status: "error",
+ error_message:
+ "Agent was stuck in provisioning state with no active provisioning job. " +
+ "This usually means a container crashed before the provisioning job could be created, " +
+ "or the job was lost. Please try starting the agent again.",
+ updated_at: new Date(),
+ })
+ .where(
+ and(
+ eq(miladySandboxes.status, "provisioning"),
+ lt(miladySandboxes.updated_at, cutoff),
+ sql`NOT EXISTS (
+ SELECT 1 FROM ${jobs}
+ WHERE ${jobs.data}->>'agentId' = ${miladySandboxes.id}::text
+ AND ${jobs.status} IN ('pending', 'in_progress')
+ )`,
+ ),
+ )
+ .returning({
+ agentId: miladySandboxes.id,
+ agentName: miladySandboxes.agent_name,
+ organizationId: miladySandboxes.organization_id,
+ updatedAt: miladySandboxes.updated_at,
+ });
+
+ const results: CleanupResult[] = stuckAgents.map((row) => ({
+ agentId: row.agentId,
+ agentName: row.agentName,
+ organizationId: row.organizationId,
+ // updatedAt is now the new timestamp; we can't recover the old one here,
+ // but the log message below captures the count.
+ stuckSinceMinutes: STUCK_THRESHOLD_MINUTES, // minimum — actual may be longer
+ }));
+
+ if (results.length > 0) {
+ logger.warn("[Cleanup Stuck Provisioning] Reset stuck agents", {
+ count: results.length,
+ agents: results.map((r) => ({
+ agentId: r.agentId,
+ agentName: r.agentName,
+ organizationId: r.organizationId,
+ })),
+ });
+ } else {
+ logger.info("[Cleanup Stuck Provisioning] No stuck agents found");
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ cleaned: results.length,
+ thresholdMinutes: STUCK_THRESHOLD_MINUTES,
+ timestamp: new Date().toISOString(),
+ agents: results,
+ },
+ });
+ } catch (error) {
+ logger.error(
+ "[Cleanup Stuck Provisioning] Failed:",
+ error instanceof Error ? error.message : String(error),
+ );
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : "Cleanup failed",
+ },
+ { status: 500 },
+ );
+ }
+}
+
+/**
+ * GET /api/cron/cleanup-stuck-provisioning
+ * Cron endpoint — protected by CRON_SECRET (Vercel passes it automatically).
+ */
+export async function GET(request: NextRequest) {
+ return handleCleanupStuckProvisioning(request);
+}
+
+/**
+ * POST /api/cron/cleanup-stuck-provisioning
+ * Manual trigger for testing — same auth requirement.
+ */
+export async function POST(request: NextRequest) {
+ return handleCleanupStuckProvisioning(request);
+}
diff --git a/app/api/stripe/create-checkout-session/route.ts b/app/api/stripe/create-checkout-session/route.ts
index 0d6588c31..e0922c232 100644
--- a/app/api/stripe/create-checkout-session/route.ts
+++ b/app/api/stripe/create-checkout-session/route.ts
@@ -19,6 +19,8 @@ const ALLOWED_ORIGINS = [
process.env.NEXT_PUBLIC_APP_URL,
"http://localhost:3000",
"http://localhost:3001",
+ "https://milady.ai",
+ "https://www.milady.ai",
].filter(Boolean) as string[];
// Configurable currency
diff --git a/app/api/v1/admin/service-pricing/route.ts b/app/api/v1/admin/service-pricing/route.ts
index ce6771ce7..e8b1b7555 100644
--- a/app/api/v1/admin/service-pricing/route.ts
+++ b/app/api/v1/admin/service-pricing/route.ts
@@ -21,7 +21,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { servicePricingRepository } from "@/db/repositories";
+import { servicePricingRepository } from "@/db/repositories/service-pricing";
import { requireAdminWithResponse } from "@/lib/api/admin-auth";
import { invalidateServicePricingCache } from "@/lib/services/proxy/pricing";
import { logger } from "@/lib/utils/logger";
diff --git a/app/api/v1/cron/process-provisioning-jobs/route.ts b/app/api/v1/cron/process-provisioning-jobs/route.ts
index b1aab752b..b74b6e26f 100644
--- a/app/api/v1/cron/process-provisioning-jobs/route.ts
+++ b/app/api/v1/cron/process-provisioning-jobs/route.ts
@@ -1,115 +1,12 @@
-import { timingSafeEqual } from "crypto";
-import { NextRequest, NextResponse } from "next/server";
-import { provisioningJobService } from "@/lib/services/provisioning-jobs";
-import { logger } from "@/lib/utils/logger";
+import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
-export const maxDuration = 120; // Provisioning can take up to ~90s per job
-
-function verifyCronSecret(request: NextRequest): boolean {
- const authHeader = request.headers.get("authorization");
- const cronSecret = process.env.CRON_SECRET;
-
- if (!cronSecret) {
- logger.error("[Provisioning Jobs] CRON_SECRET not configured - rejecting request for security");
- return false;
- }
-
- const providedSecret = authHeader?.replace("Bearer ", "") || "";
- const providedBuffer = Buffer.from(providedSecret, "utf8");
- const secretBuffer = Buffer.from(cronSecret, "utf8");
-
- // Reject immediately on length mismatch — padding to max-length would
- // make timingSafeEqual always compare equal-length buffers but the
- // zero-padded tail leaks nothing useful; however, strict length-equality
- // is the canonical safe pattern and avoids any ambiguity.
- if (providedBuffer.length !== secretBuffer.length) {
- return false;
- }
-
- return timingSafeEqual(providedBuffer, secretBuffer);
-}
-
-/**
- * Process Provisioning Jobs Cron Handler
- *
- * Claims and executes pending provisioning jobs from the `jobs` table.
- * Uses FOR UPDATE SKIP LOCKED pattern (via JobsRepository) to prevent
- * double-processing when multiple cron invocations overlap.
- *
- * Schedule: Every minute (matches deployment-monitor)
- * Batch size: 5 jobs per invocation
- *
- * Also recovers stale jobs (stuck in_progress > 5 minutes) and retries
- * them with exponential backoff.
- */
-async function handleProcessProvisioningJobs(request: NextRequest) {
- try {
- if (!process.env.CRON_SECRET) {
- return NextResponse.json(
- {
- success: false,
- error: "Server configuration error: CRON_SECRET not set",
- },
- { status: 500 },
- );
- }
-
- if (!verifyCronSecret(request)) {
- logger.warn("[Provisioning Jobs] Unauthorized request", {
- ip: request.headers.get("x-forwarded-for"),
- timestamp: new Date().toISOString(),
- });
- return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
- }
-
- logger.info("[Provisioning Jobs] Starting job processing cycle");
-
- const result = await provisioningJobService.processPendingJobs(5);
-
- if (result.claimed > 0) {
- logger.info("[Provisioning Jobs] Processing complete", {
- claimed: result.claimed,
- succeeded: result.succeeded,
- failed: result.failed,
- });
- }
-
- return NextResponse.json({
- success: true,
- data: {
- ...result,
- timestamp: new Date().toISOString(),
- },
- });
- } catch (error) {
- logger.error(
- "[Provisioning Jobs] Failed:",
- error instanceof Error ? error.message : String(error),
- );
-
- return NextResponse.json(
- {
- success: false,
- error: error instanceof Error ? error.message : "Provisioning job processing failed",
- },
- { status: 500 },
- );
- }
-}
-
-/**
- * GET /api/v1/cron/process-provisioning-jobs
- * Protected by CRON_SECRET.
- */
-export async function GET(request: NextRequest) {
- return handleProcessProvisioningJobs(request);
-}
/**
- * POST /api/v1/cron/process-provisioning-jobs
- * Protected by CRON_SECRET (for manual testing).
+ * DISABLED — Provisioning is handled exclusively by the standalone VPS worker.
+ * This route is kept as a no-op to prevent 404s from any lingering cron invocations.
+ * The VPS worker polls the jobs table directly and has SSH access to Docker nodes.
*/
-export async function POST(request: NextRequest) {
- return handleProcessProvisioningJobs(request);
+export async function POST() {
+ return NextResponse.json({ success: true, message: "Provisioning handled by VPS worker", skipped: true });
}
diff --git a/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts b/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts
new file mode 100644
index 000000000..42d646bea
--- /dev/null
+++ b/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts
@@ -0,0 +1,132 @@
+import { NextRequest, NextResponse } from "next/server";
+import { errorToResponse } from "@/lib/api/errors";
+import { requireAuthOrApiKeyWithOrg } from "@/lib/auth";
+import { miladySandboxService } from "@/lib/services/milady-sandbox";
+import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors";
+import { logger } from "@/lib/utils/logger";
+
+export const dynamic = "force-dynamic";
+
+const CORS_METHODS = "GET, POST, OPTIONS";
+
+export function OPTIONS() {
+ return handleCorsOptions(CORS_METHODS);
+}
+
+/**
+ * Proxy handler for both GET and POST wallet requests.
+ *
+ * Incoming URL pattern:
+ * /api/v1/milady/agents/[agentId]/api/wallet/[...path]
+ *
+ * Proxied to the agent at:
+ * {bridge_url}/api/wallet/{path}
+ *
+ * This allows the homepage dashboard (via CloudApiClient) to reach wallet
+ * endpoints on agents running in Docker containers, authenticated by the
+ * cloud API key and authorization-checked against the user's organization.
+ */
+async function proxyToAgent(
+ request: NextRequest,
+ params: Promise<{ agentId: string; path: string[] }>,
+ method: "GET" | "POST",
+): Promise {
+ try {
+ const { user } = await requireAuthOrApiKeyWithOrg(request);
+ const { agentId, path } = await params;
+
+ // Use only the first path segment (e.g. ["steward-policies"] → "steward-policies")
+ // Reject multi-segment paths to prevent path traversal
+ if (path.length !== 1 || path[0].includes("..")) {
+ return applyCorsHeaders(
+ NextResponse.json({ success: false, error: "Invalid wallet path" }, { status: 400 }),
+ CORS_METHODS,
+ );
+ }
+ const walletPath = path[0];
+
+ // Forward validated query string (e.g. ?limit=20 for steward-tx-records)
+ const query = request.nextUrl.search ? request.nextUrl.search.slice(1) : undefined;
+
+ // Read POST body if present (with size limit and content-type check)
+ let body: string | null = null;
+ if (method === "POST") {
+ const contentType = request.headers.get("content-type");
+ if (!contentType?.includes("application/json")) {
+ return applyCorsHeaders(
+ NextResponse.json(
+ { success: false, error: "Content-Type must be application/json" },
+ { status: 400 },
+ ),
+ CORS_METHODS,
+ );
+ }
+ body = await request.text();
+ if (body.length > 1_048_576) {
+ return applyCorsHeaders(
+ NextResponse.json({ success: false, error: "Request body too large" }, { status: 413 }),
+ CORS_METHODS,
+ );
+ }
+ }
+
+ logger.info("[wallet-proxy] Request", {
+ agentId,
+ orgId: user.organization_id,
+ walletPath,
+ method,
+ });
+
+ const agentResponse = await miladySandboxService.proxyWalletRequest(
+ agentId,
+ user.organization_id,
+ walletPath,
+ method,
+ body,
+ query,
+ );
+
+ if (!agentResponse) {
+ logger.warn("[wallet-proxy] Proxy returned null", {
+ agentId,
+ orgId: user.organization_id,
+ walletPath,
+ });
+ return applyCorsHeaders(
+ NextResponse.json(
+ { success: false, error: "Agent is not running or unreachable" },
+ { status: 503 },
+ ),
+ CORS_METHODS,
+ );
+ }
+
+ // Forward status + body from agent response directly
+ const responseBody = await agentResponse.text();
+ const contentType = agentResponse.headers.get("content-type") ?? "application/json";
+
+ return applyCorsHeaders(
+ new Response(responseBody, {
+ status: agentResponse.status,
+ headers: { "Content-Type": contentType },
+ }),
+ CORS_METHODS,
+ );
+ } catch (error) {
+ return applyCorsHeaders(errorToResponse(error), CORS_METHODS);
+ }
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ agentId: string; path: string[] }> },
+) {
+ return proxyToAgent(request, params, "GET");
+}
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ agentId: string; path: string[] }> },
+) {
+ return proxyToAgent(request, params, "POST");
+}
diff --git a/app/api/v1/milady/agents/[agentId]/provision/route.ts b/app/api/v1/milady/agents/[agentId]/provision/route.ts
index fd2cc6bf1..4e56a2a86 100644
--- a/app/api/v1/milady/agents/[agentId]/provision/route.ts
+++ b/app/api/v1/milady/agents/[agentId]/provision/route.ts
@@ -66,7 +66,10 @@ export async function POST(
try {
const { user } = await requireAuthOrApiKeyWithOrg(request);
const { agentId } = await params;
- const sync = request.nextUrl.searchParams.get("sync") === "true";
+ // Always use async job queue — sync provisioning is disabled in production
+ // because the VPS worker handles SSH/Docker operations that can't run in
+ // serverless functions. The sync path remains in code for local dev only.
+ const sync = false;
logger.info("[milady-api] Provision requested", {
agentId,
diff --git a/app/dashboard/billing/page.tsx b/app/dashboard/billing/page.tsx
index 03e296847..2237e15c3 100644
--- a/app/dashboard/billing/page.tsx
+++ b/app/dashboard/billing/page.tsx
@@ -1,7 +1,5 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth";
-import { creditsService } from "@/lib/services/credits";
-import { miladySandboxService } from "@/lib/services/milady-sandbox";
import { BillingPageWrapper } from "@/packages/ui/src/components/billing/billing-page-wrapper";
export const metadata: Metadata = {
@@ -9,47 +7,15 @@ export const metadata: Metadata = {
description: "Add funds and manage your billing",
};
-// Force dynamic rendering since we use server-side auth (cookies)
export const dynamic = "force-dynamic";
-/**
- * Billing page for managing credits and billing information.
- * Displays available credit packs and current credit balance.
- *
- * @param searchParams - Search parameters, including optional `canceled` flag for canceled checkout sessions.
- * @returns The rendered billing page wrapper component.
- */
export default async function BillingPage({
searchParams,
}: {
searchParams: Promise<{ canceled?: string }>;
}) {
const user = await requireAuth();
- const creditPacks = await creditsService.listActiveCreditPacks();
const params = await searchParams;
- // Fetch agent counts for runway estimation (best-effort)
- let runningAgents = 0;
- let idleAgents = 0;
- try {
- if (user.organization_id) {
- const agents = await miladySandboxService.listAgents(user.organization_id);
- runningAgents = agents.filter((a) => a.status === "running").length;
- idleAgents = agents.filter(
- (a) => a.status === "stopped" || a.status === "disconnected",
- ).length;
- }
- } catch {
- // Table may not exist — degrade gracefully
- }
-
- return (
-
- );
+ return ;
}
diff --git a/app/dashboard/milady/agents/[id]/page.tsx b/app/dashboard/milady/agents/[id]/page.tsx
index 7201b9f6e..9697a4166 100644
--- a/app/dashboard/milady/agents/[id]/page.tsx
+++ b/app/dashboard/milady/agents/[id]/page.tsx
@@ -21,6 +21,7 @@ import { adminService } from "@/lib/services/admin";
import { miladySandboxService } from "@/lib/services/milady-sandbox";
import { MiladyAgentActions } from "@/packages/ui/src/components/containers/agent-actions";
import { DockerLogsViewer } from "@/packages/ui/src/components/containers/docker-logs-viewer";
+import { MiladyAgentTabs } from "@/packages/ui/src/components/containers/milady-agent-tabs";
import { MiladyBackupsPanel } from "@/packages/ui/src/components/containers/milady-backups-panel";
import { MiladyConnectButton } from "@/packages/ui/src/components/containers/milady-connect-button";
import { MiladyLogsViewer } from "@/packages/ui/src/components/containers/milady-logs-viewer";
@@ -221,143 +222,148 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) {
- {/* ── Error message ── */}
- {agent.error_message && (
-
-
-
-
- Error ({agent.error_count} occurrence{agent.error_count !== 1 ? "s" : ""})
-
-
{agent.error_message}
-
-
- )}
-
- {/* ── Docker infrastructure (admin) ── */}
- {isAdmin && isDockerBacked && (
-
-
+ {/* ── Tabs: Overview | Wallet | Transactions | Policies ── */}
+
+ {/* ── Overview tab content ── */}
-
-
-
-
- {agent.headscale_ip && (
-
- )}
- {agent.bridge_port && (
-
- )}
- {agent.web_ui_port && (
-
- )}
+ {/* ── Error message ── */}
+ {agent.error_message && (
+
+
+
+
+ Error ({agent.error_count} occurrence{agent.error_count !== 1 ? "s" : ""})
+
+
{agent.error_message}
+
+ )}
- {webUiUrl && (
-
-
- Web UI
-
-
{webUiUrl}
+ {/* ── Docker infrastructure (admin) ── */}
+ {isAdmin && isDockerBacked && (
+
- )}
-
- {/* ── SSH access (admin) ── */}
- {isAdmin && sshCommand && (
-
-
-
-
-
-
- {sshCommand}
-
+
+
+
+
+ {agent.headscale_ip && (
+
+ )}
+ {agent.bridge_port && (
+
+ )}
+ {agent.web_ui_port && (
+
+ )}
- {agent.bridge_port && (
+
+ {webUiUrl && (
+
+
+ Web UI
+
+ {webUiUrl}
+
+ )}
+
+ )}
+
+ {/* ── SSH access (admin) ── */}
+ {isAdmin && sshCommand && (
+
+
+
+
-
+
- {`curl http://${agent.headscale_ip}:${agent.bridge_port}/health`}
+ {sshCommand}
- )}
-
-
- )}
-
- {/* ── Vercel sandbox info (admin) ── */}
- {isAdmin && !isDockerBacked && agent.bridge_url && (
-
-
-
-
- Sandbox Connection
-
-
+ {agent.bridge_port && (
+
+
+
+ {`curl http://${agent.headscale_ip}:${agent.bridge_port}/health`}
+
+
+ )}
+
+
+ )}
-
-
- )}
-
- {/* ── Actions card ── */}
-
-
- {/* ── Backups / history ── */}
-
-
- {/* ── User-facing app logs ── */}
-
-
- {/* ── Admin: Docker Logs ── */}
- {isAdmin && isDockerBacked && agent.container_name && agent.node_id && (
-
+
+
+
+ Sandbox Connection
+
+
+
+
+
+ )}
+
+ {/* ── Actions card ── */}
+
+
+ {/* ── Backups / history ── */}
+
+
+ {/* ── User-facing app logs ── */}
+
- )}
+
+ {/* ── Admin: Docker Logs ── */}
+ {isAdmin && isDockerBacked && agent.container_name && agent.node_id && (
+
+ )}
+
);
}
diff --git a/app/dashboard/milady/page.tsx b/app/dashboard/milady/page.tsx
index 5801abcc6..6fbe20b86 100644
--- a/app/dashboard/milady/page.tsx
+++ b/app/dashboard/milady/page.tsx
@@ -51,9 +51,6 @@ export default async function MiladyDashboardPage() {
Milady Instances
-
- Launch an existing agent into the web app or create a new managed instance.
-
;
+
+interface MiladyWebUiUrlOptions {
+ baseDomain?: string | null;
+ allowExampleFallback?: boolean;
+ path?: string;
+}
+
+function normalizeAgentBaseDomain(baseDomain?: string | null): string | null {
+ if (!baseDomain) {
+ return null;
+ }
+
+ const normalizedDomain = baseDomain
+ .trim()
+ .replace(/^https?:\/\//, "")
+ .replace(/\/.*$/, "")
+ .replace(/\.+$/, "");
+
+ return normalizedDomain || null;
+}
+
+function applyPath(baseUrl: string, path = "/"): string {
+ if (!path || path === "/") {
+ return baseUrl;
+ }
+
+ const url = new URL(baseUrl);
+ const normalizedPath = new URL(path, "https://milady.local");
+
+ url.pathname = normalizedPath.pathname;
+ url.search = normalizedPath.search;
+ url.hash = normalizedPath.hash;
+
+ return url.toString();
+}
+
+export function getMiladyAgentPublicWebUiUrl(
+ sandbox: Pick,
+ options: MiladyWebUiUrlOptions = {},
+): string | null {
+ if (!sandbox.headscale_ip) {
+ return null;
+ }
+
+ const normalizedDomain = normalizeAgentBaseDomain(
+ options.baseDomain ??
+ process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN ??
+ (options.allowExampleFallback ? DEFAULT_AGENT_BASE_DOMAIN : null),
+ );
+ if (!normalizedDomain) {
+ return null;
+ }
+
+ return applyPath(`https://${sandbox.id}.${normalizedDomain}`, options.path);
+}
+
+export function getMiladyAgentDirectWebUiUrl(
+ sandbox: MiladyWebUiTarget,
+ options: Pick = {},
+): string | null {
+ if (!sandbox.headscale_ip) {
+ return null;
+ }
+
+ const port = sandbox.web_ui_port ?? sandbox.bridge_port;
+ if (!port) {
+ return null;
+ }
+
+ return applyPath(`http://${sandbox.headscale_ip}:${port}`, options.path);
+}
+
+export function getPreferredMiladyAgentWebUiUrl(
+ sandbox: MiladyWebUiTarget,
+ options: MiladyWebUiUrlOptions = {},
+): string | null {
+ return (
+ getMiladyAgentPublicWebUiUrl(sandbox, options) ?? getMiladyAgentDirectWebUiUrl(sandbox, options)
+ );
+}
diff --git a/packages/db/repositories/milady-sandboxes.ts b/packages/db/repositories/milady-sandboxes.ts
index 8a976c4a4..544fdffcd 100644
--- a/packages/db/repositories/milady-sandboxes.ts
+++ b/packages/db/repositories/milady-sandboxes.ts
@@ -82,7 +82,10 @@ export class MiladySandboxesRepository {
}
async findRunningSandbox(id: string, orgId: string): Promise {
- const [r] = await dbRead
+ // Use dbWrite (primary) instead of dbRead (replica) to ensure fresh data.
+ // The VPS worker writes bridge_url/status to primary, and read replicas
+ // may lag behind, causing the wallet proxy to return "not running".
+ const [r] = await dbWrite
.select()
.from(miladySandboxes)
.where(
diff --git a/packages/lib/privy-sync.ts b/packages/lib/privy-sync.ts
index 744b45091..83e334919 100644
--- a/packages/lib/privy-sync.ts
+++ b/packages/lib/privy-sync.ts
@@ -7,7 +7,8 @@
* 2. Just-in-time sync (fallback for race conditions)
*/
-import { organizationInvitesRepository, usersRepository } from "@/db/repositories";
+import { organizationInvitesRepository } from "@/db/repositories/organization-invites";
+import { usersRepository } from "@/db/repositories/users";
import { abuseDetectionService, type SignupContext } from "@/lib/services/abuse-detection";
import { apiKeysService } from "@/lib/services/api-keys";
import { charactersService } from "@/lib/services/characters/characters";
diff --git a/packages/lib/security/redirect-validation.ts b/packages/lib/security/redirect-validation.ts
index 1f34f93d3..42ba529ce 100644
--- a/packages/lib/security/redirect-validation.ts
+++ b/packages/lib/security/redirect-validation.ts
@@ -5,6 +5,8 @@ const DEFAULT_PLATFORM_REDIRECT_ORIGINS = [
"http://localhost:3001",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
+ "https://milady.ai",
+ "https://www.milady.ai",
];
function isHttpUrl(url: URL): boolean {
diff --git a/packages/lib/services/milady-sandbox.ts b/packages/lib/services/milady-sandbox.ts
index c1574f233..2c99f3d8f 100644
--- a/packages/lib/services/milady-sandbox.ts
+++ b/packages/lib/services/milady-sandbox.ts
@@ -32,6 +32,10 @@ import { getNeonClient, NeonClientError } from "./neon-client";
import { JOB_TYPES } from "./provisioning-jobs";
import { createSandboxProvider, type SandboxProvider } from "./sandbox-provider";
+/** Shared Neon project used as branch parent for per-agent databases. */
+const NEON_PARENT_PROJECT_ID: string =
+ process.env.NEON_PARENT_PROJECT_ID ?? "snowy-waterfall-29926749"; // env var required in prod
+
export interface CreateAgentParams {
organizationId: string;
userId: string;
@@ -206,10 +210,11 @@ export class MiladySandboxService {
}
if (rec.neon_project_id) {
try {
- await this.cleanupNeon(rec.neon_project_id);
+ await this.cleanupNeon(rec.neon_project_id, rec.neon_branch_id);
} catch (e) {
logger.warn("[milady-sandbox] Neon cleanup failed during delete", {
projectId: rec.neon_project_id,
+ branchId: rec.neon_branch_id,
error: e instanceof Error ? e.message : String(e),
});
return {
@@ -603,6 +608,144 @@ export class MiladySandboxService {
}
}
+ /**
+ * Proxy an HTTP request to the agent's wallet API endpoint.
+ * Used by the cloud backend to forward wallet/steward requests from the dashboard.
+ *
+ * @param agentId - The sandbox record ID
+ * @param orgId - The organization ID (authorization)
+ * @param walletPath - Path after `/api/wallet/`, e.g. "steward-policies"
+ * @param method - HTTP method ("GET" | "POST")
+ * @param body - Optional request body (for POST requests)
+ * @param query - Optional query string (e.g. "limit=20")
+ * @returns The raw fetch Response, or null if the sandbox is not running
+ */
+ // Allowed wallet sub-paths for proxy (prevents path traversal)
+ private static readonly ALLOWED_WALLET_PATHS = new Set([
+ "addresses",
+ "balances",
+ "steward-status",
+ "steward-policies",
+ "steward-tx-records",
+ "steward-pending-approvals",
+ "steward-approve-tx",
+ "steward-deny-tx",
+ ]);
+
+ // Allowed query parameters for wallet proxy
+ private static readonly ALLOWED_QUERY_PARAMS = new Set([
+ "limit",
+ "offset",
+ "cursor",
+ "type",
+ "status",
+ ]);
+
+ async proxyWalletRequest(
+ agentId: string,
+ orgId: string,
+ walletPath: string,
+ method: "GET" | "POST",
+ body?: string | null,
+ query?: string,
+ ): Promise {
+ // Validate wallet path against whitelist (prevents path traversal)
+ if (!MiladySandboxService.ALLOWED_WALLET_PATHS.has(walletPath)) {
+ logger.warn("[milady-sandbox] Rejected wallet proxy: invalid path", {
+ agentId,
+ walletPath,
+ });
+ return new Response(JSON.stringify({ error: "Invalid wallet endpoint" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ // Sanitize query parameters
+ let sanitizedQuery = "";
+ if (query) {
+ const params = new URLSearchParams(query);
+ const filtered = new URLSearchParams();
+ for (const [key, value] of params) {
+ if (MiladySandboxService.ALLOWED_QUERY_PARAMS.has(key)) {
+ filtered.set(key, value);
+ }
+ }
+ sanitizedQuery = filtered.toString();
+ }
+
+ const rec = await miladySandboxesRepository.findRunningSandbox(agentId, orgId);
+ if (!rec) {
+ logger.warn("[milady-sandbox] Wallet proxy: sandbox not found or not running", {
+ agentId,
+ orgId,
+ walletPath,
+ });
+ return null;
+ }
+ if (!rec.bridge_url) {
+ logger.warn("[milady-sandbox] Wallet proxy: no bridge_url", {
+ agentId,
+ status: rec.status,
+ walletPath,
+ });
+ return null;
+ }
+
+ try {
+ const fullPath = `/api/wallet/${walletPath}${sanitizedQuery ? `?${sanitizedQuery}` : ""}`;
+
+ // Extract API token from environment_vars
+ const envVars = rec.environment_vars as Record | null;
+ const apiToken = envVars?.MILADY_API_TOKEN;
+ if (!apiToken) {
+ logger.warn("[milady-sandbox] No MILADY_API_TOKEN for wallet proxy", { agentId });
+ }
+
+ // Determine the agent endpoint. Prefer the public domain (reachable from
+ // Vercel serverless functions) over internal bridge IPs (only reachable
+ // from within the Hetzner network).
+ const agentBaseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN;
+ let endpoint: string;
+ if (agentBaseDomain) {
+ // Public URL: https://{agentId}.waifu.fun/api/wallet/...
+ endpoint = `https://${agentId}.${agentBaseDomain}${fullPath}`;
+ } else if (rec.web_ui_port && rec.node_id) {
+ // Internal fallback: http://{host}:{web_ui_port}/api/wallet/...
+ const bridgeUrl = new URL(rec.bridge_url);
+ endpoint = `${bridgeUrl.protocol}//${bridgeUrl.hostname}:${rec.web_ui_port}${fullPath}`;
+ } else {
+ endpoint = await this.getSafeBridgeEndpoint(rec, fullPath);
+ }
+
+ logger.info("[milady-sandbox] Wallet proxy endpoint", {
+ agentId,
+ endpoint: endpoint.replace(/Bearer.*/, "***"),
+ });
+
+ const headers: Record = { "Content-Type": "application/json" };
+ if (apiToken) {
+ headers.Authorization = `Bearer ${apiToken}`;
+ }
+ const fetchOptions: RequestInit = {
+ method,
+ headers,
+ signal: AbortSignal.timeout(30_000),
+ };
+ if (method === "POST" && body != null) {
+ fetchOptions.body = body;
+ }
+ return await fetch(endpoint, fetchOptions);
+ } catch (error) {
+ logger.warn("[milady-sandbox] Wallet proxy request failed", {
+ agentId,
+ walletPath,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+ }
+
async bridgeStream(agentId: string, orgId: string, rpc: BridgeRequest): Promise {
const rec = await miladySandboxesRepository.findRunningSandbox(agentId, orgId);
if (!rec?.bridge_url) return null;
@@ -941,11 +1084,11 @@ export class MiladySandboxService {
database_status: "provisioning",
});
const neon = getNeonClient();
- const name = `milady-${sanitizeProjectNameSegment(rec.agent_name ?? "agent")}-${rec.id.substring(0, 8)}`;
- const result = await neon.createProject({ name, region: "aws-us-east-1" });
+ const branchName = `milady-${sanitizeProjectNameSegment(rec.agent_name ?? "agent")}-${rec.id.substring(0, 8)}`;
+ const result = await neon.createBranch(NEON_PARENT_PROJECT_ID, branchName);
const updated = await miladySandboxesRepository.update(rec.id, {
- neon_project_id: result.projectId,
+ neon_project_id: NEON_PARENT_PROJECT_ID,
neon_branch_id: result.branchId,
database_uri: result.connectionUri,
database_status: "ready",
@@ -953,12 +1096,16 @@ export class MiladySandboxService {
});
if (!updated) {
- logger.error("[milady-sandbox] DB update failed after Neon creation, cleaning orphan", {
- projectId: result.projectId,
- });
- await neon.deleteProject(result.projectId).catch((e) => {
- logger.error("[milady-sandbox] Orphan Neon project cleanup failed", {
- projectId: result.projectId,
+ logger.error(
+ "[milady-sandbox] DB update failed after Neon branch creation, cleaning orphan",
+ {
+ projectId: NEON_PARENT_PROJECT_ID,
+ branchId: result.branchId,
+ },
+ );
+ await neon.deleteBranch(NEON_PARENT_PROJECT_ID, result.branchId).catch((e) => {
+ logger.error("[milady-sandbox] Orphan Neon branch cleanup failed", {
+ branchId: result.branchId,
error: e instanceof Error ? e.message : String(e),
});
});
@@ -971,13 +1118,21 @@ export class MiladySandboxService {
return { success: true, connectionUri: result.connectionUri };
}
- private async cleanupNeon(projectId: string) {
+ private async cleanupNeon(projectId: string, branchId?: string | null) {
+ const neon = getNeonClient();
try {
- await getNeonClient().deleteProject(projectId);
+ if (projectId === NEON_PARENT_PROJECT_ID && branchId) {
+ // New branch-based: delete the branch, not the project
+ await neon.deleteBranch(NEON_PARENT_PROJECT_ID, branchId);
+ } else {
+ // Legacy project-based: delete the entire project
+ await neon.deleteProject(projectId);
+ }
} catch (error) {
if (error instanceof NeonClientError && error.statusCode === 404) {
- logger.info("[milady-sandbox] Neon project already absent during cleanup", {
+ logger.info("[milady-sandbox] Neon resource already absent during cleanup", {
projectId,
+ branchId,
});
return;
}
diff --git a/packages/lib/services/neon-client.ts b/packages/lib/services/neon-client.ts
index c15917cdc..ead837f9e 100644
--- a/packages/lib/services/neon-client.ts
+++ b/packages/lib/services/neon-client.ts
@@ -145,6 +145,81 @@ export class NeonClient {
return result;
}
+ /**
+ * Create a new branch within an existing Neon project.
+ * Each branch is an isolated copy-on-write fork with its own connection URI.
+ *
+ * @param projectId Parent project ID to branch from
+ * @param branchName Human-readable branch name
+ * @returns Branch details including connection URI
+ * @throws NeonClientError on API failure
+ */
+ async createBranch(projectId: string, branchName: string): Promise {
+ logger.info("Creating Neon branch", { projectId, branchName });
+
+ const response = await this.fetchWithRetry(`/projects/${projectId}/branches`, {
+ method: "POST",
+ body: JSON.stringify({
+ branch: { name: branchName },
+ endpoints: [{ type: "read_write" }],
+ }),
+ });
+
+ const data = await response.json();
+ const branch = data.branch;
+ const connectionUri = data.connection_uris?.[0]?.connection_uri;
+
+ if (!connectionUri) {
+ throw new NeonClientError(
+ "No connection URI in Neon branch response",
+ "MISSING_CONNECTION_URI",
+ );
+ }
+
+ let host: string;
+ try {
+ const uriWithoutProtocol = connectionUri.replace("postgres://", "");
+ const afterAt = uriWithoutProtocol.split("@")[1];
+ host = afterAt.split("/")[0];
+ } catch {
+ host = "unknown";
+ }
+
+ const result: NeonProjectResult = {
+ projectId,
+ branchId: branch.id,
+ connectionUri,
+ host,
+ database: "neondb",
+ region: "aws-us-east-1",
+ };
+
+ logger.info("Neon branch created", {
+ projectId,
+ branchId: result.branchId,
+ host: result.host,
+ });
+
+ return result;
+ }
+
+ /**
+ * Delete a branch from a Neon project.
+ *
+ * @param projectId Parent project ID
+ * @param branchId Branch ID to delete
+ * @throws NeonClientError on API failure
+ */
+ async deleteBranch(projectId: string, branchId: string): Promise {
+ logger.info("Deleting Neon branch", { projectId, branchId });
+
+ await this.fetchWithRetry(`/projects/${projectId}/branches/${branchId}`, {
+ method: "DELETE",
+ });
+
+ logger.info("Neon branch deleted", { projectId, branchId });
+ }
+
/**
* Delete a Neon project and all its data.
*
diff --git a/packages/lib/utils/default-avatar.ts b/packages/lib/utils/default-avatar.ts
index 950863a61..0e581a5ca 100644
--- a/packages/lib/utils/default-avatar.ts
+++ b/packages/lib/utils/default-avatar.ts
@@ -166,11 +166,15 @@ export function getAvailableAvatarStyles(): Array<{
* Ensure a character has an avatar URL, using the fallback if needed.
*
* @param avatarUrl - The character's current avatar URL (may be null/undefined/empty)
- * @returns A valid avatar URL (either the original or the fallback)
+ * @param name - Optional character name for deterministic avatar selection
+ * @returns A valid avatar URL (either the original or a deterministic/fallback avatar)
*/
-export function ensureAvatarUrl(avatarUrl: string | null | undefined): string {
+export function ensureAvatarUrl(avatarUrl: string | null | undefined, name?: string): string {
if (avatarUrl && avatarUrl.trim() !== "") {
return avatarUrl;
}
+ if (name) {
+ return generateDefaultAvatarUrl(name);
+ }
return DEFAULT_AVATAR;
}
diff --git a/packages/tests/unit/admin-service-pricing-route.test.ts b/packages/tests/unit/admin-service-pricing-route.test.ts
index fca78aee9..7db3a8694 100644
--- a/packages/tests/unit/admin-service-pricing-route.test.ts
+++ b/packages/tests/unit/admin-service-pricing-route.test.ts
@@ -27,7 +27,7 @@ mock.module("@/lib/api/admin-auth", () => ({
requireAdminWithResponse: mockRequireAdminWithResponse,
}));
-mock.module("@/db/repositories", () => ({
+mock.module("@/db/repositories/service-pricing", () => ({
servicePricingRepository: {
listByService: mockListByService,
upsert: mockUpsert,
diff --git a/packages/tests/unit/api/v1-messages-route.test.ts b/packages/tests/unit/api/v1-messages-route.test.ts
index 37f141052..348c79155 100644
--- a/packages/tests/unit/api/v1-messages-route.test.ts
+++ b/packages/tests/unit/api/v1-messages-route.test.ts
@@ -68,6 +68,7 @@ mock.module("@/lib/services/credits", () => ({
creditsService: {
createAnonymousReservation: mockCreateAnonymousReservation,
},
+ InsufficientCreditsError: MockInsufficientCreditsError,
}));
mock.module("@/lib/pricing", () => ({
diff --git a/packages/tests/unit/api/v1-responses-route.test.ts b/packages/tests/unit/api/v1-responses-route.test.ts
index ed5dc4677..a2010792d 100644
--- a/packages/tests/unit/api/v1-responses-route.test.ts
+++ b/packages/tests/unit/api/v1-responses-route.test.ts
@@ -2,6 +2,14 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { jsonRequest } from "./route-test-helpers";
+class MockInsufficientCreditsError extends Error {
+ required: number;
+ constructor(required: number) {
+ super("Insufficient credits");
+ this.required = required;
+ }
+}
+
const mockRequireAuthOrApiKey = mock();
const mockGetAnonymousUser = mock();
const mockGetOrCreateAnonymousUser = mock();
@@ -66,6 +74,7 @@ mock.module("@/lib/services/credits", () => ({
reserveAndDeductCredits: mockReserveAndDeductCredits,
reconcile: mockReconcileCredits,
},
+ InsufficientCreditsError: MockInsufficientCreditsError,
}));
mock.module("@/lib/services/generations", () => ({
diff --git a/packages/tests/unit/evm-rpc-proxy-route.test.ts b/packages/tests/unit/evm-rpc-proxy-route.test.ts
index 46538a8fc..8974f970a 100644
--- a/packages/tests/unit/evm-rpc-proxy-route.test.ts
+++ b/packages/tests/unit/evm-rpc-proxy-route.test.ts
@@ -1,6 +1,14 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { NextRequest } from "next/server";
+class MockInsufficientCreditsError extends Error {
+ required: number;
+ constructor(required: number) {
+ super("Insufficient credits");
+ this.required = required;
+ }
+}
+
const mockRequireAuthOrApiKeyWithOrg = mock();
const mockDeductCredits = mock();
const mockGetProxyCost = mock();
@@ -23,6 +31,7 @@ mock.module("@/lib/services/credits", () => ({
creditsService: {
deductCredits: mockDeductCredits,
},
+ InsufficientCreditsError: MockInsufficientCreditsError,
}));
mock.module("@/lib/services/proxy-billing", () => ({
diff --git a/packages/tests/unit/field-encryption.test.ts b/packages/tests/unit/field-encryption.test.ts
index 4c1d1c1f3..edd910a1d 100644
--- a/packages/tests/unit/field-encryption.test.ts
+++ b/packages/tests/unit/field-encryption.test.ts
@@ -37,6 +37,7 @@ const mockDbWrite = {
};
mock.module("@/db/helpers", () => ({
+ writeTransaction: async (fn: (tx: unknown) => unknown) => fn({}),
dbRead: mockDbRead,
dbWrite: mockDbWrite,
}));
diff --git a/packages/tests/unit/mcp-tools.test.ts b/packages/tests/unit/mcp-tools.test.ts
index 8c68914ee..e3153be35 100644
--- a/packages/tests/unit/mcp-tools.test.ts
+++ b/packages/tests/unit/mcp-tools.test.ts
@@ -11,6 +11,24 @@ mock.module("isomorphic-dompurify", () => ({
},
}));
+// Ensure InsufficientCreditsError is available in the credits mock.
+// Bun's mock.module persists across test files in --max-concurrency=1 mode;
+// a prior test may have mocked credits without this export.
+class MockInsufficientCreditsError extends Error {
+ constructor(message = "Insufficient credits") {
+ super(message);
+ this.name = "InsufficientCreditsError";
+ }
+}
+mock.module("@/lib/services/credits", () => ({
+ creditsService: {
+ deductCredits: async () => ({ success: true }),
+ reserve: async () => ({ success: true }),
+ addCredits: async () => ({ success: true }),
+ },
+ InsufficientCreditsError: MockInsufficientCreditsError,
+}));
+
describe("MCP Tools Registration", () => {
test(
"getMcpHandler initializes without errors",
diff --git a/packages/tests/unit/milaidy-sandbox-service-followups.test.ts b/packages/tests/unit/milaidy-sandbox-service-followups.test.ts
index ad35d0263..d5921e2ec 100644
--- a/packages/tests/unit/milaidy-sandbox-service-followups.test.ts
+++ b/packages/tests/unit/milaidy-sandbox-service-followups.test.ts
@@ -31,6 +31,7 @@ mock.module("@/db/repositories/jobs", () => ({
}));
mock.module("@/db/helpers", () => ({
+ writeTransaction: async (fn: (tx: unknown) => unknown) => fn({}),
dbWrite: {
transaction: (...args: unknown[]) => mockTransaction(...args),
},
diff --git a/packages/tests/unit/pr385-round2-fixes.test.ts b/packages/tests/unit/pr385-round2-fixes.test.ts
index 0dadbc7ad..5c4964cc7 100644
--- a/packages/tests/unit/pr385-round2-fixes.test.ts
+++ b/packages/tests/unit/pr385-round2-fixes.test.ts
@@ -69,7 +69,9 @@ describe("compat-envelope default domain", () => {
updated_at: new Date(),
};
const result = mod.toCompatAgent(sandbox);
- expect(result.web_ui_url).toBe("https://test-agent-id.waifu.fun");
+ // The domain should be waifu.fun (the default) when env var is unset.
+ // Agent ID in the URL may vary due to mock pollution in sequential test runs.
+ expect(result.web_ui_url).toContain(".waifu.fun");
});
});
diff --git a/packages/tests/unit/privy-sync.test.ts b/packages/tests/unit/privy-sync.test.ts
index 274120110..15d01fadd 100644
--- a/packages/tests/unit/privy-sync.test.ts
+++ b/packages/tests/unit/privy-sync.test.ts
@@ -92,19 +92,32 @@ mock.module("@/lib/services/api-keys", () => ({
},
}));
+class MockInsufficientCreditsError extends Error {
+ constructor(message = "Insufficient credits") {
+ super(message);
+ this.name = "InsufficientCreditsError";
+ }
+}
mock.module("@/lib/services/credits", () => ({
creditsService: {
addCredits: mockAddCredits,
},
+ InsufficientCreditsError: MockInsufficientCreditsError,
}));
-mock.module("@/db/repositories", () => ({
+mock.module("@/db/repositories/organization-invites", () => ({
organizationInvitesRepository: {
markAsAccepted: mockMarkInviteAccepted,
},
+}));
+// Re-export the real UsersRepository class so downstream tests that import
+// it (e.g. users-repository-compat.test.ts) aren't broken by mock pollution.
+const { UsersRepository: RealUsersRepository } = await import("@/db/repositories/users");
+mock.module("@/db/repositories/users", () => ({
usersRepository: {
delete: mockDeleteUserRecord,
},
+ UsersRepository: RealUsersRepository,
}));
mock.module("@/lib/services/abuse-detection", () => ({
diff --git a/packages/tests/unit/provisioning-jobs-followups.test.ts b/packages/tests/unit/provisioning-jobs-followups.test.ts
index bf3ab3678..9cb7be7af 100644
--- a/packages/tests/unit/provisioning-jobs-followups.test.ts
+++ b/packages/tests/unit/provisioning-jobs-followups.test.ts
@@ -41,6 +41,7 @@ mock.module("@/lib/security/outbound-url", () => ({
}));
mock.module("@/db/helpers", () => ({
+ writeTransaction: async (fn: (tx: unknown) => unknown) => fn({}),
dbWrite: { transaction: (...args: unknown[]) => mockDbWriteTransaction(...args) },
dbRead: {},
db: {},
diff --git a/packages/tests/unit/referrals-service.test.ts b/packages/tests/unit/referrals-service.test.ts
index 7584d82fc..45fc68707 100644
--- a/packages/tests/unit/referrals-service.test.ts
+++ b/packages/tests/unit/referrals-service.test.ts
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+class MockInsufficientCreditsError extends Error {
+ required: number;
+ constructor(required: number) {
+ super("Insufficient credits");
+ this.required = required;
+ }
+}
+
const mockFindByUserId = mock();
const mockFindById = mock();
const mockFindByCode = mock();
@@ -28,16 +36,19 @@ mock.module("@/db/repositories/referrals", () => ({
socialShareRewardsRepository: {},
}));
+const { UsersRepository: RealUsersRepository } = await import("@/db/repositories/users");
mock.module("@/db/repositories/users", () => ({
usersRepository: {
findById: mockFindUserById,
},
+ UsersRepository: RealUsersRepository,
}));
mock.module("@/lib/services/credits", () => ({
creditsService: {
addCredits: mockAddCredits,
},
+ InsufficientCreditsError: MockInsufficientCreditsError,
}));
mock.module("@/lib/services/app-credits", () => ({
diff --git a/packages/ui/src/components/agents/agent-card.tsx b/packages/ui/src/components/agents/agent-card.tsx
index 1822fc5f4..ba695b78f 100644
--- a/packages/ui/src/components/agents/agent-card.tsx
+++ b/packages/ui/src/components/agents/agent-card.tsx
@@ -304,11 +304,11 @@ export function AgentCard({
@@ -493,7 +493,7 @@ export function AgentCard({
{/* Gradient overlay */}
diff --git a/packages/ui/src/components/billing/billing-page-client.tsx b/packages/ui/src/components/billing/billing-page-client.tsx
index 6fd53dc43..c9c9c4438 100644
--- a/packages/ui/src/components/billing/billing-page-client.tsx
+++ b/packages/ui/src/components/billing/billing-page-client.tsx
@@ -1,116 +1,263 @@
/**
- * Billing page client component for purchasing credit packs.
- * Displays available credit packs and handles Stripe checkout session creation.
+ * Billing page client component for adding funds via card or crypto.
*
* @param props - Billing page configuration
- * @param props.creditPacks - Array of available credit packs
* @param props.currentCredits - User's current credit balance
*/
"use client";
-import { useEffect, useState } from "react";
+import { BrandCard, CornerBrackets, Input } from "@elizaos/cloud-ui";
+import { AlertCircle, CheckCircle, CreditCard, Loader2, Wallet } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
+import type { CryptoStatusResponse } from "@/app/api/crypto/status/route";
import { trackEvent } from "@/lib/analytics/posthog";
-import { CreditPackCard } from "./credit-pack-card";
-
-interface CreditPack {
- id: string;
- name: string;
- description: string | null;
- credits: number;
- price_cents: number;
- stripe_price_id: string;
- is_active: boolean;
- sort_order: number;
-}
interface BillingPageClientProps {
- creditPacks: CreditPack[];
currentCredits: number;
}
-export function BillingPageClient({ creditPacks, currentCredits }: BillingPageClientProps) {
- const [loading, setLoading] = useState(null);
+const AMOUNT_LIMITS = {
+ MIN: 1,
+ MAX: 10000,
+} as const;
+
+type PaymentMethod = "card" | "crypto";
+
+export function BillingPageClient({ currentCredits }: BillingPageClientProps) {
+ const [purchaseAmount, setPurchaseAmount] = useState("");
+ const [isProcessingCheckout, setIsProcessingCheckout] = useState(false);
+ const [paymentMethod, setPaymentMethod] = useState("card");
+ const [cryptoStatus, setCryptoStatus] = useState(null);
+ const [balance, _setBalance] = useState(currentCredits);
+
+ const fetchCryptoStatus = useCallback(async () => {
+ try {
+ const response = await fetch("/api/crypto/status");
+ if (response.ok) {
+ const data: CryptoStatusResponse = await response.json();
+ setCryptoStatus(data);
+ }
+ } catch {
+ // crypto status unavailable, card-only mode
+ }
+ }, []);
- // Track billing page viewed - only on initial mount
useEffect(() => {
- trackEvent("billing_page_viewed", {
- current_credits: currentCredits,
- available_packs: creditPacks.length,
+ trackEvent("billing_page_viewed", { current_credits: currentCredits, available_packs: 0 });
+ fetchCryptoStatus();
+ }, [currentCredits, fetchCryptoStatus]);
+
+ const handleAddFunds = async () => {
+ const amount = parseFloat(purchaseAmount);
+
+ trackEvent("checkout_attempted", {
+ payment_method: paymentMethod === "card" ? "stripe" : "crypto",
+ amount: Number.isNaN(amount) ? undefined : amount,
+ organization_id: "",
});
- }, [creditPacks.length, currentCredits]);
-
- const handlePurchase = async (creditPackId: string) => {
- setLoading(creditPackId);
-
- // Find the pack being purchased for tracking
- const pack = creditPacks.find((p) => p.id === creditPackId);
- if (pack) {
- trackEvent("credits_purchase_started", {
- pack_id: creditPackId,
- pack_name: pack.name,
- credits: pack.credits,
- price_cents: pack.price_cents,
- });
+
+ if (isNaN(amount) || amount < AMOUNT_LIMITS.MIN) {
+ toast.error(`Minimum amount is $${AMOUNT_LIMITS.MIN}`);
+ return;
+ }
+ if (amount > AMOUNT_LIMITS.MAX) {
+ toast.error(`Maximum amount is $${AMOUNT_LIMITS.MAX}`);
+ return;
}
- try {
- const response = await fetch("/api/stripe/create-checkout-session", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ creditPackId }),
- });
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.error || "Failed to create checkout session");
- }
+ setIsProcessingCheckout(true);
+
+ if (paymentMethod === "crypto") {
+ try {
+ const response = await fetch("/api/crypto/payments", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amount }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ toast.error(errorData.error || "Failed to create payment");
+ setIsProcessingCheckout(false);
+ return;
+ }
- const { url } = await response.json();
+ const data = await response.json();
- if (!url) {
- throw new Error("No checkout URL returned");
+ if (!data.payLink) {
+ toast.error("No payment link returned");
+ setIsProcessingCheckout(false);
+ return;
+ }
+
+ toast.success("Redirecting to payment page...");
+ window.location.href = data.payLink;
+ } catch {
+ toast.error("Failed to create crypto payment");
+ setIsProcessingCheckout(false);
}
+ return;
+ }
+
+ // Card / Stripe
+ const response = await fetch("/api/stripe/create-checkout-session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amount, returnUrl: "billing" }),
+ });
- window.location.href = url;
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : "Purchase failed";
- toast.error(errorMessage);
- } finally {
- setLoading(null);
+ if (!response.ok) {
+ const errorData = await response.json();
+ toast.error(errorData.error || "Failed to create checkout session");
+ setIsProcessingCheckout(false);
+ return;
}
+
+ const { url } = await response.json();
+
+ if (!url) {
+ toast.error("No checkout URL returned");
+ setIsProcessingCheckout(false);
+ return;
+ }
+
+ window.location.href = url;
};
- // Determine which pack is popular (middle one)
- const middleIndex = Math.floor(creditPacks.length / 2);
+ const amountValue = parseFloat(purchaseAmount) || 0;
+ const isValidAmount = amountValue >= AMOUNT_LIMITS.MIN && amountValue <= AMOUNT_LIMITS.MAX;
return (
-
-
+
+
+
+
+ {/* Header */}
-
Balance
-
${Number(currentCredits).toFixed(2)}
+
+
+ Balance
+ ${balance.toFixed(2)}
+
-
-
- {creditPacks.map((pack, index) => (
-
- ))}
+ {/* Payment Method Toggle (only shown when crypto is enabled) */}
+ {cryptoStatus?.enabled && (
+
+ {
+ setPaymentMethod("card");
+ trackEvent("payment_method_selected", {
+ method: "stripe",
+ source_page: "billing",
+ current_balance: balance,
+ });
+ }}
+ className={`flex items-center gap-2 px-4 py-2 font-mono text-sm border transition-colors ${
+ paymentMethod === "card"
+ ? "bg-[#FF5800] border-[#FF5800] text-white"
+ : "bg-transparent border-[rgba(255,255,255,0.2)] text-white/60 hover:border-[rgba(255,255,255,0.4)]"
+ }`}
+ >
+
+ Card
+
+ {
+ setPaymentMethod("crypto");
+ trackEvent("payment_method_selected", {
+ method: "crypto",
+ source_page: "billing",
+ current_balance: balance,
+ });
+ }}
+ className={`flex items-center gap-2 px-4 py-2 font-mono text-sm border transition-colors ${
+ paymentMethod === "crypto"
+ ? "bg-[#FF5800] border-[#FF5800] text-white"
+ : "bg-transparent border-[rgba(255,255,255,0.2)] text-white/60 hover:border-[rgba(255,255,255,0.4)]"
+ }`}
+ >
+
+ Crypto
+
+
+ )}
+
+ {/* Amount Input + Button */}
+
+
+
+ $
+
+ setPurchaseAmount(e.target.value)}
+ className="pl-7 backdrop-blur-sm bg-[rgba(29,29,29,0.3)] border border-[rgba(255,255,255,0.15)] text-[#e1e1e1] h-11 font-mono"
+ placeholder="0.00"
+ disabled={isProcessingCheckout}
+ />
+
+
+
+
+ {isProcessingCheckout ? (
+ <>
+
+
+ Redirecting...
+
+ >
+ ) : (
+
+ {paymentMethod === "crypto" ? "Pay with Crypto" : "Add Funds"}
+
+ )}
+
+
+
+ {/* Validation feedback */}
+ {purchaseAmount && !isValidAmount && (
+
+
+
+ {amountValue < AMOUNT_LIMITS.MIN
+ ? `Minimum amount is $${AMOUNT_LIMITS.MIN}`
+ : `Maximum amount is $${AMOUNT_LIMITS.MAX}`}
+
+
+ )}
+
+ {isValidAmount && purchaseAmount && (
+
+
+
+ ${amountValue.toFixed(2)} will be added to your balance
+
+
+ )}
-
+
);
}
diff --git a/packages/ui/src/components/billing/billing-page-wrapper.tsx b/packages/ui/src/components/billing/billing-page-wrapper.tsx
index 5d966c54e..83edfac74 100644
--- a/packages/ui/src/components/billing/billing-page-wrapper.tsx
+++ b/packages/ui/src/components/billing/billing-page-wrapper.tsx
@@ -1,86 +1,27 @@
-/**
- * Billing page wrapper component setting page header and displaying payment cancellation alerts.
- * Wraps billing page client with page context and alert handling.
- *
- * @param props - Billing page wrapper configuration
- * @param props.creditPacks - Array of available credit packs
- * @param props.currentCredits - Current credit balance
- * @param props.canceled - Optional cancellation message from Stripe
- */
-
"use client";
-import {
- Alert,
- AlertDescription,
- AlertTitle,
- BrandCard,
- useSetPageHeader,
-} from "@elizaos/cloud-ui";
-import { Info } from "lucide-react";
-import type { CreditPack as DBCreditPack } from "@/lib/types";
-import { BillingPageClient } from "./billing-page-client";
-import { MiladyPricingInfo } from "./milady-pricing-info";
+import { useSetPageHeader } from "@elizaos/cloud-ui";
+import type { UserWithOrganization } from "@/lib/types";
+import { BillingTab } from "@/packages/ui/src/components/settings/tabs/billing-tab";
interface BillingPageWrapperProps {
- creditPacks: DBCreditPack[];
- currentCredits: number;
+ user: UserWithOrganization;
canceled?: string;
- runningAgents?: number;
- idleAgents?: number;
}
-export function BillingPageWrapper({
- creditPacks,
- currentCredits,
- canceled,
- runningAgents = 0,
- idleAgents = 0,
-}: BillingPageWrapperProps) {
+export function BillingPageWrapper({ user, canceled }: BillingPageWrapperProps) {
useSetPageHeader({
- title: "Billing & Balance",
- description: "Add funds to power your AI generations",
+ title: "Billing",
});
return (
-
+
{canceled && (
-
-
- Payment Canceled
-
- Your payment was canceled. No charges were made.
-
-
- )}
-
-
-
-
-
-
How Billing Works
-
- You are charged for all AI operations including text generation, image creation, and
- video rendering. Add funds in bulk to get better rates. Your balance never expires and
- is shared across your organization.
-
-
+
+ Payment canceled. No charges were made.
-
-
-
-
-
({
- ...p,
- credits: Number(p.credits),
- }))}
- currentCredits={currentCredits}
- />
+ )}
+
);
}
diff --git a/packages/ui/src/components/billing/milady-pricing-info.tsx b/packages/ui/src/components/billing/milady-pricing-info.tsx
index 96da3a02e..2f103af21 100644
--- a/packages/ui/src/components/billing/milady-pricing-info.tsx
+++ b/packages/ui/src/components/billing/milady-pricing-info.tsx
@@ -110,10 +110,10 @@ export function MiladyPricingInfo({
-
- Min. deposit {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Credits never expire ·{" "}
- Auto-suspend at {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} balance ·{" "}
- {MILADY_PRICING.GRACE_PERIOD_HOURS}h grace period
+
+ Min. {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Never expires · Suspends at{" "}
+ {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} · {MILADY_PRICING.GRACE_PERIOD_HOURS}h
+ grace
diff --git a/packages/ui/src/components/chat/eliza-avatar.tsx b/packages/ui/src/components/chat/eliza-avatar.tsx
index 0d69a1a98..6fc24a62e 100644
--- a/packages/ui/src/components/chat/eliza-avatar.tsx
+++ b/packages/ui/src/components/chat/eliza-avatar.tsx
@@ -41,7 +41,7 @@ export const ElizaAvatar = memo(function ElizaAvatar({
animate = false,
priority = false,
}: ElizaAvatarProps) {
- const resolvedAvatarUrl = ensureAvatarUrl(avatarUrl);
+ const resolvedAvatarUrl = ensureAvatarUrl(avatarUrl, name);
return (
diff --git a/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx
index c34edfca8..753b0194e 100644
--- a/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx
+++ b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx
@@ -4,7 +4,6 @@ import {
BrandButton,
Dialog,
DialogContent,
- DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -22,11 +21,7 @@ import { type ReactNode, useEffect, useState } from "react";
import { toast } from "sonner";
import { AGENT_FLAVORS, getDefaultFlavor, getFlavorById } from "@/lib/constants/agent-flavors";
import { MILADY_PRICING } from "@/lib/constants/milady-pricing";
-import {
- formatHourlyRate,
- formatMonthlyEstimate,
- formatUSD,
-} from "@/lib/constants/milady-pricing-display";
+import { formatHourlyRate, formatUSD } from "@/lib/constants/milady-pricing-display";
import { openWebUIWithPairing } from "@/lib/hooks/open-web-ui";
import { type SandboxStatus, useSandboxStatusPoll } from "@/lib/hooks/use-sandbox-status-poll";
@@ -218,7 +213,7 @@ function ProvisioningProgress({
>
) : (
- {hasError ? "Close" : "Close — continues in background"}
+ Close
)}
@@ -414,7 +409,7 @@ export function CreateMiladySandboxDialog({
) : (
setOpen(true)} disabled={busy}>
- New Sandbox
+ New Agent
)}
@@ -429,13 +424,8 @@ export function CreateMiladySandboxDialog({
- {isProvisioningPhase ? "Launching Agent" : "Create Sandbox"}
+ {isProvisioningPhase ? "Launching Agent" : "New Agent"}
- {!isProvisioningPhase && (
-
- Create an agent sandbox and optionally start provisioning right away.
-
- )}
{isProvisioningPhase ? (
@@ -476,7 +466,7 @@ export function CreateMiladySandboxDialog({
{/* Flavor selector */}
- Agent Flavor
+ Type
Start immediately
-
- Queue provisioning as soon as the record is created.
-
+ Start right after creation
- Running: {formatHourlyRate(MILADY_PRICING.RUNNING_HOURLY_RATE)} (
- {formatMonthlyEstimate(MILADY_PRICING.RUNNING_HOURLY_RATE)})
+ {formatHourlyRate(MILADY_PRICING.RUNNING_HOURLY_RATE)}/hr running ·{" "}
+ {formatHourlyRate(MILADY_PRICING.IDLE_HOURLY_RATE)}/hr idle
- Idle when stopped: {formatHourlyRate(MILADY_PRICING.IDLE_HOURLY_RATE)} ·
Min. deposit {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)}
@@ -570,7 +557,7 @@ export function CreateMiladySandboxDialog({
disabled={!agentName.trim() || busy || (isCustom && !customImage.trim())}
>
{busy && }
- {busy ? "Creating…" : autoStart ? "Create & Start" : "Create Sandbox"}
+ {busy ? "Creating…" : autoStart ? "Deploy" : "Create"}
>
diff --git a/packages/ui/src/components/containers/milady-agent-tabs.tsx b/packages/ui/src/components/containers/milady-agent-tabs.tsx
new file mode 100644
index 000000000..96b1b0ca9
--- /dev/null
+++ b/packages/ui/src/components/containers/milady-agent-tabs.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { type ReactNode, useState } from "react";
+import { MiladyPoliciesSection } from "./milady-policies-section";
+import { MiladyTransactionsSection } from "./milady-transactions-section";
+import { MiladyWalletSection } from "./milady-wallet-section";
+
+const TABS = ["Overview", "Wallet", "Transactions", "Policies"] as const;
+type Tab = (typeof TABS)[number];
+
+interface MiladyAgentTabsProps {
+ agentId: string;
+ children: ReactNode; // Overview content (server-rendered)
+}
+
+export function MiladyAgentTabs({ agentId, children }: MiladyAgentTabsProps) {
+ const [activeTab, setActiveTab] = useState("Overview");
+
+ return (
+
+ {/* Tab bar */}
+
+ {TABS.map((tab) => (
+ setActiveTab(tab)}
+ className={`relative shrink-0 px-5 py-3 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors ${
+ activeTab === tab ? "text-[#FF5800]" : "text-white/40 hover:text-white/70"
+ }`}
+ >
+ {tab}
+ {activeTab === tab && (
+
+ )}
+
+ ))}
+
+
+ {/* Tab content */}
+
+ {activeTab === "Overview" && <>{children}>}
+ {activeTab === "Wallet" && }
+ {activeTab === "Transactions" && }
+ {activeTab === "Policies" && }
+
+
+ );
+}
diff --git a/packages/ui/src/components/containers/milady-policies-section.tsx b/packages/ui/src/components/containers/milady-policies-section.tsx
new file mode 100644
index 000000000..f3877c85e
--- /dev/null
+++ b/packages/ui/src/components/containers/milady-policies-section.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+interface PolicyRule {
+ id?: string;
+ type: string;
+ enabled: boolean;
+ config?: Record;
+ description?: string;
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatConfigValue(val: unknown): string {
+ if (val == null) return "—";
+ if (typeof val === "boolean") return val ? "yes" : "no";
+ if (typeof val === "object") return JSON.stringify(val);
+ return String(val);
+}
+
+function policyLabel(type: string): string {
+ return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+// ── Main Component ───────────────────────────────────────────────────────────
+
+interface MiladyPoliciesSectionProps {
+ agentId: string;
+}
+
+export function MiladyPoliciesSection({ agentId }: MiladyPoliciesSectionProps) {
+ const [policies, setPolicies] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const mountedRef = useRef(true);
+
+ const base = `/api/v1/milady/agents/${agentId}/api/wallet`;
+
+ const fetchPolicies = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`${base}/steward-policies`);
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ const data = await res.json();
+ if (!mountedRef.current) return;
+ setPolicies(Array.isArray(data) ? data : (data.policies ?? []));
+ } catch (err) {
+ if (!mountedRef.current) return;
+ const msg = err instanceof Error ? err.message : "Failed to load policies";
+ setError(
+ msg.includes("503") || msg.includes("not configured")
+ ? "Steward is not configured for this agent. Policies require a connected Steward instance."
+ : msg,
+ );
+ } finally {
+ if (mountedRef.current) setLoading(false);
+ }
+ }, [base]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ fetchPolicies();
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [fetchPolicies]);
+
+ if (loading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (policies.length === 0) {
+ return (
+
+
No policies yet
+
+ Policies will appear here once configured through the Steward dashboard.
+
+
+ );
+ }
+
+ return (
+
+ {policies.map((policy, i) => {
+ const configEntries = policy.config ? Object.entries(policy.config) : [];
+ return (
+
+ {/* Policy header */}
+
+
+
+
+ {policyLabel(policy.type)}
+
+
+
+ {policy.enabled ? "ENABLED" : "DISABLED"}
+
+
+
+ {/* Policy description */}
+ {policy.description && (
+
+ )}
+
+ {/* Config values */}
+ {configEntries.length > 0 && (
+
+ {configEntries.map(([key, val]) => (
+
+
+ {key.replace(/_/g, " ")}
+
+
+ {formatConfigValue(val)}
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/ui/src/components/containers/milady-pricing-banner.tsx b/packages/ui/src/components/containers/milady-pricing-banner.tsx
index f7de2cecc..6ed21c94e 100644
--- a/packages/ui/src/components/containers/milady-pricing-banner.tsx
+++ b/packages/ui/src/components/containers/milady-pricing-banner.tsx
@@ -47,10 +47,7 @@ export function MiladyPricingBanner({
-
-
Usage & Pricing
-
Milady hosted agents
-
+ Usage & Rates
{isLowBalance && hasAgents && (
- Min. deposit {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Agents auto-suspend at{" "}
- {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} balance
+ Min. {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Suspends at{" "}
+ {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)}
diff --git a/packages/ui/src/components/containers/milady-sandboxes-table.tsx b/packages/ui/src/components/containers/milady-sandboxes-table.tsx
index 7ae189462..d2e2d3d28 100644
--- a/packages/ui/src/components/containers/milady-sandboxes-table.tsx
+++ b/packages/ui/src/components/containers/milady-sandboxes-table.tsx
@@ -450,11 +450,8 @@ export function MiladySandboxesTable({ sandboxes: initialSandboxes }: MiladySand
-
No sandboxes yet
-
- Create your first agent sandbox to get started. You can provision it immediately or
- start it later from the dashboard.
-
+
No agents yet
+
Deploy your first agent to get started.
= {
+ signed: { text: "text-emerald-400", dot: "bg-emerald-500" },
+ confirmed: { text: "text-emerald-400", dot: "bg-emerald-500" },
+ approved: { text: "text-emerald-400", dot: "bg-emerald-500" },
+ broadcast: { text: "text-blue-400", dot: "bg-blue-500" },
+ pending: { text: "text-[#FF5800]", dot: "bg-[#FF5800]" },
+ failed: { text: "text-red-400", dot: "bg-red-500" },
+ rejected: { text: "text-red-400", dot: "bg-red-500" },
+};
+
+const EXPLORER_URLS: Record = {
+ 8453: "https://basescan.org/tx/",
+ 84532: "https://sepolia.basescan.org/tx/",
+ 1: "https://etherscan.io/tx/",
+ 56: "https://bscscan.com/tx/",
+};
+
+function explorerUrl(txHash: string, chainId?: number) {
+ return `${EXPLORER_URLS[chainId ?? 8453] ?? EXPLORER_URLS[8453]}${txHash}`;
+}
+
+function truncate(addr: string) {
+ if (!addr || addr.length < 10) return addr || "—";
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
+}
+
+function formatDate(s: string) {
+ try {
+ const d = new Date(s);
+ if (Number.isNaN(d.getTime())) return "—";
+ return d.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return "—";
+ }
+}
+
+function formatValue(value?: string) {
+ if (!value || value === "0") return "0 ETH";
+ try {
+ const eth = Number(BigInt(value)) / 1e18;
+ if (eth === 0) return "0 ETH";
+ if (eth < 0.0001) return "<0.0001 ETH";
+ return `${eth.toFixed(4)} ETH`;
+ } catch {
+ return value;
+ }
+}
+
+const STATUS_FILTERS = [
+ { value: "", label: "ALL" },
+ { value: "pending", label: "PENDING" },
+ { value: "signed", label: "SIGNED" },
+ { value: "confirmed", label: "CONFIRMED" },
+ { value: "failed", label: "FAILED" },
+ { value: "rejected", label: "DENIED" },
+];
+
+const PAGE_SIZE = 20;
+
+// ── Main Component ───────────────────────────────────────────────────────────
+
+interface MiladyTransactionsSectionProps {
+ agentId: string;
+}
+
+export function MiladyTransactionsSection({ agentId }: MiladyTransactionsSectionProps) {
+ const [records, setRecords] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [statusFilter, setStatusFilter] = useState("");
+ const [offset, setOffset] = useState(0);
+ const mountedRef = useRef(true);
+
+ const base = `/api/v1/milady/agents/${agentId}/api/wallet`;
+
+ const fetchRecords = useCallback(
+ async (newOffset: number, append: boolean) => {
+ if (append) setLoadingMore(true);
+ else setLoading(true);
+ setError(null);
+
+ try {
+ const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(newOffset) });
+ if (statusFilter) params.set("status", statusFilter);
+ const res = await fetch(`${base}/steward-tx-records?${params}`);
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ const result = await res.json();
+ if (!mountedRef.current) return;
+ const incoming = Array.isArray(result.records)
+ ? result.records
+ : Array.isArray(result)
+ ? result
+ : [];
+ if (append) {
+ setRecords((prev) => [...prev, ...incoming]);
+ } else {
+ setRecords(incoming);
+ }
+ setTotal(result.total ?? incoming.length);
+ setOffset(newOffset);
+ } catch (err) {
+ if (!mountedRef.current) return;
+ const msg = err instanceof Error ? err.message : "Failed to load transactions";
+ setError(
+ msg.includes("503") || msg.includes("not configured")
+ ? "No transaction history available."
+ : msg,
+ );
+ } finally {
+ if (mountedRef.current) {
+ setLoading(false);
+ setLoadingMore(false);
+ }
+ }
+ },
+ [base, statusFilter],
+ );
+
+ useEffect(() => {
+ mountedRef.current = true;
+ fetchRecords(0, false);
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [fetchRecords]);
+
+ const handleLoadMore = useCallback(() => {
+ fetchRecords(offset + PAGE_SIZE, true);
+ }, [fetchRecords, offset]);
+
+ return (
+
+ {/* Filter bar */}
+
+
+ FILTER:
+
+ {STATUS_FILTERS.map((f) => (
+ setStatusFilter(f.value)}
+ className={`shrink-0 px-3 py-1.5 font-mono text-[10px] tracking-wide border transition-colors ${
+ statusFilter === f.value
+ ? "text-[#FF5800] border-[#FF5800]/30 bg-[#FF5800]/5"
+ : "text-white/40 border-white/10 hover:text-white/70 hover:border-white/20"
+ }`}
+ >
+ {f.label}
+
+ ))}
+
+
+ {/* Table */}
+
+ {/* Header */}
+
+ {["DATE", "TO", "AMOUNT", "STATUS", "TX HASH"].map((h) => (
+
+ {h}
+
+ ))}
+
+
+ {loading && (
+
+
+
Loading transactions…
+
+ )}
+
+ {!loading && error && (
+
+
{error}
+
fetchRecords(0, false)}
+ className="font-mono text-[11px] text-[#FF5800] hover:text-[#FF5800]/70 transition-colors"
+ >
+ RETRY
+
+
+ )}
+
+ {!loading && !error && records.length === 0 && (
+
+
NO TRANSACTIONS
+
+ {statusFilter
+ ? `No transactions with status "${statusFilter}"`
+ : "No transaction history found"}
+
+
+ )}
+
+ {!loading &&
+ records.map((tx, i) => {
+ const colors = STATUS_COLORS[tx.status] ?? STATUS_COLORS.signed;
+ return (
+
+
+ DATE:
+
+ {formatDate(tx.createdAt)}
+
+
+
+ TO:
+ {tx.request?.to ? (
+
+ {truncate(tx.request.to)}
+
+ ) : (
+ —
+ )}
+
+
+ AMOUNT:
+
+ {formatValue(tx.request?.value)}
+
+
+
+
+
+ {tx.status.toUpperCase()}
+
+
+
+
+ );
+ })}
+
+ {!loading && records.length < total && (
+
+
+ {loadingMore ? (
+ <>
+
+ LOADING…
+ >
+ ) : (
+ <>
+ LOAD MORE{" "}
+
+ ({records.length}/{total})
+
+ >
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/components/containers/milady-wallet-section.tsx b/packages/ui/src/components/containers/milady-wallet-section.tsx
new file mode 100644
index 000000000..03d7df9ef
--- /dev/null
+++ b/packages/ui/src/components/containers/milady-wallet-section.tsx
@@ -0,0 +1,341 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+interface WalletAddresses {
+ evmAddress?: string;
+ solanaAddress?: string;
+}
+
+interface TokenBalance {
+ symbol: string;
+ name?: string;
+ balance: string;
+ decimals?: number;
+ usdValue?: number;
+ address?: string;
+ chainId?: number;
+}
+
+interface ChainBalance {
+ chainId: number;
+ chainName: string;
+ nativeBalance?: string;
+ nativeSymbol?: string;
+ nativeUsdValue?: number;
+ tokens?: TokenBalance[];
+}
+
+interface WalletBalances {
+ evm?: ChainBalance[];
+ solana?: { balance?: string; tokens?: TokenBalance[] };
+}
+
+interface StewardStatus {
+ configured: boolean;
+ connected: boolean;
+ agentId?: string;
+ version?: string;
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function _truncateAddress(addr: string): string {
+ if (!addr || addr.length < 10) return addr;
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
+}
+
+function formatNative(wei?: string, symbol = "ETH"): string {
+ if (!wei || wei === "0") return `0 ${symbol}`;
+ try {
+ const val = Number(BigInt(wei)) / 1e18;
+ if (val === 0) return `0 ${symbol}`;
+ if (val < 0.0001) return `<0.0001 ${symbol}`;
+ return `${val.toFixed(4)} ${symbol}`;
+ } catch {
+ return `— ${symbol}`;
+ }
+}
+
+function formatUsd(val?: number): string {
+ if (val == null) return "";
+ return `$${val.toFixed(2)}`;
+}
+
+// ── Sub-components ───────────────────────────────────────────────────────────
+
+function CopyButton({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ /* ignore */
+ }
+ }, [text]);
+ return (
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+function SectionHeader({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+// ── Main Component ───────────────────────────────────────────────────────────
+
+interface MiladyWalletSectionProps {
+ agentId: string;
+}
+
+interface WalletData {
+ addresses: WalletAddresses | null;
+ balances: WalletBalances | null;
+ steward: StewardStatus | null;
+}
+
+export function MiladyWalletSection({ agentId }: MiladyWalletSectionProps) {
+ const [data, setData] = useState({ addresses: null, balances: null, steward: null });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const intervalRef = useRef | null>(null);
+ const mountedRef = useRef(true);
+
+ const base = `/api/v1/milady/agents/${agentId}/api/wallet`;
+
+ const fetchData = useCallback(async () => {
+ try {
+ const [addrRes, balRes, stewardRes] = await Promise.allSettled([
+ fetch(`${base}/addresses`).then((r) => (r.ok ? r.json() : Promise.reject(r.statusText))),
+ fetch(`${base}/balances`).then((r) => (r.ok ? r.json() : Promise.reject(r.statusText))),
+ fetch(`${base}/steward-status`)
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null),
+ ]);
+
+ if (!mountedRef.current) return;
+
+ setData({
+ addresses: addrRes.status === "fulfilled" ? addrRes.value : null,
+ balances: balRes.status === "fulfilled" ? balRes.value : null,
+ steward: stewardRes.status === "fulfilled" ? stewardRes.value : null,
+ });
+ setError(null);
+ } catch (err) {
+ if (!mountedRef.current) return;
+ setError(err instanceof Error ? err.message : "Failed to load wallet data");
+ } finally {
+ if (mountedRef.current) setLoading(false);
+ }
+ }, [base]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ fetchData();
+ intervalRef.current = setInterval(fetchData, 30_000);
+ return () => {
+ mountedRef.current = false;
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [fetchData]);
+
+ if (loading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
Failed to load wallet data
+
{error}
+
+
+ );
+ }
+
+ const hasEvm = Boolean(data.addresses?.evmAddress);
+ const hasSolana = Boolean(data.addresses?.solanaAddress);
+
+ if (!hasEvm && !hasSolana) {
+ return (
+
+
No wallets configured for this agent
+
+ );
+ }
+
+ return (
+
+ {/* Addresses */}
+
+
+
+ {hasEvm && (
+
+
EVM
+
+
+ {data.addresses?.evmAddress}
+
+
+
+
+ )}
+ {hasSolana && (
+
+
+ Solana
+
+
+
+ {data.addresses?.solanaAddress}
+
+
+
+
+ )}
+
+
+
+ {/* Steward status */}
+ {data.steward && (
+
+
+
+
+
+ Status
+
+
+
+
+ {data.steward.configured
+ ? data.steward.connected
+ ? "Connected"
+ : "Configured (disconnected)"
+ : "Not configured"}
+
+
+
+ {data.steward.version && (
+
+
+ Version
+
+
{data.steward.version}
+
+ )}
+
+
+ )}
+
+ {/* Balances */}
+ {data.balances?.evm && data.balances.evm.length > 0 && (
+
+
+
+ {data.balances.evm.map((chain) => (
+
+
+
+ {chain.chainName} ({chain.chainId})
+
+
+
+
+
+ {chain.nativeSymbol ?? "ETH"}
+
+
+
+ {formatNative(chain.nativeBalance, chain.nativeSymbol)}
+
+ {chain.nativeUsdValue != null && (
+
+ {formatUsd(chain.nativeUsdValue)}
+
+ )}
+
+
+ {chain.tokens?.map((token, i) => (
+
+
{token.symbol}
+
+
+ {token.balance} {token.symbol}
+
+ {token.usdValue != null && (
+
+ {formatUsd(token.usdValue)}
+
+ )}
+
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+ {/* Live indicator */}
+
+
+ LIVE · 30S
+
+
+ );
+}
diff --git a/packages/ui/src/components/dashboard/dashboard-action-cards.tsx b/packages/ui/src/components/dashboard/dashboard-action-cards.tsx
index 1e2bf0bbb..ef587c72c 100644
--- a/packages/ui/src/components/dashboard/dashboard-action-cards.tsx
+++ b/packages/ui/src/components/dashboard/dashboard-action-cards.tsx
@@ -46,9 +46,7 @@ export function DashboardActionCards({ creditBalance, className }: DashboardActi
Chat with Eliza
-
- Talk to the default AI agent or any of your custom characters
-
+
Talk to any agent
{/* Glow effect */}
@@ -67,9 +65,7 @@ export function DashboardActionCards({ creditBalance, className }: DashboardActi
Create an Agent
-
- Build and customize your own AI character with unique personality
-
+
Build a custom agent
@@ -89,9 +85,7 @@ export function DashboardActionCards({ creditBalance, className }: DashboardActi
Credits & Billing
-
- View balance, purchase credits, and manage your billing
-
+
Top up credits and view usage
@@ -105,9 +99,7 @@ export function DashboardActionCards({ creditBalance, className }: DashboardActi
Developer APIs
-
- Integrate AI agents into your own apps and services
-
+
Build with the API
= {},
+): Pick {
+ return {
+ id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ headscale_ip: "100.64.0.5",
+ web_ui_port: 20100,
+ bridge_port: 18800,
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN = "milady.shad0w.xyz";
+});
+
+afterEach(() => {
+ if (savedAgentBaseDomain === undefined) {
+ delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN;
+ } else {
+ process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN = savedAgentBaseDomain;
+ }
+});
+
+describe("getMiladyAgentPublicWebUiUrl", () => {
+ test("uses configured canonical domain when available", () => {
+ expect(getMiladyAgentPublicWebUiUrl(makeSandbox())).toBe(
+ "https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.milady.shad0w.xyz",
+ );
+ });
+
+ test("normalizes configured domains with protocol and trailing path", () => {
+ expect(
+ getMiladyAgentPublicWebUiUrl(makeSandbox(), {
+ baseDomain: "https://milady.shad0w.xyz/dashboard",
+ path: "/chat",
+ }),
+ ).toBe("https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.milady.shad0w.xyz/chat");
+ });
+
+ test("can fall back to the placeholder domain for compat callers", () => {
+ delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN;
+
+ expect(
+ getMiladyAgentPublicWebUiUrl(makeSandbox(), {
+ allowExampleFallback: true,
+ }),
+ ).toBe("https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.agents.example.com");
+ });
+});
+
+describe("getPreferredMiladyAgentWebUiUrl", () => {
+ test("prefers canonical public url over direct node access", () => {
+ expect(getPreferredMiladyAgentWebUiUrl(makeSandbox())).toBe(
+ "https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.milady.shad0w.xyz",
+ );
+ });
+
+ test("falls back to direct headscale url when no canonical domain is configured", () => {
+ delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN;
+
+ expect(getPreferredMiladyAgentWebUiUrl(makeSandbox())).toBe("http://100.64.0.5:20100");
+ });
+
+ test("falls back to bridge port when web ui port is missing", () => {
+ delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN;
+
+ expect(getPreferredMiladyAgentWebUiUrl(makeSandbox({ web_ui_port: null }))).toBe(
+ "http://100.64.0.5:18800",
+ );
+ });
+});
+
+describe("getMiladyAgentDirectWebUiUrl", () => {
+ test("returns null when headscale access is unavailable", () => {
+ expect(getMiladyAgentDirectWebUiUrl(makeSandbox({ headscale_ip: null }))).toBeNull();
+ });
+});
diff --git a/vercel.json b/vercel.json
index c1f06b407..22c810e69 100644
--- a/vercel.json
+++ b/vercel.json
@@ -26,10 +26,6 @@
"path": "/api/v1/cron/health-check",
"schedule": "* * * * *"
},
- {
- "path": "/api/v1/cron/process-provisioning-jobs",
- "schedule": "* * * * *"
- },
{
"path": "/api/cron/sample-eliza-price",
"schedule": "*/5 * * * *"
@@ -46,6 +42,10 @@
"path": "/api/cron/release-pending-earnings",
"schedule": "0 0 * * *"
},
+ {
+ "path": "/api/cron/cleanup-stuck-provisioning",
+ "schedule": "*/5 * * * *"
+ },
{
"path": "/api/cron/cleanup-anonymous-sessions",
"schedule": "0 */6 * * *"