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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,5 @@ modelcontextprotocol/
*storybook.log
storybook-static
.next-dev/
DEV_TO_PROD_AUDIT.md
TRIAGE_NOTES.md
266 changes: 153 additions & 113 deletions app/api/v1/milady/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,141 +21,181 @@ const createAgentSchema = z.object({
environmentVars: z.record(z.string(), z.string()).optional(),
});

function isAuthenticationError(message: string): boolean {
return (
message.includes("Unauthorized") ||
message.includes("Authentication required") ||
message.includes("Forbidden") ||
message.includes("Invalid or expired API key") ||
message.includes("API key is inactive") ||
message.includes("API key has expired") ||
message.includes("Invalid or expired token")
);
}

function getErrorMessage(error: unknown, fallbackMessage: string): string {
return error instanceof Error ? error.message : fallbackMessage;
}

/**
* GET /api/v1/milady/agents
* List all Milady cloud agents for the authenticated user's organization.
*/
export async function GET(request: NextRequest) {
const { user } = await requireAuthOrApiKeyWithOrg(request);
const agents = await miladySandboxService.listAgents(user.organization_id);
try {
const { user } = await requireAuthOrApiKeyWithOrg(request);
const agents = await miladySandboxService.listAgents(user.organization_id);

const characterIds = Array.from(
new Set(agents.map((a) => a.character_id).filter((id): id is string => id != null)),
);
const characters =
characterIds.length > 0
? await userCharactersRepository.findByIdsInOrganization(characterIds, user.organization_id)
: [];
const charMap = new Map(characters.map((c) => [c.id, c]));

return NextResponse.json({
success: true,
data: agents.map((a) => {
const char = a.character_id ? charMap.get(a.character_id) : undefined;
// Fallback: extract from agent_config JSONB if character record not linked
const cfg = a.agent_config as Record<string, unknown> | null;
return {
id: a.id,
agentName: a.agent_name,
status: a.status,
databaseStatus: a.database_status,
lastBackupAt: a.last_backup_at,
lastHeartbeatAt: a.last_heartbeat_at,
errorMessage: a.error_message,
createdAt: a.created_at,
updatedAt: a.updated_at,
// Canonical token linkage
token_address:
char?.token_address ?? (cfg?.tokenContractAddress as string | undefined) ?? null,
token_chain: char?.token_chain ?? (cfg?.chain as string | undefined) ?? null,
token_name: char?.token_name ?? (cfg?.tokenName as string | undefined) ?? null,
token_ticker: char?.token_ticker ?? (cfg?.tokenTicker as string | undefined) ?? null,
};
}),
});
const characterIds = Array.from(
new Set(agents.map((a) => a.character_id).filter((id): id is string => id != null)),
);
const characters =
characterIds.length > 0
? await userCharactersRepository.findByIdsInOrganization(characterIds, user.organization_id)
: [];
const charMap = new Map(characters.map((c) => [c.id, c]));

return NextResponse.json({
success: true,
data: agents.map((a) => {
const char = a.character_id ? charMap.get(a.character_id) : undefined;
// Fallback: extract from agent_config JSONB if character record not linked
const cfg = a.agent_config as Record<string, unknown> | null;
return {
id: a.id,
agentName: a.agent_name,
status: a.status,
databaseStatus: a.database_status,
lastBackupAt: a.last_backup_at,
lastHeartbeatAt: a.last_heartbeat_at,
errorMessage: a.error_message,
createdAt: a.created_at,
updatedAt: a.updated_at,
// Canonical token linkage
token_address:
char?.token_address ?? (cfg?.tokenContractAddress as string | undefined) ?? null,
token_chain: char?.token_chain ?? (cfg?.chain as string | undefined) ?? null,
token_name: char?.token_name ?? (cfg?.tokenName as string | undefined) ?? null,
token_ticker: char?.token_ticker ?? (cfg?.tokenTicker as string | undefined) ?? null,
};
}),
});
} catch (error) {
logger.error("[milady-api] Error listing agents", error);

const errorMessage = getErrorMessage(error, "Failed to list agents");
const authError = isAuthenticationError(errorMessage);

return NextResponse.json(
{ success: false, error: authError ? "Unauthorized" : errorMessage },
{ status: authError ? 401 : 500 },
);
}
}

/**
* POST /api/v1/milady/agents
* Create a new Milady cloud agent.
*/
export async function POST(request: NextRequest) {
const { user } = await requireAuthOrApiKeyWithOrg(request);
const body = await request.json();
try {
const { user } = await requireAuthOrApiKeyWithOrg(request);
const body = await request.json();

const parsed = createAgentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: "Invalid request data",
details: parsed.error.issues,
},
{ status: 400 },
);
}
const parsed = createAgentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: "Invalid request data",
details: parsed.error.issues,
},
{ status: 400 },
);
}

