diff --git a/.gitignore b/.gitignore index 8625dbcfa..cd12e9515 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,5 @@ modelcontextprotocol/ *storybook.log storybook-static .next-dev/ +DEV_TO_PROD_AUDIT.md +TRIAGE_NOTES.md diff --git a/app/api/v1/milady/agents/route.ts b/app/api/v1/milady/agents/route.ts index c19e7ea5d..dcc064601 100644 --- a/app/api/v1/milady/agents/route.ts +++ b/app/api/v1/milady/agents/route.ts @@ -21,48 +21,76 @@ 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 | 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 | 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 }, + ); + } } /** @@ -70,92 +98,104 @@ export async function GET(request: NextRequest) { * 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 }, - ); } diff --git a/docs/steward-container-provisioning.md b/docs/steward-container-provisioning.md new file mode 100644 index 000000000..20864252e --- /dev/null +++ b/docs/steward-container-provisioning.md @@ -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=` + - `STEWARD_AGENT_TOKEN=` +- 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. diff --git a/packages/db/migrations/0043_seed_chain_data_pricing.sql b/packages/db/migrations/0044_seed_chain_data_pricing.sql similarity index 100% rename from packages/db/migrations/0043_seed_chain_data_pricing.sql rename to packages/db/migrations/0044_seed_chain_data_pricing.sql diff --git a/packages/db/migrations/0044_add_whatsapp_identity_columns.sql b/packages/db/migrations/0045_add_whatsapp_identity_columns.sql similarity index 100% rename from packages/db/migrations/0044_add_whatsapp_identity_columns.sql rename to packages/db/migrations/0045_add_whatsapp_identity_columns.sql diff --git a/packages/db/migrations/0045_add_redeemable_earnings_breakdown_columns.sql b/packages/db/migrations/0046_add_redeemable_earnings_breakdown_columns.sql similarity index 100% rename from packages/db/migrations/0045_add_redeemable_earnings_breakdown_columns.sql rename to packages/db/migrations/0046_add_redeemable_earnings_breakdown_columns.sql diff --git a/packages/db/migrations/0046_docker_nodes.sql b/packages/db/migrations/0047_docker_nodes.sql similarity index 100% rename from packages/db/migrations/0046_docker_nodes.sql rename to packages/db/migrations/0047_docker_nodes.sql diff --git a/packages/db/migrations/0047_add_token_agent_linkage.sql b/packages/db/migrations/0048_add_token_agent_linkage.sql similarity index 100% rename from packages/db/migrations/0047_add_token_agent_linkage.sql rename to packages/db/migrations/0048_add_token_agent_linkage.sql diff --git a/packages/db/migrations/0048_elite_rumiko_fujikawa.sql b/packages/db/migrations/0049_elite_rumiko_fujikawa.sql similarity index 100% rename from packages/db/migrations/0048_elite_rumiko_fujikawa.sql rename to packages/db/migrations/0049_elite_rumiko_fujikawa.sql diff --git a/packages/db/migrations/0049_repair_existing_user_identity_privy_claims.sql b/packages/db/migrations/0050_repair_existing_user_identity_privy_claims.sql similarity index 100% rename from packages/db/migrations/0049_repair_existing_user_identity_privy_claims.sql rename to packages/db/migrations/0050_repair_existing_user_identity_privy_claims.sql diff --git a/packages/db/migrations/0050_backfill_user_identities_from_users.sql b/packages/db/migrations/0051_backfill_user_identities_from_users.sql similarity index 100% rename from packages/db/migrations/0050_backfill_user_identities_from_users.sql rename to packages/db/migrations/0051_backfill_user_identities_from_users.sql diff --git a/packages/db/migrations/0051_add_milady_pairing_tokens.sql b/packages/db/migrations/0052_add_milady_pairing_tokens.sql similarity index 100% rename from packages/db/migrations/0051_add_milady_pairing_tokens.sql rename to packages/db/migrations/0052_add_milady_pairing_tokens.sql diff --git a/packages/db/migrations/0052_add_milady_billing_columns.sql b/packages/db/migrations/0053_add_milady_billing_columns.sql similarity index 100% rename from packages/db/migrations/0052_add_milady_billing_columns.sql rename to packages/db/migrations/0053_add_milady_billing_columns.sql diff --git a/packages/db/schemas/milady-sandboxes.ts b/packages/db/schemas/milady-sandboxes.ts index 27857246e..7842ec6ab 100644 --- a/packages/db/schemas/milady-sandboxes.ts +++ b/packages/db/schemas/milady-sandboxes.ts @@ -65,7 +65,7 @@ export const miladySandboxes = pgTable( .$type>() .notNull() .default({}), - // Docker infrastructure columns (added by 0046_docker_nodes migration) + // Docker infrastructure columns (added by 0047_docker_nodes migration) node_id: text("node_id"), container_name: text("container_name"), bridge_port: integer("bridge_port"), diff --git a/packages/lib/constants/agent-flavors.ts b/packages/lib/constants/agent-flavors.ts index 121f1514b..0e0ec6194 100644 --- a/packages/lib/constants/agent-flavors.ts +++ b/packages/lib/constants/agent-flavors.ts @@ -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", diff --git a/packages/lib/services/docker-sandbox-provider.ts b/packages/lib/services/docker-sandbox-provider.ts index 631234ee0..3f5dfbdd1 100644 --- a/packages/lib/services/docker-sandbox-provider.ts +++ b/packages/lib/services/docker-sandbox-provider.ts @@ -68,7 +68,12 @@ interface ContainerMeta { // Helpers // --------------------------------------------------------------------------- -const DOCKER_IMAGE = process.env.MILADY_DOCKER_IMAGE || "milady/agent:cloud-full-ui"; +const DOCKER_IMAGE = process.env.MILADY_DOCKER_IMAGE || "milady/agent:v2.0.0-steward-2"; +const DOCKER_NETWORK = process.env.MILADY_DOCKER_NETWORK || "milady-isolated"; +const STEWARD_API_URL = process.env.STEWARD_API_URL || "http://localhost:3200"; +const DEFAULT_MILADY_PORT = process.env.MILADY_CONTAINER_PORT || "2138"; +const DEFAULT_AGENT_PORT = process.env.MILADY_AGENT_PORT || "2139"; +const DEFAULT_BRIDGE_PORT = process.env.MILADY_BRIDGE_INTERNAL_PORT || "31337"; /** Default SSH port when not specified by DB node record. */ const DEFAULT_SSH_PORT = 22; @@ -116,6 +121,90 @@ async function getUsedPorts(nodeId: string): Promise> { return used; } +function getDockerHealthCmd(port: string): string { + return `sh -lc 'wget -qO- "http://127.0.0.1:${port}/health" >/dev/null 2>&1 || curl -fsS "http://127.0.0.1:${port}/health" >/dev/null 2>&1'`; +} + +function extractStewardToken(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("[docker-sandbox] Steward token endpoint returned an empty response"); + } + + try { + const parsed = JSON.parse(trimmed) as Record; + const nested = + parsed.data && typeof parsed.data === "object" + ? (parsed.data as Record) + : undefined; + + const candidate = + parsed.token ?? + parsed.agentToken ?? + parsed.accessToken ?? + parsed.value ?? + nested?.token ?? + nested?.agentToken ?? + nested?.accessToken ?? + nested?.value; + + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } catch { + // Some Steward builds may return the token as plain text. + } + + return trimmed; +} + +async function registerAgentWithSteward( + ssh: DockerSSHClient, + agentId: string, + agentName: string, +): Promise { + const script = `python3 - <<'PY' +import json +import sys +import urllib.error +import urllib.request + +base_url = ${JSON.stringify(STEWARD_API_URL)} +agent_id = ${JSON.stringify(agentId)} +agent_name = ${JSON.stringify(agentName)} + + +def post(path, payload): + req = urllib.request.Request( + f"{base_url}{path}", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as response: + return response.status, response.read().decode("utf-8") + except urllib.error.HTTPError as error: + return error.code, error.read().decode("utf-8") + + +status, body = post("/agents", {"id": agent_id, "name": agent_name}) +if status not in (200, 201, 202, 409): + print(body, file=sys.stderr) + raise SystemExit(f"Steward agent registration failed with status {status}") + +status, body = post(f"/agents/{agent_id}/token", {"name": "milady-cloud"}) +if status not in (200, 201): + print(body, file=sys.stderr) + raise SystemExit(f"Steward token mint failed with status {status}") + +print(body) +PY`; + + const rawToken = await ssh.exec(script, DOCKER_CMD_TIMEOUT_MS); + return extractStewardToken(rawToken); +} + // --------------------------------------------------------------------------- // DockerSandboxProvider // --------------------------------------------------------------------------- @@ -277,19 +366,22 @@ export class DockerSandboxProvider implements SandboxProvider { } } - // 5. Build environment flags (spread to avoid mutating caller's environmentVars) - const allEnv: Record = { + // 5. Build the base environment (spread to avoid mutating caller's environmentVars) + const baseEnv: Record = { ...environmentVars, ...vpnEnvVars, AGENT_NAME: agentName, - // cloud-full-ui image runs two processes: + MILADY_CLOUD_PROVISIONED: "1", + STEWARD_API_URL, + STEWARD_AGENT_ID: agentId, + // steward-enabled image runs two processes: // milady.mjs (UI) on MILADY_PORT (default 2138) // cloud-agent on PORT (default 2139) // Do NOT set PORT=2138 here — it would collide with MILADY_PORT // and the API service would steal the UI port. - MILADY_PORT: "2138", - PORT: "2139", - BRIDGE_PORT: "31337", + MILADY_PORT: DEFAULT_MILADY_PORT, + PORT: DEFAULT_AGENT_PORT, + BRIDGE_PORT: DEFAULT_BRIDGE_PORT, // Eliza server requires JWT_SECRET in production mode. // Generate a unique per-container secret if the caller didn't provide one. JWT_SECRET: environmentVars.JWT_SECRET || crypto.randomUUID(), @@ -303,39 +395,9 @@ export class DockerSandboxProvider implements SandboxProvider { MILADY_ALLOWED_ORIGINS: `https://${agentId}.${getAgentBaseDomain()}`, }; - // Validate env var keys to prevent shell command injection via malformed keys - for (const key of Object.keys(allEnv)) { - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { - throw new Error(`[docker-sandbox] Invalid environment variable key: "${key}"`); - } - } - - // Note: Values do not need control-character validation. The shellQuote() function - // wraps each "key=value" pair in single quotes and escapes embedded single quotes as '"'"', - // which makes all values (including those with newlines, tabs, or other control chars) - // safe inside the shell command. Single-quoted strings in bash preserve all characters - // literally except single quotes (which shellQuote already handles). - - const envFlags = Object.entries(allEnv) - .map(([key, value]) => `-e ${shellQuote(`${key}=${value}`)}`) - .join(" "); - - // 6. Build docker run command - // Only add NET_ADMIN and /dev/net/tun when headscale is actually enabled - const dockerRunCmd = [ - "docker run -d", - `--name ${shellQuote(containerName)}`, - "--restart unless-stopped", - ...(headscaleEnabled ? ["--cap-add=NET_ADMIN", "--device /dev/net/tun"] : []), - `-v ${shellQuote(volumePath)}:/app/data`, - `-p ${bridgePort}:31337`, - `-p ${webUiPort}:2138`, - envFlags, - shellQuote(resolvedImage), - ].join(" "); - - // 7. SSH to node, ensure volume dir, pull image, run container - // Pass hostKeyFingerprint so pooled clients pin the key when available + // 6. SSH to node, ensure volume dir, pull image, register in Steward, + // then create/start the container. Pass hostKeyFingerprint so pooled + // clients pin the key when available. const ssh = DockerSSHClient.getClient(hostname, sshPort, hostKeyFingerprint, sshUser); try { @@ -353,13 +415,60 @@ export class DockerSandboxProvider implements SandboxProvider { ); } - // Run container - const output = await ssh.exec(dockerRunCmd, DOCKER_CMD_TIMEOUT_MS); - const containerId = output.trim().slice(0, 12); + logger.info(`[docker-sandbox] Registering ${agentId} with Steward on ${nodeId}`); + const stewardAgentToken = await registerAgentWithSteward(ssh, agentId, agentName); + + const allEnv: Record = { + ...baseEnv, + STEWARD_AGENT_TOKEN: stewardAgentToken, + }; + + // Validate env var keys to prevent shell command injection via malformed keys + for (const key of Object.keys(allEnv)) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + throw new Error(`[docker-sandbox] Invalid environment variable key: "${key}"`); + } + } + + // Note: Values do not need control-character validation. The shellQuote() function + // wraps each "key=value" pair in single quotes and escapes embedded single quotes as '"'"'', + // which makes all values (including those with newlines, tabs, or other control chars) + // safe inside the shell command. Single-quoted strings in bash preserve all characters + // literally except single quotes (which shellQuote already handles). + const envFlags = Object.entries(allEnv) + .map(([key, value]) => `-e ${shellQuote(`${key}=${value}`)}`) + .join(" "); + + const dockerCreateCmd = [ + "docker create", + `--name ${shellQuote(containerName)}`, + "--restart unless-stopped", + `--network ${shellQuote(DOCKER_NETWORK)}`, + `--health-cmd ${shellQuote(getDockerHealthCmd(allEnv.MILADY_PORT || DEFAULT_MILADY_PORT))}`, + "--health-interval 10s", + "--health-timeout 5s", + "--health-start-period 15s", + "--health-retries 6", + ...(headscaleEnabled ? ["--cap-add=NET_ADMIN", "--device /dev/net/tun"] : []), + `-v ${shellQuote(volumePath)}:/app/data`, + `-p ${bridgePort}:${DEFAULT_BRIDGE_PORT}`, + `-p ${webUiPort}:${allEnv.MILADY_PORT || DEFAULT_MILADY_PORT}`, + envFlags, + shellQuote(resolvedImage), + ].join(" "); + + const containerId = (await ssh.exec(dockerCreateCmd, DOCKER_CMD_TIMEOUT_MS)) + .trim() + .slice(0, 12); + await ssh.exec(`docker start ${shellQuote(containerName)}`, DOCKER_CMD_TIMEOUT_MS); logger.info( `[docker-sandbox] Container created on ${nodeId}: ${containerId} (${containerName})`, ); } catch (err) { + await ssh + .exec(`docker rm -f ${shellQuote(containerName)}`, DOCKER_CMD_TIMEOUT_MS) + .catch(() => {}); + // Rollback allocated_count on failure if (dbNode) { await dockerNodesRepository.decrementAllocated(nodeId).catch(() => {}); diff --git a/packages/lib/services/pairing-token.ts b/packages/lib/services/pairing-token.ts index 743e472e4..df5c3a342 100644 --- a/packages/lib/services/pairing-token.ts +++ b/packages/lib/services/pairing-token.ts @@ -17,7 +17,37 @@ function hashToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } +// Domain aliases — waifu.fun and milady.ai resolve to the same containers. +// The dashboard rewrites URLs from one to the other, so the Origin header +// sent by pair.html may use either domain. +const DOMAIN_ALIASES: [string, string][] = [ + [".waifu.fun", ".milady.ai"], +]; + class PairingTokenService { + /** + * Given an origin like https://uuid.waifu.fun, return https://uuid.milady.ai + * (and vice versa). Returns null if no alias applies. + */ + private getAlternateDomainOrigin(origin: string): string | null { + for (const [a, b] of DOMAIN_ALIASES) { + try { + const url = new URL(origin); + if (url.hostname.endsWith(a)) { + url.hostname = url.hostname.replace(new RegExp(`${a.replace(".", "\\.")}$`), b); + return url.origin; + } + if (url.hostname.endsWith(b)) { + url.hostname = url.hostname.replace(new RegExp(`${b.replace(".", "\\.")}$`), a); + return url.origin; + } + } catch { + // Invalid URL — skip + } + } + return null; + } + async generateToken( userId: string, orgId: string, @@ -53,11 +83,25 @@ class PairingTokenService { return null; } - const row = await miladyPairingTokensRepository.consumeValidToken( + // Try the exact origin first + let row = await miladyPairingTokensRepository.consumeValidToken( hashToken(token), normalizedOrigin, ); + // If no match, try the alternate domain. The dashboard may rewrite + // waifu.fun → milady.ai (or vice versa) which changes the Origin header + // but both domains resolve to the same agent container. + if (!row) { + const alternateOrigin = this.getAlternateDomainOrigin(normalizedOrigin); + if (alternateOrigin) { + row = await miladyPairingTokensRepository.consumeValidToken( + hashToken(token), + alternateOrigin, + ); + } + } + if (!row) { return null; } diff --git a/packages/tests/unit/milady-create-routes.test.ts b/packages/tests/unit/milady-create-routes.test.ts index caad2c2f2..73bb9c9d9 100644 --- a/packages/tests/unit/milady-create-routes.test.ts +++ b/packages/tests/unit/milady-create-routes.test.ts @@ -68,7 +68,28 @@ mock.module("@/lib/utils/logger", () => ({ })); import { POST as postCompatAgent } from "@/app/api/compat/agents/route"; -import { POST as postV1MiladyAgent } from "@/app/api/v1/milady/agents/route"; +import { + GET as getV1MiladyAgents, + POST as postV1MiladyAgent, +} from "@/app/api/v1/milady/agents/route"; + +describe("Milady v1 agents auth error handling", () => { + beforeEach(() => { + mockRequireAuthOrApiKeyWithOrg.mockReset(); + }); + + test("GET returns 401 instead of 500 for invalid API keys", async () => { + mockRequireAuthOrApiKeyWithOrg.mockRejectedValue(new Error("Invalid or expired API key")); + + const response = await getV1MiladyAgents( + jsonRequest("https://example.com/api/v1/milady/agents", "GET"), + ); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ success: false, error: "Unauthorized" }); + }); +}); describe("Milady create routes reserved config stripping", () => { const savedAutoProvision = process.env.WAIFU_AUTO_PROVISION;