Skip to content

fix(milady): wallet proxy, Neon branches, remove provisioning cron, de-slop UI#418

Open
0xSolace wants to merge 19 commits intodevfrom
fix/milady-dashboard-deslop
Open

fix(milady): wallet proxy, Neon branches, remove provisioning cron, de-slop UI#418
0xSolace wants to merge 19 commits intodevfrom
fix/milady-dashboard-deslop

Conversation

@0xSolace
Copy link
Copy Markdown
Collaborator

Summary

Infrastructure

  • Wallet proxy route /api/v1/milady/agents/[id]/api/wallet/[...path] - proxies wallet/steward requests to agent REST API on web_ui_port with MILADY_API_TOKEN auth
  • Neon branches - switch from creating a project per agent (hit 100-project limit) to creating branches within a shared parent project (unlimited, free)
  • Cleanup cron - resets agents stuck in provisioning >10min with no active job
  • Remove provisioning cron - VPS worker is the sole provisioning authority, Vercel cron was racing and failing
  • milady.ai added to redirect allowlists for Stripe checkout redirects

Dashboard UI

  • Agent detail tabs: Wallet (addresses, balances, steward status), Transactions (steward tx records with filtering), Policies (policy cards)
  • Billing: replace broken credit pack cards with custom amount input + card/crypto payment
  • Agent cards: deterministic character images from cloud-agent-samples
  • Text cleanup: removed verbose descriptions, filler copy
  • Create dialog: cleaner copy, Deploy button text

26 files changed, +1683 / -178

lalalune and others added 5 commits March 25, 2026 02:18
release: Eliza Cloud v2 — full platform merge
This enables the container's server.ts to skip pairing and onboarding
screens for cloud-provisioned agents. The platform handles authentication
via the pairing token flow, so users should go directly to the chat UI.

Works in conjunction with milaidy-dev fix/cloud-agent-auth-flow which
checks these env vars and returns pairingEnabled: false + onboarding
complete: true for cloud containers.
…e-slop UI

Infrastructure:
- Add wallet proxy route (/api/v1/milady/agents/[id]/api/wallet/[...path])
  Proxies wallet/steward requests to agent's REST API with proper auth
- Switch Neon provisioning from projects to branches (fixes 100-project limit)
  New agents get branches within shared parent project
- Add cleanup-stuck-provisioning cron (resets agents stuck >10min)
- Remove process-provisioning-jobs Vercel cron (VPS worker handles this)
- Add milady.ai to redirect allowlists for Stripe checkout

Dashboard UI:
- Agent detail: add Wallet, Transactions, Policies tabs
- Billing: replace credit pack cards with custom amount + card/crypto
- Agent cards: deterministic character images instead of identical fallbacks
- De-slop text across all dashboard pages
- Create dialog: cleaner copy, Deploy button
- Pricing: tighter descriptions
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
eliza-cloud-v2 Ready Ready Preview, Comment Mar 30, 2026 6:52am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6db4bbe9-7974-4337-bd54-28a613703032

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/milady-dashboard-deslop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

Code Review

Overview

This PR introduces a wallet proxy route, Neon branching migration, new dashboard tabs (Wallet/Transactions/Policies), billing page redesign, and UI cleanup. The scope is large and the infrastructure changes are significant. Here are my findings:


CRITICAL

1. Plaintext API token storage + unsafe extraction

File: app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts

MILADY_API_TOKEN is fetched from environment_vars column as plaintext and forwarded via Bearer header. Multiple unsafe as Record<string, unknown> casts obscure missing validation:

const apiToken = (rec as Record<string, unknown>).environment_vars
  ? ((rec as Record<string, unknown>).environment_vars as Record<string, string>)?.MILADY_API_TOKEN
  : undefined;

This should use a typed accessor, fail fast if the token is missing, and ideally encrypt sensitive credentials at rest rather than storing them in a generic JSON column.

2. Unvalidated query string passthrough in proxy

File: app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts

const query = request.nextUrl.search ? request.nextUrl.search.slice(1) : undefined;
// passed directly: `/api/wallet/${walletPath}${query ? `?${query}` : ""}`

Query parameters are forwarded verbatim. Whitelist expected parameters (e.g. limit, cursor, type) rather than passing everything through.

3. Path traversal risk in walletPath

No validation on the [...path] segment before it's forwarded. A request to ../../etc/passwd would be proxied as-is. Validate against a whitelist of known wallet sub-paths before proxying.

4. Hardcoded Neon parent project ID fallback

File: lib/services/milady-sandbox.ts (or equivalent)

const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID || "holy-rain-20729618";

The fallback silently uses a hardcoded project ID if the env var is missing. This should throw at startup if unconfigured—silent fallbacks in infrastructure code cause hard-to-debug production incidents.


HIGH

5. Fragile connection URI parsing in Neon client

const uriWithoutProtocol = connectionUri.replace("postgres://", "");
const afterAt = uriWithoutProtocol.split("@")[1];
host = afterAt.split("/")[0];

This doesn't handle postgresql:// and silently returns "unknown" on failure. Use new URL(connectionUri).hostname instead and throw on parse failure rather than masking it.

