Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -739,3 +739,11 @@ ELIZA_APP_WHATSAPP_PHONE_NUMBER=
# Create a dedicated org + user in the platform for this purpose.
# WAIFU_SERVICE_ORG_ID=uuid-of-waifu-service-org
# WAIFU_SERVICE_USER_ID=uuid-of-waifu-service-user

# ── Steward Wallet Provider (Phase 1) ────────────────────────────────────
# Set USE_STEWARD_FOR_NEW_WALLETS=true to route new agent wallets through Steward
# instead of Privy. Existing wallets are unaffected.
# USE_STEWARD_FOR_NEW_WALLETS=false
# STEWARD_API_URL=http://localhost:3200
# STEWARD_TENANT_API_KEY=stw_your_key_here
# STEWARD_TENANT_ID=milady-cloud
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ storybook-static
DEV_TO_PROD_AUDIT.md
TRIAGE_NOTES.md
.env.preview
.env.local.bak-*
19 changes: 18 additions & 1 deletion app/api/compat/agents/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/lib/api/compat-envelope";
import { reusesExistingMiladyCharacter } from "@/lib/services/milady-agent-config";
import { miladySandboxService } from "@/lib/services/milady-sandbox";
import { getStewardAgent } from "@/lib/services/steward-client";
import { logger } from "@/lib/utils/logger";
import { requireCompatAuth } from "../../_lib/auth";
import { handleCompatCorsOptions, withCompatCors } from "../../_lib/cors";
Expand Down Expand Up @@ -41,7 +42,23 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
);
}

return withCompatCors(NextResponse.json(envelope(toCompatAgent(agent))), CORS_METHODS);
// Resolve wallet info for Docker-backed agents
let walletInfo: { address: string | null; provider: "steward" | "privy" | null } | undefined;
if (agent.node_id) {
try {
const stewardAgent = await getStewardAgent(agentId);
if (stewardAgent?.walletAddress) {
walletInfo = { address: stewardAgent.walletAddress, provider: "steward" };
}
} catch {
// Steward unreachable — wallet fields will be null
}
}

