diff --git a/.env.example b/.env.example index 1f516a86f..3aa5968bd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index df2ef8b16..12f650779 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ storybook-static DEV_TO_PROD_AUDIT.md TRIAGE_NOTES.md .env.preview +.env.local.bak-* diff --git a/app/api/compat/agents/[id]/route.ts b/app/api/compat/agents/[id]/route.ts index cf9df9d8e..788fae298 100644 --- a/app/api/compat/agents/[id]/route.ts +++ b/app/api/compat/agents/[id]/route.ts @@ -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"; @@ -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); } diff --git a/app/api/compat/agents/route.ts b/app/api/compat/agents/route.ts index 782b394a0..98a964f82 100644 --- a/app/api/compat/agents/route.ts +++ b/app/api/compat/agents/route.ts @@ -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); } diff --git a/app/api/v1/admin/docker-containers/route.ts b/app/api/v1/admin/docker-containers/route.ts index 8682056df..49e175d29 100644 --- a/app/api/v1/admin/docker-containers/route.ts +++ b/app/api/v1/admin/docker-containers/route.ts @@ -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"; @@ -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: { diff --git a/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts b/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts index 5d5781bfa..8410f93f2 100644 --- a/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts +++ b/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts @@ -59,12 +59,11 @@ export async function POST( ); } + const tokenService = getPairingTokenService(); const envVars = (sandbox.environment_vars ?? {}) as Record; - 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, @@ -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, }, }), diff --git a/app/api/v1/milady/agents/[agentId]/route.ts b/app/api/v1/milady/agents/[agentId]/route.ts index 8b2913d01..262d1fa5e 100644 --- a/app/api/v1/milady/agents/[agentId]/route.ts +++ b/app/api/v1/milady/agents/[agentId]/route.ts @@ -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"; @@ -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, @@ -90,6 +130,10 @@ export async function GET( token_chain: tokenChain, token_name: tokenName, token_ticker: tokenTicker, + // Wallet info + walletAddress, + walletProvider, + walletStatus, }, }), CORS_METHODS, diff --git a/app/api/v1/milady/agents/[agentId]/wallet/route.ts b/app/api/v1/milady/agents/[agentId]/wallet/route.ts new file mode 100644 index 000000000..d1be647dc --- /dev/null +++ b/app/api/v1/milady/agents/[agentId]/wallet/route.ts @@ -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); + } +} diff --git a/app/dashboard/containers/agents/[id]/page.tsx b/app/dashboard/containers/agents/[id]/page.tsx index edc58604d..82656bdc9 100644 --- a/app/dashboard/containers/agents/[id]/page.tsx +++ b/app/dashboard/containers/agents/[id]/page.tsx @@ -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"; @@ -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); @@ -99,7 +97,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { Containers - {webUiUrl && agent.status === "running" && } + {agent.status === "running" && } {/* ── Agent header ── */} @@ -226,14 +224,6 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} - {webUiUrl && ( -
- - Web UI - - {webUiUrl} -
- )} )} @@ -300,7 +290,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} {/* ── Actions card ── */} - + {/* ── Backups / history ── */} false); - const isDockerBacked = !!agent.node_id; - const webUiUrl = getPreferredMiladyAgentWebUiUrl(agent); + // Fetch wallet info server-side (best-effort — never blocks the page) + const isDockerBacked_early = !!agent.node_id; + const walletInfo: StewardWalletInfo | null = isDockerBacked_early + ? await getStewardWalletInfo(agent.id).catch(() => null) + : null; + + const isDockerBacked = isDockerBacked_early; const sshCommand = agent.headscale_ip ? `ssh root@${agent.headscale_ip}` : null; const badgeColor = statusBadgeColor(agent.status); @@ -108,7 +113,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { Milady Instances - {webUiUrl && agent.status === "running" && } + {agent.status === "running" && } {/* ── Agent header ── */} @@ -234,6 +239,9 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} + {/* ── Wallet info ── */} + + {/* ── Docker infrastructure (admin) ── */} {isAdmin && isDockerBacked && (
@@ -259,14 +267,6 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} - {webUiUrl && ( -
- - Web UI - - {webUiUrl} -
- )}
)} @@ -333,7 +333,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} {/* ── Actions card ── */} - + {/* ── Backups / history ── */} +
+ +

Wallet