6. Race condition in Neon branch cleanup on DB update failure

When the DB update after branch creation fails, an orphan cleanup is attempted—but if cleanup itself fails, the error is only logged and execution continues silently. The caller should receive a failure signal. Also, without a lock, a retry race could create duplicate branches for the same agent.

7. fetchPolicies infinite re-render risk

File: components/containers/milady-policies-section.tsx

useEffect(() => {
  mountedRef.current = true;
  fetchPolicies();
  return () => { mountedRef.current = false; };
}, [fetchPolicies]);

If fetchPolicies is recreated each render (e.g. because its dependency array includes base), this effect loops. Use useCallback with stable dependencies or derive base inside the effect from agentId directly. Prefer AbortController over the mountedRef pattern.

8. Missing validation on data.branch in Neon branch response

The code accesses data.branch.id and data.connection_uris?.[0]?.connection_uri without validating the outer data.branch exists. Add a guard before field access.


MEDIUM

9. creditsService.listActiveCreditPacks() removed from UI but no DB migration

The billing page removes credit pack cards, but there's no migration to archive or drop the credit_packs table (if it exists). Per CLAUDE.md, all schema changes need migrations. If the table is being retired, create a targeted deprecation migration.

10. POST body read without Content-Type or size validation

if (method === "POST") {
  body = await request.text();
}

No Content-Type check, no body size limit. Add a Content-Type: application/json requirement and a size cap (e.g. 1MB) before reading.

11. stuckSinceMinutes hardcoded to threshold value

File: app/api/cron/cleanup-stuck-provisioning/route.ts

stuckSinceMinutes: STUCK_THRESHOLD_MINUTES, // minimum — actual may be longer

Log the actual stuck duration (Math.round((Date.now() - row.updatedAt.getTime()) / 60_000)) instead of just the threshold—this is much more useful for debugging infrastructure patterns.

12. Crypto status fetch dependency inconsistency

File: components/billing/billing-page-client.tsx

fetchCryptoStatus has an empty useCallback dep array but is listed as an useEffect dependency. This works today but is fragile. Fetch once on mount with a clear dependency chain.


LOW

13. Missing aria-label on copy buttons

File: components/containers/milady-wallet-section.tsx

Copy buttons use only title="Copy". Add aria-label={copied ? "Copied" : "Copy to clipboard"} for screen reader accessibility.

14. DEFAULT_AGENT_BASE_DOMAIN = "agents.example.com" placeholder

This placeholder domain leaking into unit tests or misconfigured deployments could cause confusing failures. Either remove the fallback or require explicit configuration.

15. Test coverage gaps

File: tests/unit/milady-web-ui.test.ts

Tests cover happy paths only. Worth adding: null/undefined inputs, invalid domain formats, port boundary values (0, 65535, negative).


Positive Notes

  • The cleanup cron is well-documented with clear criteria for "stuck" detection and good use of SQL for the subquery check
  • The Neon branching migration from per-project to per-branch is the right call at scale
  • verifyCronSecret is used consistently across cron routes
  • The AbortSignal.timeout(30_000) on proxy fetches is a good default

Summary

Severity Count
Critical 4
High 4
Medium 4
Low 3

The proxy route needs the most attention before merge—specifically query/path validation and token handling. The Neon branching change is structurally sound but needs the hardcoded fallback removed and better error propagation on branch creation failures.

Replace the broken custom billing page with the working BillingTab
component from Settings. Same Stripe + crypto flow, invoices, and
balance display.
…ution

Root cause: bun test --max-concurrency=1 runs all test files in a single
process. When a test file calls mock.module("@/db/repositories", ...) with
only a partial set of exports, the mock persists and breaks all subsequent
test files that import named exports not included in the mock.

Fixes:
1. privy-sync: change @/db/repositories mock to use specific sub-module
   paths (organization-invites, users) so the full repositories index is
   never replaced globally.
2. admin-service-pricing route+test: import servicePricingRepository from
   its specific sub-module (service-pricing) instead of the full index, and
   update the test mock accordingly.
3. Add missing InsufficientCreditsError export to @/lib/services/credits
   mocks in four test files that omitted it, preventing mcp-tools.test.ts
   from failing when it transitively imports app/api/mcp/tools/memory.ts.
@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

test

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

Eliza Cloud V2

Stack

  • Runtime: Bun
  • Framework: Next.js 15 (App Router)
  • Database: PostgreSQL + Drizzle ORM
  • Deployment: Vercel Serverless
  • UI: React + Tailwind CSS

Commands

bun install          # Install dependencies
bun run dev          # Start dev server
bun run build        # Production build
bun run db:migrate   # Apply database migrations
bun run db:generate  # Generate migration from schema
bun run db:studio    # Open Drizzle Studio

Database Migrations

Never use db:push - it's removed. All schema changes go through migrations.

Schema Change Workflow

  1. Edit schema in db/schemas/
  2. bun run db:generate
  3. Review SQL in db/migrations/
  4. bun run db:migrate
  5. Commit both schema + migration

Custom Migrations

npx drizzle-kit generate --custom --name=descriptive_name