return withCompatCors(
NextResponse.json(envelope(toCompatAgent(agent, walletInfo))),
CORS_METHODS,
);
} catch (err) {
return handleCompatError(err, CORS_METHODS);
}
Expand Down
2 changes: 1 addition & 1 deletion app/api/compat/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function GET(request: NextRequest) {
try {
const { user } = await requireCompatAuth(request);
const agents = await miladySandboxService.listAgents(user.organization_id);
return withCompatCors(NextResponse.json(envelope(agents.map(toCompatAgent))), CORS_METHODS);
return withCompatCors(NextResponse.json(envelope(agents.map((a) => toCompatAgent(a)))), CORS_METHODS);
} catch (err) {
return handleCompatError(err, CORS_METHODS);
}
Expand Down
32 changes: 31 additions & 1 deletion app/api/v1/admin/docker-containers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { NextRequest, NextResponse } from "next/server";
import { dbRead } from "@/db/helpers";
import { type MiladySandboxStatus, miladySandboxes } from "@/db/schemas/milady-sandboxes";
import { requireAdmin } from "@/lib/auth";
import { getStewardAgent } from "@/lib/services/steward-client";
import { logger } from "@/lib/utils/logger";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -97,10 +98,39 @@ export async function GET(request: NextRequest) {
.orderBy(desc(miladySandboxes.created_at))
.limit(limit);

// Enrich containers with wallet info from Steward (best-effort, parallel)
const enrichedContainers = await Promise.all(
containers.map(async (c) => {
let walletAddress: string | null = null;
let walletProvider: "steward" | "privy" | null = null;

// All Docker-node containers use Steward wallets
if (c.nodeId) {
try {
const stewardAgent = await getStewardAgent(c.id);
if (stewardAgent?.walletAddress) {
walletAddress = stewardAgent.walletAddress;
walletProvider = "steward";
} else {
walletProvider = "steward"; // registered but wallet pending
}
} catch {
// Steward unreachable — leave as null
}
}

return {
...c,
walletAddress,
walletProvider,
};
}),
);

return NextResponse.json({
success: true,
data: {
containers,
containers: enrichedContainers,
total: totalCount, // actual total matching filters
returned: containers.length, // number returned in this page
filters: {
Expand Down
7 changes: 3 additions & 4 deletions app/api/v1/milady/agents/[agentId]/pairing-token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,11 @@ export async function POST(
);
}

const tokenService = getPairingTokenService();
const envVars = (sandbox.environment_vars ?? {}) as Record<string, string>;
const hasUiApiToken = Boolean(
const supportsUiTokenPairing = Boolean(
envVars.MILADY_API_TOKEN?.trim() || envVars.ELIZA_API_TOKEN?.trim(),
);

const tokenService = getPairingTokenService();
const pairingToken = await tokenService.generateToken(
user.id,
user.organization_id,
Expand All @@ -77,7 +76,7 @@ export async function POST(
success: true,
data: {
token: pairingToken,
redirectUrl: hasUiApiToken ? `${webUiUrl}/pair?token=${pairingToken}` : webUiUrl,
redirectUrl: supportsUiTokenPairing ? `${webUiUrl}/pair?token=${pairingToken}` : webUiUrl,
expiresIn: 60,
},
}),
Expand Down
44 changes: 44 additions & 0 deletions app/api/v1/milady/agents/[agentId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/db/client";
import { userCharactersRepository } from "@/db/repositories/characters";
import { agentServerWallets } from "@/db/schemas/agent-server-wallets";
import { errorToResponse } from "@/lib/api/errors";
import { requireAuthOrApiKeyWithOrg } from "@/lib/auth";
import { reusesExistingMiladyCharacter } from "@/lib/services/milady-agent-config";
import { miladySandboxService } from "@/lib/services/milady-sandbox";
import { getStewardAgent } from "@/lib/services/steward-client";
import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors";
import { logger } from "@/lib/utils/logger";

Expand Down Expand Up @@ -70,6 +74,42 @@ export async function GET(
tokenTicker = typeof cfg?.tokenTicker === "string" ? cfg.tokenTicker : null;
}

// Resolve wallet info — Docker agents use Steward, others use Privy
let walletAddress: string | null = null;
let walletProvider: "steward" | "privy" | null = null;
let walletStatus: "active" | "pending" | "none" | "error" = "none";

const isDockerAgent = !!agent.node_id;

if (isDockerAgent) {
// Steward-backed agent — query Steward for wallet address
try {
const stewardAgent = await getStewardAgent(agentId);
if (stewardAgent?.walletAddress) {
walletAddress = stewardAgent.walletAddress;
walletProvider = "steward";
walletStatus = "active";
} else if (stewardAgent) {
walletProvider = "steward";
walletStatus = "pending";
}
} catch (err) {
logger.warn(`[milady-api] Steward wallet lookup failed for ${agentId}`, { err });
}
}

// Fallback: check for Privy server wallet via character_id
if (!walletAddress && agent.character_id) {
const walletRecord = await db.query.agentServerWallets.findFirst({
where: eq(agentServerWallets.character_id, agent.character_id),
});
if (walletRecord) {
walletAddress = walletRecord.address;
walletProvider = "privy";
walletStatus = "active";
}
}

return applyCorsHeaders(
NextResponse.json({
success: true,
Expand All @@ -90,6 +130,10 @@ export async function GET(
token_chain: tokenChain,
token_name: tokenName,
token_ticker: tokenTicker,
// Wallet info
walletAddress,
walletProvider,
walletStatus,
},
}),
CORS_METHODS,
Expand Down
133 changes: 133 additions & 0 deletions app/api/v1/milady/agents/[agentId]/wallet/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* GET /api/v1/milady/agents/[agentId]/wallet
*
* Returns detailed wallet information for an agent, including address,
* provider type, balance, and chain info.
*
* For steward-backed agents: queries Steward API for live wallet data.
* For privy-backed agents: returns DB-stored wallet info.
*/

import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/db/client";
import { agentServerWallets } from "@/db/schemas/agent-server-wallets";
import { errorToResponse } from "@/lib/api/errors";
import { requireAuthOrApiKeyWithOrg } from "@/lib/auth";
import { miladySandboxService } from "@/lib/services/milady-sandbox";
import { getStewardWalletInfo } from "@/lib/services/steward-client";
import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors";
import { logger } from "@/lib/utils/logger";

export const dynamic = "force-dynamic";

const CORS_METHODS = "GET, OPTIONS";

export function OPTIONS() {
return handleCorsOptions(CORS_METHODS);
}

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ agentId: string }> },
) {
try {
const { user } = await requireAuthOrApiKeyWithOrg(request);
const { agentId } = await params;

// Verify the agent belongs to this user's org
const agent = await miladySandboxService.getAgent(agentId, user.organization_id);
if (!agent) {
return applyCorsHeaders(
NextResponse.json({ success: false, error: "Agent not found" }, { status: 404 }),
CORS_METHODS,
);
}

// Check if there's a privy server wallet linked by character_id
let privyWallet: { address: string; chain_type: string } | null = null;
if (agent.character_id) {
const walletRecord = await db.query.agentServerWallets.findFirst({
where: eq(agentServerWallets.character_id, agent.character_id),
});
if (walletRecord) {
privyWallet = { address: walletRecord.address, chain_type: walletRecord.chain_type };
}
}

// All Docker-node agents use Steward for wallet management.
// Try Steward first for any agent with a node_id (Docker-backed).
const isDockerAgent = !!agent.node_id;

if (isDockerAgent) {
const stewardInfo = await getStewardWalletInfo(agentId);

if (stewardInfo) {
return applyCorsHeaders(
NextResponse.json({
success: true,
data: {
agentId,
walletAddress: stewardInfo.walletAddress,
walletProvider: "steward",
walletStatus: stewardInfo.walletStatus,
balance: stewardInfo.balance,
chain: stewardInfo.chain ?? "base",
// Include privy wallet info if it exists (legacy/dual period)
...(privyWallet
? {
legacyWallet: {
address: privyWallet.address,
provider: "privy",
chainType: privyWallet.chain_type,
},
}
: {}),
},
}),
CORS_METHODS,
);
}

// Steward unreachable — fall through to privy wallet if available
logger.warn(`[wallet-api] Steward unreachable for agent ${agentId}, falling back to DB`);
}

// Privy / DB fallback
if (privyWallet) {
return applyCorsHeaders(
NextResponse.json({
success: true,
data: {
agentId,
walletAddress: privyWallet.address,
walletProvider: "privy",
walletStatus: "active",
balance: null, // Privy doesn't expose balance via our API
chain: privyWallet.chain_type === "evm" ? "base" : privyWallet.chain_type,
},
}),
CORS_METHODS,
);
}

// No wallet found
return applyCorsHeaders(
NextResponse.json({
success: true,
data: {
agentId,
walletAddress: null,
walletProvider: null,
walletStatus: "none",
balance: null,
chain: null,
},
}),
CORS_METHODS,
);
} catch (error) {
logger.error("[wallet-api] GET /agents/[agentId]/wallet error", { error });
return applyCorsHeaders(errorToResponse(error), CORS_METHODS);
}
}
14 changes: 2 additions & 12 deletions app/dashboard/containers/agents/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { requireAuthWithOrg } from "@/lib/auth";
import { statusBadgeColor, statusDotColor } from "@/lib/constants/sandbox-status";
import { getPreferredMiladyAgentWebUiUrl } from "@/lib/milady-web-ui";
import { adminService } from "@/lib/services/admin";
import { miladySandboxService } from "@/lib/services/milady-sandbox";
import { MiladyAgentActions } from "@/packages/ui/src/components/containers/agent-actions";
Expand Down Expand Up @@ -79,7 +78,6 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) {
const isAdmin = await adminService.isUserAdmin(user.id).catch(() => false);

const isDockerBacked = !!agent.node_id;
const webUiUrl = getPreferredMiladyAgentWebUiUrl(agent);
const sshCommand = agent.headscale_ip ? `ssh root@${agent.headscale_ip}` : null;

const badgeColor = statusBadgeColor(agent.status);
Expand All @@ -99,7 +97,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) {
<span>Containers</span>
</Link>

{webUiUrl && agent.status === "running" && <MiladyConnectButton agentId={agent.id} />}
{agent.status === "running" && <MiladyConnectButton agentId={agent.id} />}
</div>

{/* ── Agent header ── */}
Expand Down Expand Up @@ -226,14 +224,6 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) {
)}
</div>

{webUiUrl && (
<div className="border border-white/10 bg-black/40 px-4 py-3 flex items-center gap-3 text-sm">
<span className="text-[11px] uppercase tracking-widest text-white/35 shrink-0">
Web UI
</span>
<span className="text-white/50 font-mono text-xs break-all">{webUiUrl}</span>
</div>
)}
</section>
)}

Expand Down Expand Up @@ -300,7 +290,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) {
)}

{/* ── Actions card ── */}
<MiladyAgentActions agentId={agent.id} status={agent.status} webUiUrl={webUiUrl} />
<MiladyAgentActions agentId={agent.id} status={agent.status} />

{/* ── Backups / history ── */}
<MiladyBackupsPanel
Expand Down
Loading
Loading