// ── Credit gate: require minimum deposit before creating an agent ──
const creditCheck = await checkMiladyCreditGate(user.organization_id);
if (!creditCheck.allowed) {
logger.warn("[milady-api] Agent creation blocked: insufficient credits", {
// ── Credit gate: require minimum deposit before creating an agent ──
const creditCheck = await checkMiladyCreditGate(user.organization_id);
if (!creditCheck.allowed) {
logger.warn("[milady-api] Agent creation blocked: insufficient credits", {
orgId: user.organization_id,
balance: creditCheck.balance,
required: MILADY_PRICING.MINIMUM_DEPOSIT,
});
return NextResponse.json(
{
success: false,
error: creditCheck.error,
requiredBalance: MILADY_PRICING.MINIMUM_DEPOSIT,
currentBalance: creditCheck.balance,
},
{ status: 402 },
);
}

if (parsed.data.characterId) {
const character = await userCharactersRepository.findByIdInOrganizationForWrite(
parsed.data.characterId,
user.organization_id,
);

if (!character) {
return NextResponse.json(
{
success: false,
error: "Character not found",
},
{ status: 404 },
);
}
}

// Strip reserved __milady* keys from user-supplied agentConfig to prevent
// callers from spoofing internal lifecycle flags.
const sanitizedConfig = stripReservedMiladyConfigKeys(parsed.data.agentConfig);
const managedEnvironment = await prepareManagedMiladyEnvironment({
existingEnv: parsed.data.environmentVars,
organizationId: user.organization_id,
userId: user.id,
});

const agent = await miladySandboxService.createAgent({
organizationId: user.organization_id,
userId: user.id,
agentName: parsed.data.agentName,
characterId: parsed.data.characterId,
agentConfig: parsed.data.characterId
? withReusedMiladyCharacterOwnership(sanitizedConfig)
: sanitizedConfig,
environmentVars: managedEnvironment.environmentVars,
});

logger.info("[milady-api] Agent created", {
agentId: agent.id,
orgId: user.organization_id,
balance: creditCheck.balance,
required: MILADY_PRICING.MINIMUM_DEPOSIT,
});

return NextResponse.json(
{
success: false,
error: creditCheck.error,
requiredBalance: MILADY_PRICING.MINIMUM_DEPOSIT,
currentBalance: creditCheck.balance,
success: true,
data: {
id: agent.id,
agentName: agent.agent_name,
status: agent.status,
createdAt: agent.created_at,
},
},
{ status: 402 },
{ status: 201 },
);
}
} catch (error) {
logger.error("[milady-api] Error creating agent", error);

if (parsed.data.characterId) {
const character = await userCharactersRepository.findByIdInOrganizationForWrite(
parsed.data.characterId,
user.organization_id,
);
const errorMessage = getErrorMessage(error, "Failed to create agent");
const authError = isAuthenticationError(errorMessage);

if (!character) {
return NextResponse.json(
{
success: false,
error: "Character not found",
},
{ status: 404 },
);
}
return NextResponse.json(
{ success: false, error: authError ? "Unauthorized" : errorMessage },
{ status: authError ? 401 : 500 },
);
}

// Strip reserved __milady* keys from user-supplied agentConfig to prevent
// callers from spoofing internal lifecycle flags.
const sanitizedConfig = stripReservedMiladyConfigKeys(parsed.data.agentConfig);
const managedEnvironment = await prepareManagedMiladyEnvironment({
existingEnv: parsed.data.environmentVars,
organizationId: user.organization_id,
userId: user.id,
});

const agent = await miladySandboxService.createAgent({
organizationId: user.organization_id,
userId: user.id,
agentName: parsed.data.agentName,
characterId: parsed.data.characterId,
agentConfig: parsed.data.characterId
? withReusedMiladyCharacterOwnership(sanitizedConfig)
: sanitizedConfig,
environmentVars: managedEnvironment.environmentVars,
});

logger.info("[milady-api] Agent created", {
agentId: agent.id,
orgId: user.organization_id,
});

return NextResponse.json(
{
success: true,
data: {
id: agent.id,
agentName: agent.agent_name,
status: agent.status,
createdAt: agent.created_at,
},
},
{ status: 201 },
);
}
23 changes: 23 additions & 0 deletions docs/steward-container-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Steward-aware container provisioning

Updated the Docker sandbox orchestrator so newly provisioned Milady containers are created with Steward integration by default.

## What changed

- Default Docker image now falls back to `milady/agent:v2.0.0-steward-2`
- Overrideable with `MILADY_DOCKER_IMAGE`
- New containers now receive these env vars automatically:
- `MILADY_CLOUD_PROVISIONED=1`
- `STEWARD_API_URL=http://localhost:3200`
- `STEWARD_AGENT_ID=<agent-id>`
- `STEWARD_AGENT_TOKEN=<minted during provisioning>`
- Provisioning now registers the agent in Steward on the target node before container start:
- `POST /agents`
- `POST /agents/:agentId/token`
- New containers are attached to `milady-isolated` by default
- Overrideable with `MILADY_DOCKER_NETWORK`
- Docker healthcheck now targets `MILADY_PORT` instead of legacy `ELIZA_PORT`

## Scope

These changes only affect newly created Docker sandboxes. Running containers are not modified.
4 changes: 2 additions & 2 deletions packages/lib/constants/agent-flavors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export const AGENT_FLAVORS: AgentFlavor[] = [
{
id: "milady",
name: "Milady",
description: "Full milady agent with VRM companion UI",
dockerImage: "milady/agent:cloud-full-ui",
description: "Full milady agent with Steward wallet vault integration and VRM companion UI",
dockerImage: "milady/agent:v2.0.0-steward-2",
},
{
id: "cloud-agent",
Expand Down
Loading
Loading