Rules

  • No CREATE INDEX CONCURRENTLY (runs in transaction)
  • Use IF NOT EXISTS / IF EXISTS for creating tables
  • Never edit applied migrations
  • NEVER use omnibus migrations that recreate the full schema or existing objects - they will fail in production by locking active tables. Instead:
    1. Create small targeted migrations that ONLY add your new schema objects
    2. Use separate migrations for data backfills
    3. Put cleanup/drops in their own migration
    4. Group related objects together but limit migrations to <100 lines
  • See docs/database-migrations.md for details

Type Checking

bun run check-types has many pre-existing errors across the codebase (db/, lib/services/, app/). Don't try to fix them all — only verify your changed files have no new errors. Filter output:

bun run check-types 2>&1 | grep -E "(your-file\.ts|your-other-file\.ts)"

If the grep returns empty, your changes are clean. bun run build also fails on unrelated env vars (ELIZA_APP_DISCORD_BOT_TOKEN). Use check-types filtered to your files instead.

Project Structure

app/           # Next.js App Router pages
lib/           # Business logic, services
db/
  schemas/     # Drizzle schema definitions
  migrations/  # SQL migration files
  repositories/# Data access layer
components/    # React components
scripts/       # CLI utilities

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

test edit last

…ry whitelist, typed token access, no hardcoded fallback

- Wallet proxy: whitelist allowed wallet sub-paths (prevents path traversal)
- Wallet proxy: whitelist allowed query params (limit, offset, cursor, type, status)
- Wallet proxy: typed environment_vars access instead of unsafe casts
- Wallet proxy: POST body size limit (1MB) + Content-Type validation
- Wallet proxy: reject multi-segment paths
- Neon: remove hardcoded project ID fallback, warn if env var missing
@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

Code Review

Good set of changes overall. The Neon branch migration solves a real constraint cleanly, and the wallet proxy has solid defense-in-depth. A few issues worth addressing:


Bug: Stripe payment path missing try/catch — button can get permanently stuck

packages/ui/src/components/billing/billing-page-client.tsx

The crypto path wraps the fetch in try/catch and resets isProcessingCheckout on failure. The card/Stripe path doesn't:

// Card / Stripe
const response = await fetch("/api/stripe/create-checkout-session", { ... });

If this fetch throws (network error, server down), the exception propagates unhandled from an async event handler, isProcessingCheckout stays true, and the button is permanently disabled/stuck in "Redirecting…" state. Wrap the card path in the same try/catch pattern as the crypto path.


Bug: _setBalance is unused — balance display never refreshes

packages/ui/src/components/billing/billing-page-client.tsx

const [balance, _setBalance] = useState(currentCredits);

The _ prefix signals the setter is intentionally unused, but that means after a successful purchase the displayed balance won't update. If this is intentional (relies on a page reload), the state is unnecessary — use currentCredits directly. If a refresh was planned, the setter needs to be wired up.


Dead code: _truncateAddress defined but never called

packages/ui/src/components/containers/milady-wallet-section.tsx

function _truncateAddress(addr: string): string { ... }

This function is unused (both the addresses section renders the full address via data.addresses?.evmAddress directly). Remove it, or use it in the addresses display if truncation was intended.


Test assertion weakened without resolving root cause

packages/tests/unit/pr385-round2-fixes.test.ts

// Before
expect(result.web_ui_url).toBe("https://test-agent-id.waifu.fun");
// After
expect(result.web_ui_url).toContain(".waifu.fun");

The comment says "Agent ID in the URL may vary due to mock pollution in sequential test runs." The toContain assertion no longer verifies the agent ID is correctly embedded in the URL. The other test fixes in this PR address mock pollution by adding InsufficientCreditsError to credits mocks — the same approach should be applied here to restore the exact assertion.


neon_project_id now stores the shared parent project ID

packages/lib/services/milady-sandbox.ts

neon_project_id: NEON_PARENT_PROJECT_ID,  // same for all agents
neon_branch_id: result.branchId,           // unique per agent

This is functionally correct and backward-compatible via the cleanupNeon guard. Worth adding a comment on the neon_project_id field assignment to clarify it holds the shared parent ID for branch-based agents, since reading it in isolation looks like a per-agent project ID.


createBranch hardcodes region in the returned result

packages/lib/services/neon-client.ts

region: "aws-us-east-1",  // branch inherits parent's region; this is always wrong for other regions

Branches inherit the parent project's region — this field misrepresents the actual region. Either omit it from NeonProjectResult for the branch case, or extract the real region from the API response.


Wallet section polls with no backoff on error

packages/ui/src/components/containers/milady-wallet-section.tsx

The 30s setInterval keeps running even when the agent is unreachable (returns null from proxyWalletRequest). For stopped agents this generates steady 503 traffic. Consider clearing the interval on a 503/404 and only retrying on manual interaction.


What's good

  • Neon branch migration: clean solution to the 100-project limit; backward-compatible cleanup logic for legacy project-based records
  • Wallet proxy defense-in-depth: path whitelist + query param allowlist at both route and service layers; 1MB body limit; explicit MILADY_API_TOKEN extraction via typed access
  • Cleanup cron: single UPDATE … RETURNING is efficient; correctly skips agents with active jobs
  • Promise.allSettled in wallet section — one failing endpoint doesn't block the others
  • mountedRef pattern consistently applied across all three new sections to prevent state updates on unmounted components
  • Test fixes: comprehensive InsufficientCreditsError additions across test files address the mock pollution root cause properly

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