+
+ + {!isDockerBacked ? ( + // Non-docker agents don't use Steward wallets yet +
+

Wallet management is not available for this agent type.

+
+ ) : !hasWallet ? ( + // No wallet provisioned +
+ +

No wallet provisioned

+
+ ) : ( + // Wallet details +
+ {/* Address row */} +
+ +
+

Address

+
+ + {walletInfo.walletAddress} + {truncateAddress(walletInfo.walletAddress!)} + + {/* Basescan link */} + + + Basescan + +
+
+
+ + {/* Meta row: provider · status · chain · balance */} +
+ {/* Provider */} +
+

Provider

+ + {walletInfo.walletProvider === "steward" ? "Steward" : "Privy"} + +
+ + {/* Status */} +
+

Status

+ + + {walletInfo.walletStatus ?? "unknown"} + +
+ + {/* Chain */} +
+

Chain

+

+ {normalizeChain(walletInfo.chain)} +

+
+ + {/* Balance */} +
+

Balance

+

+ {walletInfo.balance ?? "—"} +

+
+
+
+ )} + + ); +} + // ---------------------------------------------------------------- // Sub-components // ---------------------------------------------------------------- diff --git a/bun.lock b/bun.lock index f6966c283..064c53673 100644 --- a/bun.lock +++ b/bun.lock @@ -73,6 +73,7 @@ "@solana/web3.js": "^1.98.4", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.4", + "@stwd/sdk": "^0.3.0", "@tabler/icons-react": "^3.36.1", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", @@ -1493,6 +1494,8 @@ "@stripe/stripe-js": ["@stripe/stripe-js@8.9.0", "", {}, "sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww=="], + "@stwd/sdk": ["@stwd/sdk@0.3.0", "", {}, "sha512-GUx+6lskxFA9PTlPtfVeZbKiIb0mZelBNjvodr8fiayyJIyJVbAzmr2gMLhbM88AGSqqp+BH6ur2GUhBosFl+Q=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], diff --git a/package.json b/package.json index ba0c5819b..ad7f7791c 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "@solana/web3.js": "^1.98.4", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.4", + "@stwd/sdk": "^0.3.0", "@tabler/icons-react": "^3.36.1", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", diff --git a/packages/db/migrations/0058_add_steward_wallet_provider.sql b/packages/db/migrations/0058_add_steward_wallet_provider.sql new file mode 100644 index 000000000..e75f3c7dc --- /dev/null +++ b/packages/db/migrations/0058_add_steward_wallet_provider.sql @@ -0,0 +1,40 @@ +-- Migration: Add Steward wallet provider support (dual provider routing) +-- Phase 1 of Privy → Steward wallet migration. +-- +-- This migration is additive and zero-downtime safe: +-- - Adds wallet_provider column (defaults to 'privy' for existing rows) +-- - Adds Steward reference columns (nullable) +-- - Makes privy_wallet_id nullable for future Steward-only wallets +-- - Adds a CHECK constraint ensuring exactly one provider ID is present + +BEGIN; + +-- 1. Add wallet_provider routing column (existing rows get 'privy') +ALTER TABLE "agent_server_wallets" + ADD COLUMN IF NOT EXISTS "wallet_provider" TEXT NOT NULL DEFAULT 'privy'; + +-- 2. Add Steward reference columns +ALTER TABLE "agent_server_wallets" + ADD COLUMN IF NOT EXISTS "steward_agent_id" TEXT, + ADD COLUMN IF NOT EXISTS "steward_tenant_id" TEXT; + +-- 3. Make privy_wallet_id nullable (Steward wallets won't have one) +ALTER TABLE "agent_server_wallets" + ALTER COLUMN "privy_wallet_id" DROP NOT NULL; + +-- 4. Constraint: exactly one provider's ID must be present +ALTER TABLE "agent_server_wallets" + ADD CONSTRAINT "wallet_provider_id_check" CHECK ( + ("wallet_provider" = 'privy' AND "privy_wallet_id" IS NOT NULL) OR + ("wallet_provider" = 'steward' AND "steward_agent_id" IS NOT NULL) + ); + +-- 5. Indexes for Steward lookups +CREATE INDEX IF NOT EXISTS "idx_asw_steward_agent" + ON "agent_server_wallets" ("steward_agent_id") + WHERE "steward_agent_id" IS NOT NULL; + +CREATE INDEX IF NOT EXISTS "idx_asw_wallet_provider" + ON "agent_server_wallets" ("wallet_provider"); + +COMMIT; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 86470d90e..da5179db5 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -372,6 +372,27 @@ "when": 1773868800000, "tag": "0053_add_milady_billing_columns", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "when": 1774300000000, + "tag": "0056_add_billing_status_check", + "breakpoints": true + }, + { + "idx": 54, + "version": "7", + "when": 1774300100000, + "tag": "0057_update_milady_hourly_rate_default", + "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1774800000000, + "tag": "0058_add_steward_wallet_provider", + "breakpoints": true } ] } diff --git a/packages/db/schemas/agent-server-wallets.ts b/packages/db/schemas/agent-server-wallets.ts index 152616454..875ae7966 100644 --- a/packages/db/schemas/agent-server-wallets.ts +++ b/packages/db/schemas/agent-server-wallets.ts @@ -7,9 +7,12 @@ import { users } from "./users"; /** * Agent Server Wallets table schema. * - * Tracks secure server-side wallets provisioned via Privy for agents. - * The private keys reside entirely within Privy KMS. - * The client pubkey is used to verify RPC requests from the remote agent. + * Tracks secure server-side wallets provisioned for agents. + * Supports dual providers: Privy (legacy) and Steward (new). + * + * The `wallet_provider` column routes RPC calls to the correct backend. + * New wallets default to 'steward' when the feature flag is enabled; + * existing wallets remain on 'privy' until explicitly migrated. */ export const agentServerWallets = pgTable( "agent_server_wallets", @@ -25,8 +28,15 @@ export const agentServerWallets = pgTable( onDelete: "set null", }), - // The ID of the wallet in Privy - privy_wallet_id: text("privy_wallet_id").notNull(), + // Provider routing: 'privy' (legacy) or 'steward' (new) + wallet_provider: text("wallet_provider").notNull().default("privy"), + + // Privy wallet ID (nullable — only set for privy-managed wallets) + privy_wallet_id: text("privy_wallet_id"), + + // Steward references (only set for steward-managed wallets) + steward_agent_id: text("steward_agent_id"), + steward_tenant_id: text("steward_tenant_id"), // The public address of the provisioned wallet address: text("address").notNull(), @@ -47,6 +57,10 @@ export const agentServerWallets = pgTable( privy_wallet_idx: index("agent_server_wallets_privy_wallet_idx").on(table.privy_wallet_id), address_idx: index("agent_server_wallets_address_idx").on(table.address), client_address_idx: index("agent_server_wallets_client_address_idx").on(table.client_address), + steward_agent_idx: index("agent_server_wallets_steward_agent_idx").on(table.steward_agent_id), + wallet_provider_idx: index("agent_server_wallets_wallet_provider_idx").on( + table.wallet_provider, + ), }), ); diff --git a/packages/lib/api/compat-envelope.ts b/packages/lib/api/compat-envelope.ts index 259e1a61a..bad2fa75e 100644 --- a/packages/lib/api/compat-envelope.ts +++ b/packages/lib/api/compat-envelope.ts @@ -53,12 +53,24 @@ export interface CompatAgentShape { database_status: string; error_message: string | null; last_heartbeat_at: string | null; + // wallet info (Phase 1 — steward migration) + wallet_address: string | null; + wallet_provider: "steward" | "privy" | null; } /** * Translate a MiladySandbox row to the canonical Agent shape. */ -export function toCompatAgent(sandbox: MiladySandbox): CompatAgentShape { +/** + * Translate a MiladySandbox row to the canonical Agent shape. + * + * Optionally accepts pre-resolved wallet info to avoid redundant Steward calls. + * If not provided, wallet fields default to null. + */ +export function toCompatAgent( + sandbox: MiladySandbox, + walletInfo?: { address: string | null; provider: "steward" | "privy" | null }, +): CompatAgentShape { const webUiUrl = getAgentWebUiUrl(sandbox); return { @@ -78,6 +90,9 @@ export function toCompatAgent(sandbox: MiladySandbox): CompatAgentShape { database_status: sandbox.database_status, error_message: sandbox.error_message, last_heartbeat_at: sandbox.last_heartbeat_at ? toISO(sandbox.last_heartbeat_at) : null, + // Wallet info — Docker agents use Steward, everything else defaults to null + wallet_address: walletInfo?.address ?? null, + wallet_provider: walletInfo?.provider ?? (sandbox.node_id ? "steward" : null), }; } diff --git a/packages/lib/config/feature-flags.ts b/packages/lib/config/feature-flags.ts index df50aea07..6ab129fff 100644 --- a/packages/lib/config/feature-flags.ts +++ b/packages/lib/config/feature-flags.ts @@ -122,3 +122,6 @@ export function getFeatureForRoute(pathname: string): FeatureFlag | null { } return null; } + + +// Steward wallet migration flags live in wallet-provider-flags.ts diff --git a/packages/lib/config/wallet-provider-flags.ts b/packages/lib/config/wallet-provider-flags.ts new file mode 100644 index 000000000..25cf0dd88 --- /dev/null +++ b/packages/lib/config/wallet-provider-flags.ts @@ -0,0 +1,19 @@ +/** + * Feature flags for the Privy → Steward wallet migration. + * + * Controlled via environment variables. Defaults are conservative + * (Privy remains the default) so the rollout is opt-in. + * + * Separate from the main feature-flags.ts to avoid coupling the + * migration with the existing UI feature flag system. + */ +export const WALLET_PROVIDER_FLAGS = { + /** When true, new agent wallets are created via Steward instead of Privy. */ + USE_STEWARD_FOR_NEW_WALLETS: process.env.USE_STEWARD_FOR_NEW_WALLETS === "true", + + /** When true, the migration script is allowed to convert Privy wallets to Steward. */ + ALLOW_PRIVY_MIGRATION: process.env.ALLOW_PRIVY_MIGRATION === "true", + + /** When true, Privy wallet creation is fully disabled (Phase 3). */ + DISABLE_PRIVY_WALLETS: process.env.DISABLE_PRIVY_WALLETS === "true", +} as const; diff --git a/packages/lib/milady-web-ui.ts b/packages/lib/milady-web-ui.ts index 763645b7b..4d9e1b074 100644 --- a/packages/lib/milady-web-ui.ts +++ b/packages/lib/milady-web-ui.ts @@ -99,5 +99,5 @@ export function getClientSafeMiladyAgentWebUiUrl( return applyPath(sandbox.canonicalWebUiUrl, options.path); } - return getMiladyAgentDirectWebUiUrl(sandbox, options); + return null; } diff --git a/packages/lib/services/managed-milady-env.ts b/packages/lib/services/managed-milady-env.ts index ab40cb5a1..314a738e4 100644 --- a/packages/lib/services/managed-milady-env.ts +++ b/packages/lib/services/managed-milady-env.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { apiKeysService } from "./api-keys"; +import { resolveStewardContainerUrl } from "./docker-sandbox-utils"; const DEFAULT_MILADY_APP_URL = "https://app.milady.ai"; const DEFAULT_CLOUD_PUBLIC_URL = "https://www.elizacloud.ai"; @@ -126,6 +127,8 @@ export async function prepareManagedMiladyEnvironment(params: { existingEnv?: Record | null; organizationId: string; userId: string; + /** Sandbox/agent ID — used as STEWARD_AGENT_ID for Docker-backed agents. */ + sandboxId?: string; }): Promise { const existingEnv = { ...(params.existingEnv ?? {}) }; const userApiKey = await getOrCreateUserApiKey(params.userId, params.organizationId); @@ -143,6 +146,22 @@ export async function prepareManagedMiladyEnvironment(params: { ELIZAOS_CLOUD_BASE_URL: resolveCloudPublicUrl(), }; + // Steward env vars — Docker-backed agents need these to talk to the wallet vault. + // STEWARD_API_URL is resolved for container reachability (host.docker.internal + // or the explicit override). STEWARD_AGENT_ID maps to the sandbox ID. + // STEWARD_AGENT_TOKEN is set during provisioning in docker-sandbox-provider.ts. + const stewardContainerUrl = resolveStewardContainerUrl( + process.env.STEWARD_API_URL || "http://localhost:3200", + process.env.STEWARD_CONTAINER_URL, + ); + + if (!existingEnv.STEWARD_API_URL) { + environmentVars.STEWARD_API_URL = stewardContainerUrl; + } + if (params.sandboxId && !existingEnv.STEWARD_AGENT_ID) { + environmentVars.STEWARD_AGENT_ID = params.sandboxId; + } + const changed = JSON.stringify(existingEnv) !== JSON.stringify(environmentVars); return { diff --git a/packages/lib/services/milady-sandbox.ts b/packages/lib/services/milady-sandbox.ts index c1574f233..5fadbd833 100644 --- a/packages/lib/services/milady-sandbox.ts +++ b/packages/lib/services/milady-sandbox.ts @@ -279,6 +279,7 @@ export class MiladySandboxService { existingEnv: (rec.environment_vars as Record) ?? {}, organizationId: rec.organization_id, userId: rec.user_id, + sandboxId: agentId, }); if (managedEnvironment.changed) { diff --git a/packages/lib/services/server-wallets.ts b/packages/lib/services/server-wallets.ts index 7fe171be0..91e7c5415 100644 --- a/packages/lib/services/server-wallets.ts +++ b/packages/lib/services/server-wallets.ts @@ -2,11 +2,20 @@ import type { WalletApiWalletResponseType } from "@privy-io/server-auth"; import { eq } from "drizzle-orm"; import { verifyMessage } from "viem"; import { db } from "@/db/client"; -import { agentServerWallets } from "@/db/schemas/agent-server-wallets"; +import { + agentServerWallets, + type AgentServerWallet, +} from "@/db/schemas/agent-server-wallets"; import { getPrivyClient } from "@/lib/auth/privy-client"; import { cache } from "@/lib/cache/client"; +import { WALLET_PROVIDER_FLAGS } from "@/lib/config/wallet-provider-flags"; +import { getStewardClient } from "@/lib/services/steward-client"; import { logger } from "@/lib/utils/logger"; +// --------------------------------------------------------------------------- +// Error classes +// --------------------------------------------------------------------------- + class WalletAlreadyExistsError extends Error { constructor() { super("Wallet already exists for this client address"); @@ -40,12 +49,16 @@ class RpcReplayError extends Error { class ServerWalletNotFoundError extends Error { constructor() { super( - "Server wallet not found: No provisioned Privy Server Wallet matches this client address.", + "Server wallet not found: No provisioned wallet matches this client address.", ); this.name = "ServerWalletNotFoundError"; } } +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + export interface ProvisionWalletParams { organizationId: string; userId: string; @@ -54,7 +67,90 @@ export interface ProvisionWalletParams { chainType: "evm" | "solana"; } -export async function provisionServerWallet({ +export interface RpcPayload { + method: string; + params: unknown[]; + timestamp: number; + nonce: string; +} + +export interface ExecuteParams { + clientAddress: string; + payload: RpcPayload; + signature: `0x${string}`; +} + +// --------------------------------------------------------------------------- +// Provision — top-level router +// --------------------------------------------------------------------------- + +export async function provisionServerWallet(params: ProvisionWalletParams) { + if (WALLET_PROVIDER_FLAGS.USE_STEWARD_FOR_NEW_WALLETS) { + return provisionStewardWallet(params); + } + return provisionPrivyWallet(params); +} + +// --------------------------------------------------------------------------- +// Provision — Steward (new) +// --------------------------------------------------------------------------- + +async function provisionStewardWallet({ + organizationId, + userId, + characterId, + clientAddress, + chainType, +}: ProvisionWalletParams) { + const steward = getStewardClient(); + const agentName = `cloud-${characterId || clientAddress}`; + const tenantId = process.env.STEWARD_TENANT_ID || `org-${organizationId}`; + + try { + // Create agent + wallet in Steward (idempotent — 409 means already exists) + const agent = await steward.createWallet(agentName, `Agent ${agentName}`, clientAddress); + const walletAddress = agent.walletAddress; + + if (!walletAddress) { + throw new Error(`Steward did not return a wallet address for agent ${agentName}`); + } + + const [record] = await db + .insert(agentServerWallets) + .values({ + organization_id: organizationId, + user_id: userId, + character_id: characterId, + wallet_provider: "steward", + steward_agent_id: agentName, + steward_tenant_id: tenantId, + address: walletAddress, + chain_type: chainType, + client_address: clientAddress, + }) + .returning(); + + logger.info( + `[server-wallets] Provisioned Steward wallet for ${agentName}: ${walletAddress}`, + ); + return record; + } catch (error: unknown) { + const code = error instanceof Error ? Reflect.get(error, "code") : undefined; + const isUniqueViolation = + code === "23505" || + (error instanceof Error && error.message.includes("unique constraint")); + if (isUniqueViolation) { + throw new WalletAlreadyExistsError(); + } + throw error; + } +} + +// --------------------------------------------------------------------------- +// Provision — Privy (legacy) +// --------------------------------------------------------------------------- + +async function provisionPrivyWallet({ organizationId, userId, characterId, @@ -75,6 +171,7 @@ export async function provisionServerWallet({ organization_id: organizationId, user_id: userId, character_id: characterId, + wallet_provider: "privy", privy_wallet_id: wallet.id, address: wallet.address, chain_type: chainType, @@ -86,7 +183,8 @@ export async function provisionServerWallet({ } catch (error: unknown) { const code = error instanceof Error ? Reflect.get(error, "code") : undefined; const isUniqueViolation = - code === "23505" || (error instanceof Error && error.message.includes("unique constraint")); + code === "23505" || + (error instanceof Error && error.message.includes("unique constraint")); if (isUniqueViolation) { if (wallet?.id) { const walletId = wallet.id; @@ -110,6 +208,10 @@ export async function provisionServerWallet({ } } +// --------------------------------------------------------------------------- +// Organization lookup +// --------------------------------------------------------------------------- + /** Returns the organization_id that owns the server wallet for this client address, or null if none. */ export async function getOrganizationIdForClientAddress( clientAddress: string, @@ -122,53 +224,120 @@ export async function getOrganizationIdForClientAddress( return row[0]?.organization_id ?? null; } -export interface RpcPayload { - method: string; - params: unknown[]; - timestamp: number; - nonce: string; -} +// --------------------------------------------------------------------------- +// RPC execution — top-level (validates signature, routes by provider) +// --------------------------------------------------------------------------- -export interface ExecuteParams { - clientAddress: string; - payload: RpcPayload; - signature: `0x${string}`; -} - -export async function executeServerWalletRpc({ clientAddress, payload, signature }: ExecuteParams) { +export async function executeServerWalletRpc({ + clientAddress, + payload, + signature, +}: ExecuteParams) { + // Timestamp check const now = Date.now(); if (!payload.timestamp || now - payload.timestamp > 5 * 60 * 1000) { throw new RpcRequestExpiredError(); } + // Signature verification const isValid = await verifyMessage({ address: clientAddress as `0x${string}`, message: JSON.stringify(payload), signature, }); - if (!isValid) { throw new InvalidRpcSignatureError(); } + // Nonce replay protection const nonceKey = `rpc-nonce:${clientAddress}:${payload.nonce}`; const nonceSet = await cache.setIfNotExists(nonceKey, "1", 24 * 60 * 60 * 1000); if (!nonceSet) { throw new RpcReplayError(); } + // Look up wallet record const walletRecord = await db.query.agentServerWallets.findFirst({ where: eq(agentServerWallets.client_address, clientAddress), }); - if (!walletRecord) { throw new ServerWalletNotFoundError(); } - const privy = getPrivyClient(); + // Route by provider + if (walletRecord.wallet_provider === "steward") { + return executeStewardRpc(walletRecord, payload); + } + return executePrivyRpc(walletRecord, payload); +} + +// --------------------------------------------------------------------------- +// RPC execution — Steward +// --------------------------------------------------------------------------- + +async function executeStewardRpc(wallet: AgentServerWallet, payload: RpcPayload) { + const steward = getStewardClient(); + const agentId = wallet.steward_agent_id; - return await privy.walletApi.rpc({ - walletId: walletRecord.privy_wallet_id, + if (!agentId) { + throw new Error( + `Wallet ${wallet.id} is marked as steward but has no steward_agent_id`, + ); + } + + switch (payload.method) { + case "eth_sendTransaction": { + const [tx] = payload.params as [ + { to: string; value?: string; data?: string; chainId?: number }, + ]; + return steward.signTransaction(agentId, { + to: tx.to, + value: tx.value || "0", + data: tx.data, + chainId: tx.chainId || 8453, // Default to Base mainnet + }); + } + + case "personal_sign": + case "eth_sign": { + const [message] = payload.params as [string]; + return steward.signMessage(agentId, message); + } + + case "eth_signTypedData_v4": { + const [, typedData] = payload.params as [string, string]; + const parsed = JSON.parse(typedData); + // EIP-712 uses "message" but SDK expects "value" + return steward.signTypedData(agentId, { + domain: parsed.domain, + types: parsed.types, + primaryType: parsed.primaryType, + value: parsed.message ?? parsed.value, + }); + } + + default: + throw new Error( + `RPC method "${payload.method}" is not supported via Steward. ` + + `Supported: eth_sendTransaction, personal_sign, eth_sign, eth_signTypedData_v4`, + ); + } +} + +// --------------------------------------------------------------------------- +// RPC execution — Privy (legacy) +// --------------------------------------------------------------------------- + +async function executePrivyRpc(wallet: AgentServerWallet, payload: RpcPayload) { + if (!wallet.privy_wallet_id) { + throw new Error( + `Wallet ${wallet.id} is marked as privy but has no privy_wallet_id`, + ); + } + + const privy = getPrivyClient(); + return privy.walletApi.rpc({ + walletId: wallet.privy_wallet_id, method: payload.method as any, params: payload.params as any, }); diff --git a/packages/lib/services/steward-client.ts b/packages/lib/services/steward-client.ts new file mode 100644 index 000000000..321da5f8a --- /dev/null +++ b/packages/lib/services/steward-client.ts @@ -0,0 +1,195 @@ +/** + * Steward integration for Eliza Cloud. + * + * Two layers: + * 1. `getStewardClient()` — returns a `@stwd/sdk` StewardClient for + * provisioning and signing (used by server-wallets.ts). + * 2. Read-only helpers (`getStewardAgent`, `getStewardWalletInfo`) that + * hit the Steward REST API directly for the API/dashboard layer. + * These use lightweight fetch calls so we don't depend on the SDK for + * simple reads that only need a subset of the response. + */ + +import { StewardClient } from "@stwd/sdk"; +import { logger } from "@/lib/utils/logger"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const STEWARD_HOST_URL = process.env.STEWARD_API_URL || "http://localhost:3200"; +const STEWARD_TENANT_API_KEY = process.env.STEWARD_TENANT_API_KEY || ""; +const STEWARD_TENANT_ID = process.env.STEWARD_TENANT_ID || "milady-cloud"; + +// --------------------------------------------------------------------------- +// SDK client (singleton) +// --------------------------------------------------------------------------- + +let _client: StewardClient | null = null; + +/** + * Returns a configured `@stwd/sdk` StewardClient instance (singleton). + * + * Used by `server-wallets.ts` for wallet provisioning and RPC execution. + */ +export function getStewardClient(): StewardClient { + if (!_client) { + _client = new StewardClient({ + baseUrl: STEWARD_HOST_URL, + apiKey: STEWARD_TENANT_API_KEY || undefined, + tenantId: STEWARD_TENANT_ID || undefined, + }); + } + return _client; +} + +// --------------------------------------------------------------------------- +// Types (for read-only API layer) +// --------------------------------------------------------------------------- + +export interface StewardAgentInfo { + id: string; + name: string; + walletAddress: string | null; + createdAt: string; +} + +export interface StewardWalletInfo { + agentId: string; + walletAddress: string | null; + walletProvider: "steward"; + walletStatus: "active" | "pending" | "error" | "unknown"; + balance?: string | null; + chain?: string | null; +} + +// --------------------------------------------------------------------------- +// Lightweight fetch helpers (for API routes that only need reads) +// --------------------------------------------------------------------------- + +function stewardHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + if (STEWARD_TENANT_ID) { + headers["X-Steward-Tenant"] = STEWARD_TENANT_ID; + } + if (STEWARD_TENANT_API_KEY) { + headers["X-Steward-Key"] = STEWARD_TENANT_API_KEY; + } + return headers; +} + +async function stewardFetch(path: string, options?: RequestInit): Promise { + const url = `${STEWARD_HOST_URL}${path}`; + try { + const response = await fetch(url, { + ...options, + headers: { ...stewardHeaders(), ...(options?.headers ?? {}) }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + if (response.status === 404) return null; + logger.warn(`[steward-client] ${path} returned ${response.status}`); + return null; + } + + return (await response.json()) as T; + } catch (err) { + logger.warn( + `[steward-client] Failed to reach Steward at ${url}: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} + +// --------------------------------------------------------------------------- +// Read-only public API (used by API routes + dashboard) +// --------------------------------------------------------------------------- + +/** + * Fetch agent info from Steward, including wallet address. + */ +export async function getStewardAgent(agentId: string): Promise { + const data = await stewardFetch<{ + id?: string; + name?: string; + walletAddress?: string; + wallet_address?: string; + created_at?: string; + createdAt?: string; + }>(`/agents/${encodeURIComponent(agentId)}`); + + if (!data) return null; + + return { + id: data.id ?? agentId, + name: data.name ?? "", + walletAddress: data.walletAddress ?? data.wallet_address ?? null, + createdAt: data.createdAt ?? data.created_at ?? "", + }; +} + +/** + * Fetch wallet info for a sandbox/agent from Steward. + * Returns a normalized StewardWalletInfo or null if unreachable. + */ +export async function getStewardWalletInfo(agentId: string): Promise { + // Use the SDK client for balance, since it handles auth + parsing + const client = getStewardClient(); + + let agent: StewardAgentInfo | null = null; + try { + const sdkAgent = await client.getAgent(agentId); + agent = { + id: sdkAgent.id, + name: sdkAgent.name, + walletAddress: sdkAgent.walletAddress || null, + createdAt: sdkAgent.createdAt?.toISOString?.() ?? "", + }; + } catch { + // SDK call failed, try lightweight fetch as fallback + agent = await getStewardAgent(agentId); + } + + if (!agent) return null; + + let balance: string | null = null; + let chain: string | null = null; + + if (agent.walletAddress) { + try { + const balanceResult = await client.getBalance(agentId); + balance = balanceResult.balances?.nativeFormatted ?? null; + chain = balanceResult.balances?.chainId + ? `eip155:${balanceResult.balances.chainId}` + : null; + } catch { + // Balance fetch is best-effort + } + } + + return { + agentId, + walletAddress: agent.walletAddress, + walletProvider: "steward", + walletStatus: agent.walletAddress ? "active" : "pending", + balance, + chain, + }; +} + +/** + * Check if Steward is reachable. + */ +export async function isStewardAvailable(): Promise { + try { + const response = await fetch(`${STEWARD_HOST_URL}/health`, { + signal: AbortSignal.timeout(5_000), + }); + return response.ok; + } catch { + return false; + } +} diff --git a/packages/lib/services/wallet-provider.ts b/packages/lib/services/wallet-provider.ts new file mode 100644 index 000000000..fa78527fd --- /dev/null +++ b/packages/lib/services/wallet-provider.ts @@ -0,0 +1,62 @@ +/** + * WalletProvider — abstraction for agent wallet management. + * + * Allows pluggable wallet backends (Privy, Steward, etc.) while + * presenting a uniform interface to the rest of the application. + * + * Phase 1: The concrete routing lives in server-wallets.ts using + * feature flags. This interface is the target for Phase 2+ when + * each provider becomes a standalone class. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CreateWalletOptions { + /** Human-readable agent name. */ + name?: string; + /** Blockchain ecosystem. */ + chainType?: "evm" | "solana"; + /** Platform identifier (e.g. client address). */ + platformId?: string; +} + +export interface WalletInfo { + /** Provider-specific wallet/agent ID. */ + walletId: string; + /** On-chain public address. */ + address: string; + /** Which provider manages this wallet. */ + provider: "privy" | "steward"; + /** Chain type. */ + chainType: "evm" | "solana"; +} + +export interface TransactionRequest { + to: string; + value?: string; + data?: string; + chainId?: number; +} + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface WalletProvider { + /** Create a new wallet for an agent. */ + createWallet(agentId: string, options?: CreateWalletOptions): Promise; + + /** Look up an existing wallet for an agent. Returns null if none exists. */ + getWallet(agentId: string): Promise; + + /** Get native balance for a chain (returns wei/lamports as string). */ + getBalance(agentId: string, chain: string): Promise; + + /** Sign and optionally broadcast a transaction. Returns tx hash or signed tx. */ + signTransaction(agentId: string, tx: TransactionRequest): Promise; + + /** Sign an arbitrary message. Returns hex signature. */ + signMessage(agentId: string, message: string): Promise; +} diff --git a/packages/scripts/check-types-split.ts b/packages/scripts/check-types-split.ts index 35bb37990..be65c350c 100644 --- a/packages/scripts/check-types-split.ts +++ b/packages/scripts/check-types-split.ts @@ -49,11 +49,11 @@ async function splitIntoSubdirectories(dir: string): Promise { } async function getDirectoriesToCheck(): Promise { - const libSubdirs = await splitIntoSubdirectories("lib"); + const libSubdirs = await splitIntoSubdirectories("packages/lib"); const appSubdirs = await splitIntoSubdirectories("app"); - const componentSubdirs = await splitIntoSubdirectories("components"); + const componentSubdirs = await splitIntoSubdirectories("packages/ui/src/components"); - return ["db", ...libSubdirs, ...componentSubdirs, ...appSubdirs]; + return ["packages/db", ...libSubdirs, ...componentSubdirs, ...appSubdirs]; } async function createTempTsconfig(directory: string, baseTsconfig: object): Promise { diff --git a/packages/scripts/cloudformation/per-user-stack.json b/packages/scripts/cloudformation/per-user-stack.json index 59d8a1e51..5b451767c 100644 --- a/packages/scripts/cloudformation/per-user-stack.json +++ b/packages/scripts/cloudformation/per-user-stack.json @@ -24,6 +24,11 @@ "Default": 3000, "Description": "Container port" }, + "DirectContainerPortCidr": { + "Type": "String", + "Default": "", + "Description": "Optional IPv4 CIDR allowed to reach the container port directly. Leave empty to require ALB-only access." + }, "ContainerCpu": { "Type": "Number", "Default": 1792, @@ -123,6 +128,18 @@ ] } ] + }, + "HasDirectContainerPortCidr": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "DirectContainerPortCidr" + }, + "" + ] + } + ] } }, "Resources": { @@ -153,15 +170,25 @@ "Description": "Allow traffic from ALB" }, { - "IpProtocol": "tcp", - "FromPort": { - "Ref": "ContainerPort" - }, - "ToPort": { - "Ref": "ContainerPort" - }, - "CidrIp": "0.0.0.0/0", - "Description": "Allow direct public access to container port" + "Fn::If": [ + "HasDirectContainerPortCidr", + { + "IpProtocol": "tcp", + "FromPort": { + "Ref": "ContainerPort" + }, + "ToPort": { + "Ref": "ContainerPort" + }, + "CidrIp": { + "Ref": "DirectContainerPortCidr" + }, + "Description": "Optional direct access to container port" + }, + { + "Ref": "AWS::NoValue" + } + ] } ], "SecurityGroupEgress": [ @@ -891,7 +918,7 @@ } }, "DirectAccessUrl": { - "Description": "Direct access URL via EC2 public DNS (bypasses ALB)", + "Description": "Direct access URL via EC2 public DNS (requires DirectContainerPortCidr opt-in)", "Value": { "Fn::Sub": "http://${EC2Instance.PublicDnsName}:${ContainerPort}" } diff --git a/packages/tests/integration/server-wallets-steward.test.ts b/packages/tests/integration/server-wallets-steward.test.ts new file mode 100644 index 000000000..a19a965ba --- /dev/null +++ b/packages/tests/integration/server-wallets-steward.test.ts @@ -0,0 +1,680 @@ +/** + * Integration tests for dual-provider wallet routing (Privy ↔ Steward). + * + * Tests cover: + * 1. Provisioning routing — flag off → Privy, flag on → Steward + * 2. RPC routing — wallet_provider column determines which backend handles the call + * 3. Schema validation — correct fields set/absent per provider + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; + +// --------------------------------------------------------------------------- +// Steward mock setup +// --------------------------------------------------------------------------- + +const mockStewardCreateWallet = mock(); +const mockStewardSignTransaction = mock(); +const mockStewardSignMessage = mock(); +const mockStewardSignTypedData = mock(); +const mockGetStewardClient = mock(); + +const mockStewardClient = { + createWallet: mockStewardCreateWallet, + signTransaction: mockStewardSignTransaction, + signMessage: mockStewardSignMessage, + signTypedData: mockStewardSignTypedData, +}; + +// --------------------------------------------------------------------------- +// Privy mock setup +// --------------------------------------------------------------------------- + +const mockPrivyWalletCreate = mock(); +const mockPrivyWalletRpc = mock(); +const mockGetPrivyClient = mock(); + +// --------------------------------------------------------------------------- +// DB mock setup +// --------------------------------------------------------------------------- + +const mockInsertReturning = mock(); +const mockInsertValues = mock(); +const mockDbInsert = mock(); +const mockFindFirst = mock(); + +// --------------------------------------------------------------------------- +// Cache + viem mocks +// --------------------------------------------------------------------------- + +const mockCacheSetIfNotExists = mock().mockResolvedValue(true); +const mockVerifyMessage = mock(); + +// --------------------------------------------------------------------------- +// Feature-flag object — mutated per test to control routing +// --------------------------------------------------------------------------- + +const mockWalletProviderFlags = { + USE_STEWARD_FOR_NEW_WALLETS: false, + ALLOW_PRIVY_MIGRATION: false, + DISABLE_PRIVY_WALLETS: false, +}; + +// --------------------------------------------------------------------------- +// Module wiring — must run before any import of server-wallets +// --------------------------------------------------------------------------- + +beforeAll(async () => { + const actualViem = await import("viem"); + const actualDbClient = await import("@/db/client"); + const actualCacheModule = await import("@/lib/cache/client"); + + // Wallet-provider feature flags (mutable object so per-test mutations work) + mock.module("@/lib/config/wallet-provider-flags", () => ({ + WALLET_PROVIDER_FLAGS: mockWalletProviderFlags, + })); + + // Steward SDK client + mock.module("@/lib/services/steward-client", () => ({ + getStewardClient: mockGetStewardClient, + })); + + // Privy client + mock.module("@/lib/auth/privy-client", () => ({ + getPrivyClient: mockGetPrivyClient, + privyClient: mockGetPrivyClient, + verifyAuthTokenCached: mock().mockResolvedValue(null), + invalidatePrivyTokenCache: mock().mockResolvedValue(undefined), + invalidateAllPrivyTokenCaches: mock().mockResolvedValue(undefined), + getUserFromIdToken: mock().mockResolvedValue(null), + getUserById: mock().mockResolvedValue(null), + })); + + // DB client + mock.module("@/db/client", () => ({ + ...actualDbClient, + db: { + insert: mockDbInsert, + query: { + agentServerWallets: { + findFirst: mockFindFirst, + }, + }, + }, + })); + + // viem — only replace verifyMessage + mock.module("viem", () => ({ + ...actualViem, + verifyMessage: mockVerifyMessage, + })); + + // Cache — only replace setIfNotExists (nonce guard) + mock.module("@/lib/cache/client", () => ({ + ...actualCacheModule, + cache: { + get: actualCacheModule.cache.get.bind(actualCacheModule.cache), + getWithSWR: actualCacheModule.cache.getWithSWR.bind(actualCacheModule.cache), + set: actualCacheModule.cache.set.bind(actualCacheModule.cache), + setIfNotExists: mockCacheSetIfNotExists, + incr: actualCacheModule.cache.incr.bind(actualCacheModule.cache), + expire: actualCacheModule.cache.expire.bind(actualCacheModule.cache), + getAndDelete: actualCacheModule.cache.getAndDelete.bind(actualCacheModule.cache), + del: actualCacheModule.cache.del.bind(actualCacheModule.cache), + delPattern: actualCacheModule.cache.delPattern.bind(actualCacheModule.cache), + mget: actualCacheModule.cache.mget.bind(actualCacheModule.cache), + isAvailable: actualCacheModule.cache.isAvailable.bind(actualCacheModule.cache), + }, + })); +}); + +afterAll(() => { + mock.restore(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal RPC payload with a fresh timestamp to avoid expiry errors. */ +function rpcPayload(method: string, params: unknown[], nonce: string) { + return { method, params, timestamp: Date.now(), nonce }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dual-provider wallet routing", () => { + beforeEach(() => { + // Steward + mockGetStewardClient.mockClear().mockReturnValue(mockStewardClient); + mockStewardCreateWallet.mockClear(); + mockStewardSignTransaction.mockClear(); + mockStewardSignMessage.mockClear(); + mockStewardSignTypedData.mockClear(); + + // Privy + mockGetPrivyClient.mockClear().mockReturnValue({ + walletApi: { create: mockPrivyWalletCreate, rpc: mockPrivyWalletRpc }, + }); + mockPrivyWalletCreate.mockClear(); + mockPrivyWalletRpc.mockClear(); + + // DB + mockInsertReturning.mockClear(); + mockInsertValues.mockClear().mockReturnValue({ returning: mockInsertReturning }); + mockDbInsert.mockClear().mockReturnValue({ values: mockInsertValues }); + mockFindFirst.mockClear(); + + // Cache / viem + mockCacheSetIfNotExists.mockClear().mockResolvedValue(true); + mockVerifyMessage.mockClear(); + + // Default: Privy mode + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + mockWalletProviderFlags.ALLOW_PRIVY_MIGRATION = false; + mockWalletProviderFlags.DISABLE_PRIVY_WALLETS = false; + }); + + // ========================================================================= + // 1. Provisioning routing + // ========================================================================= + + describe("provisionServerWallet — routing", () => { + it("calls Privy and never touches Steward when USE_STEWARD_FOR_NEW_WALLETS=false", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + + mockPrivyWalletCreate.mockResolvedValue({ id: "pw_privy1", address: "0xPrivy1" }); + mockInsertReturning.mockResolvedValue([ + { id: "rec-1", wallet_provider: "privy", privy_wallet_id: "pw_privy1", address: "0xPrivy1" }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org1", + userId: "user1", + characterId: "char1", + clientAddress: "0xClient1", + chainType: "evm", + }); + + expect(mockPrivyWalletCreate).toHaveBeenCalledWith({ chainType: "ethereum" }); + expect(mockStewardCreateWallet).not.toHaveBeenCalled(); + expect(mockGetStewardClient).not.toHaveBeenCalled(); + }); + + it("calls Steward and never touches Privy when USE_STEWARD_FOR_NEW_WALLETS=true", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-char2", + walletAddress: "0xSteward2", + }); + mockInsertReturning.mockResolvedValue([ + { + id: "rec-2", + wallet_provider: "steward", + steward_agent_id: "cloud-char2", + steward_tenant_id: "org-org2", + address: "0xSteward2", + privy_wallet_id: null, + }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org2", + userId: "user2", + characterId: "char2", + clientAddress: "0xClient2", + chainType: "evm", + }); + + expect(mockGetStewardClient).toHaveBeenCalled(); + expect(mockStewardCreateWallet).toHaveBeenCalledWith( + "cloud-char2", + "Agent cloud-char2", + "0xClient2", + ); + expect(mockPrivyWalletCreate).not.toHaveBeenCalled(); + expect(mockGetPrivyClient).not.toHaveBeenCalled(); + }); + + it("uses clientAddress as agent name when characterId is null (Steward mode)", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-0xClient3", + walletAddress: "0xSteward3", + }); + mockInsertReturning.mockResolvedValue([ + { + id: "rec-3", + wallet_provider: "steward", + steward_agent_id: "cloud-0xClient3", + address: "0xSteward3", + privy_wallet_id: null, + }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org3", + userId: "user3", + characterId: null, + clientAddress: "0xClient3", + chainType: "evm", + }); + + expect(mockStewardCreateWallet).toHaveBeenCalledWith( + "cloud-0xClient3", + "Agent cloud-0xClient3", + "0xClient3", + ); + }); + + it("throws if Steward returns no walletAddress", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ id: "cloud-charX", walletAddress: null }); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await expect( + provisionServerWallet({ + organizationId: "org4", + userId: "user4", + characterId: "charX", + clientAddress: "0xClientX", + chainType: "evm", + }), + ).rejects.toThrow("Steward did not return a wallet address"); + }); + }); + + // ========================================================================= + // 2. Schema validation — correct fields per provider + // ========================================================================= + + describe("provisionServerWallet — schema validation", () => { + it("Privy wallet insert: privy_wallet_id set, no steward_agent_id / steward_tenant_id", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + + mockPrivyWalletCreate.mockResolvedValue({ id: "pw_schema1", address: "0xAddr1" }); + mockInsertReturning.mockResolvedValue([{ id: "rec-s1" }]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org-s1", + userId: "user-s1", + characterId: "char-s1", + clientAddress: "0xClientS1", + chainType: "evm", + }); + + const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record; + + expect(insertedValues).toBeDefined(); + expect(insertedValues.wallet_provider).toBe("privy"); + expect(insertedValues.privy_wallet_id).toBe("pw_schema1"); + // Steward fields must not be present + expect(insertedValues.steward_agent_id).toBeUndefined(); + expect(insertedValues.steward_tenant_id).toBeUndefined(); + }); + + it("Steward wallet insert: steward_agent_id + steward_tenant_id set, no privy_wallet_id", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-char-s2", + walletAddress: "0xAddrS2", + }); + mockInsertReturning.mockResolvedValue([{ id: "rec-s2" }]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org-s2", + userId: "user-s2", + characterId: "char-s2", + clientAddress: "0xClientS2", + chainType: "evm", + }); + + const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record; + + expect(insertedValues).toBeDefined(); + expect(insertedValues.wallet_provider).toBe("steward"); + expect(insertedValues.steward_agent_id).toBe("cloud-char-s2"); + expect(typeof insertedValues.steward_tenant_id).toBe("string"); + expect((insertedValues.steward_tenant_id as string).length).toBeGreaterThan(0); + // Privy field must not be present + expect(insertedValues.privy_wallet_id).toBeUndefined(); + }); + + it("Steward wallet insert: steward_tenant_id falls back to org- when env var unset", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + delete process.env.STEWARD_TENANT_ID; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-char-s3", + walletAddress: "0xAddrS3", + }); + mockInsertReturning.mockResolvedValue([{ id: "rec-s3" }]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org-s3", + userId: "user-s3", + characterId: "char-s3", + clientAddress: "0xClientS3", + chainType: "evm", + }); + + const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record; + + expect(insertedValues.steward_tenant_id).toBe("org-org-s3"); + }); + }); + + // ========================================================================= + // 3. RPC routing — wallet_provider drives dispatch + // ========================================================================= + + describe("executeServerWalletRpc — routing by wallet_provider", () => { + it("routes to Privy RPC for a wallet record with wallet_provider='privy'", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue({ + id: "rec-rpc-privy", + wallet_provider: "privy", + privy_wallet_id: "pw_rpc1", + steward_agent_id: null, + }); + mockPrivyWalletRpc.mockResolvedValue({ method: "eth_sendTransaction", data: "0xResult" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("eth_sendTransaction", [{ to: "0xDead" }], "nonce-rpc-privy-1"); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientRpc1" as `0x${string}`, + payload, + signature: "0xSigRpc1" as `0x${string}`, + }); + + expect(mockPrivyWalletRpc).toHaveBeenCalledWith( + expect.objectContaining({ walletId: "pw_rpc1", method: "eth_sendTransaction" }), + ); + expect(mockStewardSignTransaction).not.toHaveBeenCalled(); + expect(result).toEqual({ method: "eth_sendTransaction", data: "0xResult" }); + }); + + it("routes to Steward for a wallet record with wallet_provider='steward'", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue({ + id: "rec-rpc-steward", + wallet_provider: "steward", + steward_agent_id: "cloud-char-rpc", + privy_wallet_id: null, + }); + mockStewardSignTransaction.mockResolvedValue({ txHash: "0xTxHash" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload( + "eth_sendTransaction", + [{ to: "0xBeef", value: "0x1", data: "0x", chainId: 8453 }], + "nonce-rpc-steward-1", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientRpc2" as `0x${string}`, + payload, + signature: "0xSigRpc2" as `0x${string}`, + }); + + expect(mockStewardSignTransaction).toHaveBeenCalledWith( + "cloud-char-rpc", + expect.objectContaining({ to: "0xBeef", value: "0x1", data: "0x", chainId: 8453 }), + ); + expect(mockPrivyWalletRpc).not.toHaveBeenCalled(); + expect(result).toEqual({ txHash: "0xTxHash" }); + }); + }); + + // ========================================================================= + // 4. Steward RPC method dispatch + // ========================================================================= + + describe("executeServerWalletRpc — Steward method dispatch", () => { + const stewardWalletRecord = { + id: "rec-steward-rpc", + wallet_provider: "steward", + steward_agent_id: "cloud-agent-dispatch", + privy_wallet_id: null, + }; + + beforeEach(() => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue(stewardWalletRecord); + }); + + it("dispatches eth_sendTransaction to steward.signTransaction", async () => { + mockStewardSignTransaction.mockResolvedValue({ txHash: "0xTx1" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload( + "eth_sendTransaction", + [{ to: "0xTo1", value: "0x64", data: "0xdata", chainId: 1 }], + "nonce-dispatch-tx", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD1" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignTransaction).toHaveBeenCalledWith( + "cloud-agent-dispatch", + { to: "0xTo1", value: "0x64", data: "0xdata", chainId: 1 }, + ); + expect(result).toEqual({ txHash: "0xTx1" }); + }); + + it("dispatches eth_sendTransaction defaults chainId to 8453 (Base) when omitted", async () => { + mockStewardSignTransaction.mockResolvedValue({ txHash: "0xTxBase" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload( + "eth_sendTransaction", + [{ to: "0xTo2" }], // no chainId + "nonce-dispatch-tx-base", + ); + await executeServerWalletRpc({ + clientAddress: "0xClientD2" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignTransaction).toHaveBeenCalledWith( + "cloud-agent-dispatch", + expect.objectContaining({ chainId: 8453 }), + ); + }); + + it("dispatches personal_sign to steward.signMessage", async () => { + mockStewardSignMessage.mockResolvedValue({ signature: "0xPersonalSig" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("personal_sign", ["hello world"], "nonce-dispatch-sign"); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD3" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignMessage).toHaveBeenCalledWith("cloud-agent-dispatch", "hello world"); + expect(result).toEqual({ signature: "0xPersonalSig" }); + }); + + it("dispatches eth_signTypedData_v4 to steward.signTypedData", async () => { + mockStewardSignTypedData.mockResolvedValue({ signature: "0xTypedSig" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const typedData = JSON.stringify({ + domain: { name: "TestDomain", chainId: 1 }, + types: { Mail: [{ name: "contents", type: "string" }] }, + primaryType: "Mail", + message: { contents: "Hello" }, + }); + const payload = rpcPayload( + "eth_signTypedData_v4", + ["0xSignerAddr", typedData], + "nonce-dispatch-typed", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD4" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignTypedData).toHaveBeenCalledWith("cloud-agent-dispatch", { + domain: { name: "TestDomain", chainId: 1 }, + types: { Mail: [{ name: "contents", type: "string" }] }, + primaryType: "Mail", + value: { contents: "Hello" }, + }); + expect(result).toEqual({ signature: "0xTypedSig" }); + }); + + it("throws for unsupported RPC methods on Steward", async () => { + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("eth_getBalance", ["0xAddr", "latest"], "nonce-unsupported"); + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientD5" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow(/not supported via Steward/); + }); + + it("throws if steward wallet record has no steward_agent_id", async () => { + mockFindFirst.mockResolvedValue({ + id: "rec-broken", + wallet_provider: "steward", + steward_agent_id: null, + privy_wallet_id: null, + }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("personal_sign", ["msg"], "nonce-no-agent-id"); + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientBroken" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow(/steward_agent_id/); + }); + }); + + // ========================================================================= + // 5. Shared executeServerWalletRpc guards (signature / nonce / not-found) + // ========================================================================= + + describe("executeServerWalletRpc — guards", () => { + it("throws InvalidRpcSignatureError when signature is invalid", async () => { + mockVerifyMessage.mockResolvedValue(false); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG1", + payload: rpcPayload("eth_sendTransaction", [], "nonce-guard-sig"), + signature: "0xBadSig" as `0x${string}`, + }), + ).rejects.toThrow("Invalid RPC signature"); + }); + + it("throws ServerWalletNotFoundError when no wallet record exists", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue(null); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG2", + payload: rpcPayload("eth_sendTransaction", [], "nonce-guard-notfound"), + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow("Server wallet not found"); + }); + + it("throws RpcReplayError when nonce has already been used", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockCacheSetIfNotExists.mockResolvedValue(false); // nonce already set + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG3", + payload: rpcPayload("eth_sendTransaction", [], "nonce-guard-replay"), + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow("RPC nonce already used"); + }); + + it("throws RpcRequestExpiredError when timestamp is too old", async () => { + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const stalePayload = { + method: "eth_sendTransaction", + params: [], + timestamp: Date.now() - 6 * 60 * 1000, // 6 minutes ago + nonce: "nonce-guard-expired", + }; + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG4", + payload: stalePayload, + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow("RPC request expired"); + }); + + it("throws if privy wallet record has no privy_wallet_id", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue({ + id: "rec-privy-broken", + wallet_provider: "privy", + privy_wallet_id: null, + steward_agent_id: null, + }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG5", + payload: rpcPayload("eth_sendTransaction", [], "nonce-privy-broken"), + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow(/privy_wallet_id/); + }); + }); +}); diff --git a/packages/tests/unit/cloudformation-template.test.ts b/packages/tests/unit/cloudformation-template.test.ts new file mode 100644 index 000000000..dd42eaea7 --- /dev/null +++ b/packages/tests/unit/cloudformation-template.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; + +const templatePath = path.join( + process.cwd(), + "packages/scripts/cloudformation/per-user-stack.json", +); + +const template = JSON.parse(fs.readFileSync(templatePath, "utf-8")); + +describe("per-user CloudFormation template", () => { + test("disables direct container port exposure by default", () => { + expect(template.Parameters.DirectContainerPortCidr.Default).toBe(""); + expect(template.Conditions.HasDirectContainerPortCidr).toEqual({ + "Fn::Not": [ + { + "Fn::Equals": [ + { Ref: "DirectContainerPortCidr" }, + "", + ], + }, + ], + }); + + const ingressRules = template.Resources.UserSecurityGroup.Properties.SecurityGroupIngress; + const directIngressRule = ingressRules.find( + (rule: Record) => "Fn::If" in rule, + ); + + expect(directIngressRule).toEqual({ + "Fn::If": [ + "HasDirectContainerPortCidr", + { + IpProtocol: "tcp", + FromPort: { Ref: "ContainerPort" }, + ToPort: { Ref: "ContainerPort" }, + CidrIp: { Ref: "DirectContainerPortCidr" }, + Description: "Optional direct access to container port", + }, + { Ref: "AWS::NoValue" }, + ], + }); + }); + + test("still allows ALB traffic to reach the container port", () => { + const ingressRules = template.Resources.UserSecurityGroup.Properties.SecurityGroupIngress; + + expect(ingressRules).toContainEqual({ + IpProtocol: "tcp", + FromPort: { Ref: "ContainerPort" }, + ToPort: { Ref: "ContainerPort" }, + SourceSecurityGroupId: { Ref: "SharedALBSecurityGroupId" }, + Description: "Allow traffic from ALB", + }); + }); +}); diff --git a/packages/tests/unit/milady-web-ui.test.ts b/packages/tests/unit/milady-web-ui.test.ts index 15fba5c5a..c06055a2a 100644 --- a/packages/tests/unit/milady-web-ui.test.ts +++ b/packages/tests/unit/milady-web-ui.test.ts @@ -94,10 +94,10 @@ describe("getClientSafeMiladyAgentWebUiUrl", () => { ).toBe("https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.milady.shad0w.xyz"); }); - test("falls back to direct access when no canonical url is provided", () => { + test("does not fall back to direct access when no canonical url is provided", () => { delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; - expect(getClientSafeMiladyAgentWebUiUrl(makeSandbox())).toBe("http://100.64.0.5:20100"); + expect(getClientSafeMiladyAgentWebUiUrl(makeSandbox())).toBeNull(); }); }); diff --git a/packages/tests/unit/milaidy-pairing-token-route.test.ts b/packages/tests/unit/milaidy-pairing-token-route.test.ts index b3635f061..9ec1089c6 100644 --- a/packages/tests/unit/milaidy-pairing-token-route.test.ts +++ b/packages/tests/unit/milaidy-pairing-token-route.test.ts @@ -97,7 +97,7 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { ); }); - test("opens the web UI directly when the sandbox has no UI API token", async () => { + test("falls back to the root web UI url without a UI API token", async () => { mockFindByIdAndOrg.mockResolvedValue({ id: "agent-1", status: "running", diff --git a/packages/ui/src/components/containers/agent-actions.tsx b/packages/ui/src/components/containers/agent-actions.tsx index 5bb856a64..7f12ed24a 100644 --- a/packages/ui/src/components/containers/agent-actions.tsx +++ b/packages/ui/src/components/containers/agent-actions.tsx @@ -15,10 +15,9 @@ import { useJobPoller } from "@/lib/hooks/use-job-poller"; interface MiladyAgentActionsProps { agentId: string; status: string; - webUiUrl: string | null; } -export function MiladyAgentActions({ agentId, status, webUiUrl }: MiladyAgentActionsProps) { +export function MiladyAgentActions({ agentId, status }: MiladyAgentActionsProps) { const router = useRouter(); const [loading, setLoading] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -126,7 +125,7 @@ export function MiladyAgentActions({ agentId, status, webUiUrl }: MiladyAgentAct
- {isRunning && webUiUrl && ( + {isRunning && ( { const isDocker = isDockerBacked(sb); - const connectUrl = getConnectUrl(sb); const trackedJob = poller.getStatus(sb.id); const isProvisioningActive = poller.isActive(sb.id); const displayStatus = isProvisioningActive ? "provisioning" : sb.status; @@ -632,7 +623,7 @@ export function MiladySandboxesTable({ sandboxes: initialSandboxes }: MiladySand {/* Web UI */} - {connectUrl && displayStatus === "running" ? ( + {displayStatus === "running" ? (