PR Review: fix(milady): wallet proxy, Neon branches, remove provisioning cron, de-slop UI

Overall this is a solid PR with well-structured infrastructure changes. A few issues worth addressing before merge.


Bugs

1. Missing try/catch in Stripe checkout path — loading state can get stuck

packages/ui/src/components/billing/billing-page-client.tsx

The crypto path wraps in try/catch, but the card/Stripe path does not:

// Card / Stripe
const response = await fetch("/api/stripe/create-checkout-session", { ... });
// if fetch throws (network error), setIsProcessingCheckout(false) never runs

If the network fails, the button stays disabled showing "Redirecting..." forever. Wrap in try/catch or add a finally { setIsProcessingCheckout(false) } like the crypto branch.


Security / Reliability

2. Hardcoded Neon project ID fallback

packages/lib/services/milady-sandbox.ts line ~35:

const NEON_PARENT_PROJECT_ID: string = process.env.NEON_PARENT_PROJECT_ID ?? "holy-rain-20729618";

The comment says "env var required in prod" but the fallback silently applies if unset — embedding an internal project ID in public code and silently misdirecting branch creation in misconfigured environments. This was also flagged in the previous PR's security review. Consider throwing at startup instead:

const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID;
if (!NEON_PARENT_PROJECT_ID) throw new Error("NEON_PARENT_PROJECT_ID env var is required");

Minor Issues

3. Dead code in milady-wallet-section.tsx

_truncateAddress is defined but never called — the live version is truncate in milady-transactions-section.tsx.

4. Token list uses array index as key

milady-wallet-section.tsxchain.tokens?.map((token, i) => <div key={i}>. Since balances are display-only and don't reorder this won't cause visible bugs, but token.address ?? token.symbol would be a more stable key.

5. trackEvent passes empty org ID

billing-page-client.tsx:

trackEvent("checkout_attempted", { ..., organization_id: "" });

Passing an empty string makes the analytics event less useful. If org ID isn't available client-side, omitting the field entirely is cleaner.

6. _setBalance setter is never called

billing-page-client.tsx initializes const [balance, _setBalance] = useState(currentCredits) but _setBalance is never invoked — the displayed balance is always the server-rendered initial value. The setter can be removed: const balance = currentCredits.


Code Quality Notes (no action required)

  • The cleanup cron's stuckSinceMinutes is hardcoded to the 10-minute threshold minimum; the inline comment acknowledges this is acceptable.
  • The cleanupNeon backward-compat logic (branch vs. project delete based on project ID equality) cleanly handles legacy project-per-agent records.
  • ALLOWED_WALLET_PATHS + ALLOWED_QUERY_PARAMS whitelists in the service layer are good defense-in-depth on top of the route-level single-segment check.
  • Barrel import refactoring (@/db/repositories → specific files) correctly resolves the Bun mock module pollution issue.

Summary: Fix the Stripe try/catch (#1) and the hardcoded Neon fallback (#2) before merge. The rest are minor polish items.

@0xSolace 0xSolace changed the base branch from main to dev March 30, 2026 02:58
@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

PR Review: Overall well-structured. Must-fix: (1) Neon project ID hardcoded fallback in milady-sandbox.ts should throw instead of silently using wrong project. (2) Stripe checkout path missing try/catch leaves UI stuck. Bugs: _truncateAddress unused dead code, _setBalance setter never called, token list uses index as React key. Nits: neondb and aws-us-east-1 hardcoded in createBranch. Good: cleanup cron TOCTOU-safe, cleanupNeon backward compat, wallet proxy double-layered validation.

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

Code Review

High Severity

1. process-provisioning-jobs cron removed without explanation (vercel.json)

The /api/v1/cron/process-provisioning-jobs cron entry is deleted, but there's no corresponding code removal or migration to an alternative. The new cleanup cron assumes a provisioning worker is running to actually drive agents forward — if this cron was that driver, removing it means agents will never provision and will all eventually be reset to error by the cleanup job. Please clarify whether provisioning has moved to a VPS-side poller and confirm no gap in coverage.

2. Hardcoded Neon parent project ID fallback (milady-sandbox.ts)

const NEON_PARENT_PROJECT_ID: string = process.env.NEON_PARENT_PROJECT_ID ?? "holy-rain-20729618";

This silently falls back to a hardcoded project ID if the env var is missing in production. The previous commit (9513fe4) explicitly removed this fallback. If NEON_PARENT_PROJECT_ID is absent, all new agents get silently bucketed under the hardcoded project — a subtle data integrity failure with no alert. Should throw at startup if the var is missing:

const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID;
if (!NEON_PARENT_PROJECT_ID) throw new Error("NEON_PARENT_PROJECT_ID env var is required");

3. No tests for proxyWalletRequest

This is the most security-sensitive new addition (path validation, query sanitization, token injection, HTTP forwarding) and has zero test coverage. Key missing scenarios:

  • Paths not in the allowlist are rejected
  • Disallowed query params are stripped
  • Missing MILADY_API_TOKEN (the warning path)
  • Agent returning non-200 responses
  • Agent unreachable (returns null)

Medium Severity

4. cleanupNeon could delete the shared parent project (milady-sandbox.ts)

private async cleanupNeon(projectId: string, branchId?: string | null) {
  if (projectId === NEON_PARENT_PROJECT_ID && branchId) {
    await neon.deleteBranch(NEON_PARENT_PROJECT_ID, branchId);
  } else {
    await neon.deleteProject(projectId); // ← danger if projectId === NEON_PARENT_PROJECT_ID
  }
}

If a new-style record has branchId null/undefined (e.g. a partial write failure), the else branch runs with projectId === NEON_PARENT_PROJECT_ID, attempting to delete the shared parent project. The condition needs a guard:

if (projectId === NEON_PARENT_PROJECT_ID) {
  if (branchId) {
    await neon.deleteBranch(NEON_PARENT_PROJECT_ID, branchId);
  } else {
    logger.error("[milady-sandbox] Attempted to delete shared parent project — branchId missing", { projectId });
  }
} else {
  await neon.deleteProject(projectId);
}

5. Stripe payment path missing try/catch (billing-page-client.tsx)

The crypto path is wrapped in try/catch but the Stripe fetch call is not. A network failure will throw an unhandled rejection while isProcessingCheckout stays true, permanently disabling the button until a page reload.

6. No response-size limit on wallet proxy (wallet/[...path]/route.ts)

The proxy forwards the full agent response body via agentResponse.text() with no size limit. A misbehaving agent could return a large response causing memory pressure in the Vercel function. Add a Content-Length check or consider streaming with a byte cap.

7. Fragile host parsing in createBranch (neon-client.ts)

const uriWithoutProtocol = connectionUri.replace("postgres://", "");
const afterAt = uriWithoutProtocol.split("@")[1];
host = afterAt.split("/")[0];

This won't match postgresql:// URIs and silently sets host = "unknown" on any parse error. Consider using the URL API or extracting the host from the Neon API response directly if available.

8. No tests for cleanup cron or Neon branch methods

cleanup-stuck-provisioning handler and NeonClient.createBranch/deleteBranch have no test coverage. The fragile host parsing path is particularly risky without tests.


Low Severity

9. _setBalance setter never called — stale balance after purchase (billing-page-client.tsx)

const [balance, _setBalance] = useState(currentCredits);

The setter is never called, so the balance shown after a successful purchase is stale until the user reloads. Either wire up the setter post-purchase or fetch the balance dynamically.

10. _truncateAddress defined but never used (milady-wallet-section.tsx)

The underscore prefix suppresses the lint warning but dead code should be removed outright.

11. Array index used as React key (milady-wallet-section.tsx)

{chain.tokens?.map((token, i) => <div key={i} ...>)}

Use a stable identifier: key={token.address ?? token.symbol ?? i}.

12. database: "neondb" hardcoded in createBranch (neon-client.ts)

The parent project's default database name is assumed to be neondb. If it differs, the NeonProjectResult.database field will be wrong for downstream consumers.

13. MockInsufficientCreditsError copy-pasted across 6 test files

The same mock class appears in v1-messages-route.test.ts, v1-responses-route.test.ts, evm-rpc-proxy-route.test.ts, referrals-service.test.ts, privy-sync.test.ts, and mcp-tools.test.ts. Extract to a shared test helper (e.g. packages/tests/helpers/mock-errors.ts).

14. Weakened test assertion masks an isolation problem (pr385-round2-fixes.test.ts)

// was: expect(result.web_ui_url).toBe("https://test-agent-id.waifu.fun");
expect(result.web_ui_url).toContain(".waifu.fun");

"Mock pollution" is a test isolation bug, not a reason to weaken the assertion. Fix the underlying isolation issue and restore the strict check.


Summary

Area Issue Severity
Bug process-provisioning-jobs cron removed — provisioning gap? High
Bug/Security Hardcoded Neon parent project ID fallback reintroduced High
Tests No tests for proxyWalletRequest High
Bug cleanupNeon could delete shared parent project if branchId is null Medium
Bug Stripe payment path missing try/catch Medium
Performance No response-size limit on wallet proxy Medium
Bug Fragile URI parsing in createBranch Medium
Tests No tests for cleanup cron or Neon branch methods Medium
Quality Stale balance after purchase, setter never called Low
Quality Dead code _truncateAddress Low
Quality Array index as React key Low
Quality neondb database name hardcoded Low
Quality MockInsufficientCreditsError duplicated 6× Low
Tests Weakened assertion masks isolation bug Low

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

PR Review: fix(milady) - wallet proxy, Neon branches, cron cleanup, UI

Overall solid work. The Neon project-to-branch migration solves the 100-project scaling limit, the wallet proxy has good security controls (allowlist + query param sanitization + org ownership check), and the test fixes are necessary hygiene. A few things worth addressing:

BUGS

  1. Stripe card path missing try/catch - billing-page-client.tsx

The crypto path is wrapped in try/catch but the Stripe/card path is not. If fetch throws due to a network error, isProcessingCheckout stays true forever and the button stays disabled with no feedback. The old code had a finally block that reset loading. Wrap the Stripe fetch path in try/catch like the crypto path.

  1. Dead state: _setBalance never called - billing-page-client.tsx

const [balance, _setBalance] = useState(currentCredits) - the setter is never invoked so the displayed balance never updates after mount. Should be const balance = currentCredits or properly wired up.

  1. Dead function: _truncateAddress - milady-wallet-section.tsx

Defined but never called anywhere. Remove it.

SECURITY

  1. Hardcoded real Neon project ID as env var fallback - packages/lib/services/milady-sandbox.ts

process.env.NEON_PARENT_PROJECT_ID with a fallback of holy-rain-20729618 hardcodes a real infrastructure ID in source. If this repo becomes public this leaks live infrastructure. Recommend removing the fallback and throwing at startup if the env var is missing.

POTENTIAL ISSUE

  1. No DB migration for neon_branch_id in this PR

Code references and writes rec.neon_branch_id but no schema or migration file is in this diff. If the column was added in a prior PR this is fine - worth confirming since deploying without the column causes runtime errors on agent cleanup.

MINOR

  1. stuckSinceMinutes always reports threshold not actual age - cleanup-stuck-provisioning/route.ts

Always returns 10 (the threshold minimum). The code even comments on this. Consider renaming to minStuckMinutes or removing from the response to avoid misleading monitoring.

  1. Number(BigInt(value)) precision loss - milady-transactions-section.tsx and milady-wallet-section.tsx

Number() on a BigInt loses precision above 2^53. Fine for ETH display in practice, but worth noting if these components are extended to support tokens with different decimals where raw integers could be very large.

@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

PR #418 Review

Overall this is a solid PR — the Neon branch migration and wallet proxy are well-structured. A few issues worth addressing before merge.


Bugs

1. _setBalance is dead state — balance never updates
billing-page-client.tsx

const [balance, _setBalance] = useState(currentCredits);

The setter is named _setBalance and never called anywhere. The balance shown in the UI will never update if credits change while the page is open. Either use currentCredits directly (no state needed), or wire up a real fetch to keep it live.


2. Missing finally / try-catch on the Stripe checkout path
billing-page-client.tsx — handleAddFunds

The crypto path has a try/catch that calls setIsProcessingCheckout(false) on error. The Stripe path (after the early return) does not. If the fetch throws (network error, timeout), isProcessingCheckout stays true and the button is permanently disabled until the user refreshes. Wrap the Stripe path in a try/catch or add a finally block.


3. Unused _truncateAddress in milady-wallet-section.tsx

The function is defined but never called — truncate in the transactions section is a separate copy. Dead code should be removed.


Security

4. Wallet proxy proceeds without auth when MILADY_API_TOKEN is missing
milady-sandbox.ts — proxyWalletRequest

const apiToken = envVars?.MILADY_API_TOKEN;
if (!apiToken) {
  logger.warn('[milady-sandbox] No MILADY_API_TOKEN for wallet proxy', { agentId });
}
// request sent anyway, without Authorization header

This fails open. If the token is missing, an unauthenticated request is forwarded to the agent. The agent will likely return 401, but it's safer to fail-closed and return a 503 with a descriptive error rather than silently forwarding an unauthenticated request.


5. Hardcoded Neon project ID in source with silent fallback
lib/services/milady-sandbox.ts

const NEON_PARENT_PROJECT_ID: string = process.env.NEON_PARENT_PROJECT_ID ?? "snowy-waterfall-29926749";

The comment says "env var required in prod" but the fallback means a missing env var silently uses a real production resource. This will also bite in staging/local where the env var isn't set. Better to throw at startup:

const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID;
if (!NEON_PARENT_PROJECT_ID) throw new Error("NEON_PARENT_PROJECT_ID env var is required");

Code Quality

6. Loosened test assertion masks a mock pollution bug
packages/tests/unit/pr385-round2-fixes.test.ts

// Before: expect(result.web_ui_url).toBe("https://test-agent-id.waifu.fun");
// After: "may vary due to mock pollution in sequential test runs"
expect(result.web_ui_url).toContain(".waifu.fun");

The assertion was weakened to work around mock pollution rather than fixing the root cause. The test should restore the env var explicitly (e.g. in beforeEach/afterEach) so the exact URL can be asserted.


7. MockInsufficientCreditsError copy-pasted across 6 test files

The same class is duplicated in v1-messages-route.test.ts, v1-responses-route.test.ts, evm-rpc-proxy-route.test.ts, mcp-tools.test.ts, privy-sync.test.ts, and referrals-service.test.ts. Should live in a shared test helper (e.g. packages/tests/helpers/mock-errors.ts).


8. database hardcoded in NeonClient.createBranch
packages/lib/services/neon-client.ts

database: "neondb",

The hardcoded "neondb" may not match the actual database name for all branches, especially if the parent project uses a different default. Consider parsing the database name from the connection URI the same way host is parsed directly above it.


What looks good

  • Neon branch migration logic (create branch, store neon_branch_id, backwards-compatible legacy project cleanup path) is clean and correct.
  • Path traversal mitigation in the wallet proxy is layered correctly: route-level path segment count check + service-level ALLOWED_WALLET_PATHS whitelist.
  • The cleanup cron query (UPDATE … WHERE NOT EXISTS) is efficient — avoids a separate SELECT.
  • Query parameter sanitization in proxyWalletRequest (allowlist of known params) is correct.
  • milady.ai additions to Stripe ALLOWED_ORIGINS and redirect-validation.ts are consistent.
  • Tab architecture (server-rendered Overview children + client-rendered Wallet/Transactions/Policies) is a clean separation.

…async job queue

The VPS worker handles SSH/Docker operations. Sync provisioning in Vercel
serverless functions can't do SSH and times out. Force async path so the
VPS worker is always the one deploying containers.
The VPS worker writes bridge_url and status to the primary DB.
The wallet proxy reads with findRunningSandbox which was using the
read replica (dbRead). Replica lag caused 503 'not running' errors.
Switched to dbWrite (primary) for consistent reads.
@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

Code Review

Overall this is a solid PR with good architectural decisions (Neon branches, wallet proxy defense-in-depth). A few issues worth addressing before merge.


🔴 Bugs

1. Number(BigInt(wei)) loses precision for large balances

In both milady-wallet-section.tsx and milady-transactions-section.tsx:

const val = Number(BigInt(wei)) / 1e18;  // ❌ precision loss > ~9000 ETH

Number.MAX_SAFE_INTEGER is ~9×10¹⁵, but wei values for >9000 ETH exceed that. Use BigInt arithmetic:

const val = Number((BigInt(wei) * 10_000n) / 10n ** 18n) / 10_000;

Both formatNative (wallet section) and formatValue (transactions section) have this issue.

2. Hardcoded Neon parent project ID fallback

In milady-sandbox.ts:

const NEON_PARENT_PROJECT_ID: string =
  process.env.NEON_PARENT_PROJECT_ID ?? "snowy-waterfall-29926749"; // env var required in prod

The comment says "env var required in prod" but the code silently uses the hardcoded value if the env var is missing. This is a footgun — a misconfigured staging/preview deployment will happily create branches on the production project. Consider throwing at startup if the env var is absent, or at minimum emitting a warning.


🟡 Code Quality

3. _truncateAddress is dead code

In milady-wallet-section.tsx, _truncateAddress is defined (with the conventional _ prefix signaling "unused") but never called. The wallet section renders full addresses. Remove it.

4. region: "aws-us-east-1" hardcoded in createBranch

const result: NeonProjectResult = {
  ...
  region: "aws-us-east-1",  // branches inherit parent region, not always us-east-1
};

Branches inherit the parent project's region. This field will be silently wrong if the parent project is in a different region. Either derive it from the API response or omit it from NeonProjectResult for branches.

5. Connection URI parsing is fragile

In neon-client.ts createBranch:

const uriWithoutProtocol = connectionUri.replace("postgres://", "");

This breaks if the URI uses postgresql:// (which the Neon API also returns). The existing createProject method likely has the same issue, but it's worth fixing here. Use new URL(connectionUri) with a protocol swap instead.

6. Unreachable outer catch in MiladyWalletSection.fetchData

const fetchData = useCallback(async () => {
  try {
    const [addrRes, balRes, stewardRes] = await Promise.allSettled([...]);
    ...
    setError(null);
  } catch (err) {   // ← Promise.allSettled never rejects; this catch is unreachable
    setError(...);
  }
}, [base]);

Promise.allSettled never rejects, so the catch block is dead. Individual request failures are handled via addrRes.status === "fulfilled" checks. The catch gives a false sense of safety. Either remove it or replace allSettled with all if total failure should surface as an error.


🟡 Security

7. Query param values are not sanitized, only keys are

In proxyWalletRequest:

for (const [key, value] of params) {
  if (MiladySandboxService.ALLOWED_QUERY_PARAMS.has(key)) {
    filtered.set(key, value);  // value is forwarded as-is
  }
}

The allowed keys (limit, offset, cursor, type, status) are controlled, which is the main protection. However, the forwarded values are not type-validated. For numeric params like limit/offset, consider validating they're integers before forwarding to avoid unexpected agent behavior.


🟢 Positives

  • Defense in depth on wallet proxy: path validation in route + allowlist in service is correct.
  • findRunningSandboxdbWrite: good fix for replica lag; the comment explains the reasoning well.
  • cleanupNeon backward compatibility: the project-vs-branch detection logic is clean.
  • verifyCronSecret usage on the new cleanup cron is correct.
  • mountedRef pattern in wallet/transactions components correctly prevents state updates after unmount.
  • sync = false hardcoding with clear comment is the right call for the serverless constraint.
  • Test fixes (InsufficientCreditsError mock exports) are a good catch.

…internal IPs

Vercel serverless functions can't reach Hetzner internal Docker IPs.
Route wallet proxy through the agent's public domain ({agentId}.waifu.fun)
which is accessible from anywhere via nginx/cloudflare routing.
@claude
Copy link
Copy Markdown

claude bot commented Mar 30, 2026

Code Review

Critical Issues

1. Hardcoded Neon Project ID as Fallback
lib/services/milady-sandbox.ts

const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID ?? "snowy-waterfall-29926749";

If NEON_PARENT_PROJECT_ID is missing from staging/preview envs, agents silently branch off the production Neon project. The fallback should throw instead:

if (!process.env.NEON_PARENT_PROJECT_ID) throw new Error("NEON_PARENT_PROJECT_ID env var is required");

2. Wallet Proxy Continues When MILADY_API_TOKEN is Missing
lib/services/milady-sandbox.tsproxyWalletRequest

if (!apiToken) {
  logger.warn("...");
  // continues anyway — unauthenticated request forwarded to agent
}

Should return null (or a 502) immediately rather than forwarding an unauthenticated request that will silently 401.

3. Missing Error Handling on Stripe Fetch — Button Stuck Disabled
components/billing/billing-page-client.tsxhandleAddFunds

The Stripe path has no try/finally. If the fetch throws (network error, timeout), isProcessingCheckout is never reset to false and the Pay button stays permanently disabled for the user session. Needs a try/finally { setIsProcessingCheckout(false) }.


Potential Bugs

4. Number(BigInt(wei)) Loses Precision for Large Balances
components/containers/milady-transactions-section.tsx, milady-wallet-section.tsx

const eth = Number(BigInt(value)) / 1e18;

Number(BigInt(...)) loses precision above 2^53. For large ERC-20 balances this produces silently incorrect display values. Use integer arithmetic before the float:

const eth = Number(BigInt(value) * 10000n / BigInt(1e18)) / 10000;

5. Brittle Neon Connection URI Parsing
lib/services/neon-client.tscreateBranch

const uriWithoutProtocol = connectionUri.replace("postgres://", "");
const afterAt = uriWithoutProtocol.split("@")[1];
host = afterAt.split("/")[0];  // throws if no "@" in URI
  • Does not handle the postgresql:// scheme (Neon sometimes returns this)
  • If no @ is present, afterAt is undefined → throws → silently caught → host = "unknown" → silent connection failures downstream

new URL(connectionUri.replace(/^postgres(ql)?:\/\//, "https://")) is more robust.

6. stuckSinceMinutes is Always 10, Never the Real Duration
app/api/cron/cleanup-stuck-provisioning/route.ts lines 95–101

The code acknowledges it can't recover the original updatedAt after the UPDATE. Remove this field from the response or clearly document it as a minimum floor — as-is it will mislead any dashboard/log consumer.

7. React Key Using Array Index on Polling Token List
components/containers/milady-wallet-section.tsx

{chain.tokens?.map((token, i) => <div key={i} ...>

Token lists update every 30 seconds. Index keys cause React to reuse DOM nodes incorrectly when order changes. Use token.address ?? token.symbol as key.


Security

8. Path Traversal Comment is Misleading
app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts

The route-level check (path[0].includes("..")) only catches literal .., not URL-encoded forms (%2F, %2e%2e). The service-layer exact-whitelist is what actually prevents traversal — that's fine, but the route-level comment "prevent path traversal" overstates what it does and could give false confidence during future changes. Either remove the comment or note it's a supplemental check.


Performance

9. Three Primary DB Reads Per Wallet Tab Poll
components/containers/milady-wallet-section.tsxfetchData

Each 30-second poll fires 3 parallel requests, each of which calls findRunningSandbox against the primary DB (after the recent fix to avoid replica lag). Consider a single /api/wallet/summary endpoint, or a short TTL cache on findRunningSandbox results.


Code Quality

10. Orphaned process-provisioning-jobs Route
vercel.json removes the cron schedule but the route file still exists with no caller documented. If the VPS worker is the new driver, a comment in the route or vercel.json would prevent future confusion.

11. _setBalance Never Called — Balance Won't Refresh
components/billing/billing-page-client.tsx

const [balance, _setBalance] = useState(currentCredits);

_setBalance is never invoked, so the displayed balance never updates after a successful payment. BillingTab calls fetchBalance(true) on mount — this component should do the same or be removed if BillingTab supersedes it.

12. Duplicate AMOUNT_LIMITS Constant
Defined identically in both billing-page-client.tsx and billing-tab.tsx. Should be extracted to a shared constants file.

13. createBranch Hardcodes region: "aws-us-east-1" in Result
lib/services/neon-client.ts

Branches inherit the parent project's region and cannot be placed independently. The hardcoded value happens to be correct but will silently lie if the parent project is ever moved. Parse data.branch.region_id from the API response instead.


Missing Tests

  • proxyWalletRequest — whitelist validation, blocked query param stripping, endpoint selection, missing token behavior
  • Wallet proxy route — unauthenticated requests, multi-segment paths, oversized bodies
  • cleanup-stuck-provisioning cron — correct agent selection, 10-minute threshold, agents with active jobs not reset

Nits

  • Filename typo: milaidy-sandbox-service-followups.test.tsmilady-sandbox-service-followups.test.ts
  • milady-web-ui.ts uses new URL(path, "https://milady.local")https://placeholder.invalid is more conventional for parsing-only bases
  • billing-page-wrapper.tsx imports BillingTab via a deep @/packages/ui/... path rather than the package alias used elsewhere in the same file

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants