Skip to content

Add managed Discord install flow for Milady agents#424

Merged
lalalune merged 11 commits intodevfrom
shaw/dev-pr407-pr411-pr412
Apr 5, 2026
Merged

Add managed Discord install flow for Milady agents#424
lalalune merged 11 commits intodevfrom
shaw/dev-pr407-pr411-pr412

Conversation

@lalalune
Copy link
Copy Markdown
Member

@lalalune lalalune commented Apr 5, 2026

Summary

  • add the managed Discord OAuth/install API flow for Milady cloud agents
  • route shared bot guild mentions into the linked agent sandbox
  • enforce guild-owner linking and persist the Discord admin lock

Testing

  • bun test packages/tests/unit/milady-managed-discord.test.ts packages/tests/unit/milady-agent-discord-routes.test.ts packages/tests/unit/managed-discord-eliza-app-route.test.ts

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 5, 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 Apr 5, 2026 0:06am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 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: d8805370-3fdd-4827-8d06-8711591eac6a

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 shaw/dev-pr407-pr411-pr412

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 Apr 5, 2026

Code Review: Add managed Discord install flow for Milady agents

Overall: solid architecture for the OAuth/install flow with good validation at boundaries. A few security, reliability, and test coverage issues worth addressing before merging.


Security

OAuth state base64url encoding consistency
packages/lib/services/discord-automation/index.ts
The timingSafeEqual path is good, but verify that the same base64url encoding (with/without padding) is used when generating and verifying the state signature. An asymmetry here could cause valid states to fail verification, or—more dangerously—invalid states to pass if the byte arrays get truncated before comparison.

Bot mention regex not escaped
packages/services/gateway-discord/src/gateway-manager.ts

const botMentionRegex = new RegExp(`<@!?${botUserId}>`, "g");

botUserId is a Snowflake (numeric) so the risk is low in practice, but defensive regex escaping is missing. If the ID source ever changes, this becomes an injection vector. Use a utility like escapeRegExp(botUserId) or assert the value is numeric before using it in a regex.

CORS wildcard check
Multiple routes use handleCorsOptions(CORS_METHODS). Confirm the implementation does not set Access-Control-Allow-Origin: * — these are OAuth callback routes and should be restricted to specific origins.

Loopback origin list duplicated
The localhost allowlist appears in at least two different files with slightly different syntax. Centralize it to avoid one copy being updated and the other drifting.


Bugs / Logic Errors

Redundant null check after length guard
app/api/internal/discord/eliza-app/messages/route.ts
After if (linkedSandboxes.length === 0) return ..., a subsequent if (!sandbox) check on linkedSandboxes[0] is unreachable. This is dead code and can be removed.

Agent config updated before restart succeeds
packages/lib/services/milady-managed-discord.tsconnectAgent
The agent config is persisted (guild binding written to DB) before the shutdown/provision cycle completes. If the restart fails partway through, the config reflects a state that doesn't match the running agent. Consider:

  • Writing config only after successful restart, or
  • Implementing a rollback on restart failure.

Bridge failure returns HTTP 200
app/api/internal/discord/eliza-app/messages/route.ts
When bridgeResponse.error is set, the route returns { handled: false, reason: "bridge_failed" } with status 200. Callers have no way to distinguish "unhandled because no agent is linked" from "unhandled because the bridge errored". Return a 500 for actual failures.


Error Handling

sanitizedContent not validated before forwarding
packages/services/gateway-discord/src/gateway-manager.ts
After stripping bot mentions, sanitizedContent could be empty or whitespace-only. There's a check for the empty case, but guildId, channelId, and messageId are forwarded without being validated as Snowflake IDs (18–20 digit numeric strings). Add format validation before sending to the internal bridge.

Race condition on guild conflict check
packages/lib/services/milady-managed-discord.ts
The "is this guild already linked?" check is a read followed by a write, with no database-level lock between them. Two simultaneous install requests for the same guild could both pass the conflict check. A UNIQUE constraint on the guild binding column (or a SELECT FOR UPDATE) would make this safe.

Bridge response not schema-validated
packages/services/gateway-discord/src/gateway-manager.ts
The response from the internal bridge endpoint is cast to a TypeScript interface but not validated at runtime. If the response shape changes, the cast silently succeeds. Add a Zod schema (matching ManagedDiscordRouteResponse) to validate the response.

Silent JSON parse error swallowed
app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts

.catch(() => ({}))

This masks JSON parse failures. At minimum, log the error so it's visible in traces.


Performance

Regex recompiled on every message
packages/services/gateway-discord/src/gateway-manager.ts
botMentionRegex is constructed inside handleManagedMiladyGuildMessage, which is called for every message. Move it to a cached property or class-level constant.

Two sequential DB queries in connectAgent
packages/lib/services/milady-managed-discord.ts
findByManagedDiscordGuildId (conflict check) and findByIdAndOrg (target agent fetch) are separate round-trips. They could be combined or parallelized with Promise.all where the results are independent.


Test Coverage

No test for guild-owner enforcement
The "only the guild owner can link" check is a critical security invariant but has no test. Add a case to milady-agent-discord-routes.test.ts that verifies a non-owner is rejected.

No test for redirect validation
resolveOAuthReturnTarget / resolveManagedReturnUrl have non-trivial redirect-allow-list logic (including loopback support). Add tests for: malicious absolute URLs, relative path traversal attempts, and valid loopback origins.

Bridge error reason codes untested
packages/tests/unit/managed-discord-eliza-app-route.test.ts covers the success and not_linked paths but not: ambiguous_guild_link, agent_not_running, or bridge_failed. Each distinct reason code should have a test.

No test for agent restart on connect
connectAgent performs a shutdown + provision cycle. No test verifies this behavior or what happens if the restart fails mid-flight.


Code Quality

asRecord / ensureRecord duplicated
packages/lib/services/milady-managed-discord.ts
These helpers also exist in milady-agent-config.ts. Import from the existing location rather than redefining.

Magic string "milady-managed" scattered across files
This flow-type string is hardcoded in multiple places. Define it as a typed constant in a shared types file to make future renames safe.

Vague conflict error message

throw new Error("Discord server is already linked to another agent");

Include the conflicting agent/guild IDs to make debugging easier: "Guild ${guildId} is already linked to agent ${existing.agentId}".

New bot permissions undocumented
packages/lib/services/discord-automation/index.ts
The permissions bitfield now includes View Channels and Change Nickname. Add a comment explaining why each is required.

OAuth scope change is a breaking config change
Scopes changed from "bot" to "identify guilds bot applications.commands". Existing Discord app configurations will need to be updated. This deserves a note in release/migration docs.


Posted by Claude Code

@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

Overall this is a solid, well-structured implementation of managed Discord installs. The HMAC-signed OAuth state is a notable improvement over the old plain base64 encoding. Test coverage is good. A few issues worth addressing before merging:


Bug: Mutation in withoutDiscordConnectorAdmin (and withDiscordConnectorAdmin)

packages/lib/services/milady-managed-discord.ts

function withoutDiscordConnectorAdmin(agentConfig) {
  const next = { ...(agentConfig ?? {}) };  // shallow copy only
  const plugins = asRecord(next.plugins);   // returns same reference, not a copy
  // ...
  if (connectorAdmins) {
    delete connectorAdmins.discord;  // mutates the original nested object
  }
}

The top-level spread only creates a shallow copy. plugins, entries, rolesEntry, etc. are all references into the original object. The delete connectorAdmins.discord call mutates the original agentConfig in place. The same issue exists in withDiscordConnectorAdmin when plugins already exists (via ensureRecord returning the live reference).

These helpers should deep-clone the nested path they modify, similar to how withManagedMiladyDiscordBinding uses cloneAgentConfig.


Bug: No rollback if agent restart fails after config is persisted

packages/lib/services/milady-managed-discord.tsconnectAgent and disconnectAgent

await miladySandboxesRepository.update(sandbox.id, { agent_config: nextConfig });

// If this fails, the config is already updated but the agent is stopped:
const shutdown = await miladySandboxService.shutdown(...);
const provision = await miladySandboxService.provision(...);
if (!provision.success) {
  throw new Error(provision.error || "Failed to restart agent");
}

If provision fails after shutdown succeeds, the agent is left stopped with the new config (or old config after disconnect). The error propagates to the caller but the DB state and runtime state are inconsistent. Consider either wrapping in a transaction with the config update, or at minimum catching the restart failure and returning a structured error that lets the client know the config was saved but restart failed.


Race condition: no DB-level uniqueness constraint on guild links

packages/lib/services/milady-managed-discord.tsconnectAgent

const conflict = conflictingGuildLinks.find(s => s.id !== params.agentId);
if (conflict) throw new Error("Discord server is already linked to another agent");
// gap — another concurrent request could pass this check
await miladySandboxesRepository.update(sandbox.id, { agent_config: nextConfig });

Two concurrent installs for the same guild can both pass the conflict check before either writes. Without a DB-level unique index or a serialized lock, two agents could end up linked to the same guild, causing the ambiguous_guild_link error in the message routing route. A partial unique index on the extracted JSON field (or a dedicated junction table with a unique constraint) would close this window.


Missing DB index on JSON field lookup

packages/db/repositories/milady-sandboxes.ts

WHERE (agent_config -> '__miladyManagedDiscord' ->> 'guildId') = $1

This executes a sequential scan over all sandboxes on every inbound guild message. A functional index like:

CREATE INDEX idx_milady_sandbox_managed_discord_guild_id
  ON milady_sandboxes ((agent_config -> '__miladyManagedDiscord' ->> 'guildId'))
  WHERE agent_config -> '__miladyManagedDiscord' ->> 'guildId' IS NOT NULL;

would keep routing latency flat as the table grows.


LOOPBACK_REDIRECT_ORIGINS duplicated across two files

The same constant is defined identically in both:

  • app/api/v1/discord/callback/route.ts
  • app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts

It should live once in lib/security/redirect-validation.ts (or adjacent) and be exported from there, same as getDefaultPlatformRedirectOrigins.


asRecord helper duplicated

asRecord is copied verbatim into both milady-agent-config.ts and milady-managed-discord.ts. Since milady-managed-discord.ts already imports from milady-agent-config.ts, it can just re-use the one from there (or export it).


Minor: OAuth access token is never revoked

packages/lib/services/discord-automation/index.tsresolveOAuthIdentity

The user access token obtained during the managed-flow callback is used to verify guild ownership and then discarded. Discord access tokens are short-lived but it would be cleaner (and a security best practice) to call POST /oauth2/token/revoke on the token once the identity check is complete, since the token is not needed beyond that point.


Minor: regex built from unchecked user ID

packages/services/gateway-discord/src/gateway-manager.ts

const botMentionRegex = new RegExp(`<@!?${botUserId}>`, "g");

Discord snowflake IDs are always numeric, so this is low risk in practice. But constructing a RegExp from a value read at runtime is worth a note — consider using String.prototype.includes or pre-building the two literal mention strings (<@${id}> / <@!${id}>) to avoid any theoretical regex-injection surface.


Typo alias routes

app/api/v1/milaidy/ (with an extra i) re-exports from app/api/v1/milady/. If this is an intentional backwards-compat alias for existing clients, a short comment explaining that would prevent future confusion. If it's a typo that slipped in, it can be dropped.

@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR Review: Add managed Discord install flow for Milady agents

Overall this is a well-structured PR with good input validation, HMAC-signed OAuth state, and solid test coverage. A few issues are worth addressing before merge.


Bugs / Correctness Issues

1. withDiscordConnectorAdmin mutates the config object in place

File: packages/lib/services/milady-managed-discord.ts, lines 20–46

ensureRecord creates a new child object and assigns it onto the parent:

const next: Record<string, unknown> = {};
parent[key] = next;   // mutates the object that was passed in
return next;

Then withDiscordConnectorAdmin does a shallow clone at the top level (const next = { ...(agentConfig ?? {}) }), but the nested plugins / entries / ROLES_PLUGIN_ID / config / connectorAdmins objects are all the same references as the originals. This means ensureRecord can mutate the caller's copy of those nested objects when they already exist, making the function impure. This is the same kind of in-place mutation that caused bugs elsewhere. Each level that already exists should be spread-cloned before being stored.

2. connectAgent has a check-then-act race on guild uniqueness

File: packages/lib/services/milady-managed-discord.ts, lines 137–143

const conflictingGuildLinks = await miladySandboxesRepository.findByManagedDiscordGuildId(...)
const conflict = conflictingGuildLinks.find(...)
if (conflict) throw ...
// ... separate write happens later

Two concurrent OAuth callbacks for the same guild can both pass the conflict check before either writes. Without a unique constraint or atomic update at the DB layer, two agents can end up linked to the same guild. The SQL query in findByManagedDiscordGuildId also lacks an index on the JSON path, so this will degrade at scale.

Consider adding a unique partial index on (agent_config -> '__miladyManagedDiscord' ->> 'guildId') in a migration.

3. Partial restart: config written before sandbox is confirmed healthy

File: packages/lib/services/milady-managed-discord.ts, lines 159–174 (and disconnectAgent mirror at lines 210–225)

miladySandboxesRepository.update is called before shutdown/provision. If the provision step fails, the config is already persisted with the new binding but the agent never restarts with it. The consumer receives an error, but the DB row is in an inconsistent state. The update should ideally be committed only after the restart succeeds, or the failure path should roll back the config change.


Security Concerns

4. OAuth state nonce is generated but never verified

File: packages/lib/services/discord-automation/index.ts, encodeOAuthState / decodeOAuthState

A nonce field is required in OAuthState and populated on generation (16 random bytes), but decodeOAuthState never checks whether the nonce has been seen/consumed. Without nonce verification a captured state+signature value can be replayed indefinitely. At minimum, nonces should be stored in Redis with a TTL matching the OAuth session lifetime and checked on decode.

5. OAuth access token not revoked after use

File: packages/lib/services/discord-automation/index.ts, resolveOAuthIdentity

The access_token returned in tokenData is used to fetch /users/@me and /users/@me/guilds, then the token is included in the returned DiscordOAuthIdentity but never stored or explicitly revoked. If it leaks through logs or debug output it remains valid. Since the token is only needed for the identity check, calling DELETE /oauth2/token (revoke) after the identity is resolved would reduce the attack surface.

6. LOOPBACK_REDIRECT_ORIGINS allows https://localhost — unusual and potentially risky

Files: app/api/v1/discord/callback/route.ts:22 and app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts:18

https://localhost:* is included in the loopback allowlist. While uncommon in dev environments, in some network configurations localhost can be resolved to non-loopback addresses or overridden in /etc/hosts. The intent seems to be loopback-only for the desktop client; limiting to http://localhost:* and http://127.0.0.1:* would be safer.


Code Quality / Best Practices

7. asRecord is duplicated across two files

Files: packages/lib/services/milady-agent-config.ts:19 and packages/lib/services/milady-managed-discord.ts:14

The identical helper is copy-pasted. It should live in a shared utility module (e.g. packages/lib/utils/records.ts) and imported by both callers.

8. LOOPBACK_REDIRECT_ORIGINS is duplicated across two route files

Files: app/api/v1/discord/callback/route.ts:22 and app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts:18

Same four-element as const array in two places. This should be exported from packages/lib/security/redirect-validation.ts alongside the existing getDefaultPlatformRedirectOrigins() so they stay in sync.

9. Redundant null-check after length check

File: app/api/internal/discord/eliza-app/messages/route.ts, lines 66–83

if (linkedSandboxes.length > 1) { return ... }
const sandbox = linkedSandboxes[0];
if (!sandbox) { return ... "not_linked" ... }

After asserting length === 0 returns early and length > 1 returns early, linkedSandboxes[0] is guaranteed to be defined. The second null check is dead code. It can be removed or replaced with a non-null assertion for clarity.

10. botNickname length cap is applied twice with differing style

File: app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts, lines 409–412

The schema caps the field at z.string().trim().max(32), but the code also calls .slice(0, 32) again in two places when building the generateOAuthUrl arguments. One source of truth is cleaner — the Zod schema should be sufficient.

11. withDiscordConnectorAdmin / withoutDiscordConnectorAdmin test coverage is absent

File: packages/lib/services/milady-managed-discord.ts, lines 31–78

milady-managed-discord.test.ts only covers milady-agent-config helpers. The withDiscordConnectorAdmin and withoutDiscordConnectorAdmin plugin-config manipulation functions have no direct tests, including the partial-cleanup logic (lines 64–77). These paths are especially worth covering given the mutation risk noted in issue #1.


Performance Considerations

12. No database index on the JSONB guild-ID path

File: packages/db/repositories/milady-sandboxes.ts, lines 543–549

findByManagedDiscordGuildId uses a JSONB path expression in the WHERE clause with no index. Every call performs a full table scan. This query is on the hot path — it runs on every guild message routed through the Eliza App bot. A partial index like:

CREATE INDEX idx_milady_sandboxes_managed_discord_guild
  ON milady_sandboxes ((agent_config -> '__miladyManagedDiscord' ->> 'guildId'))
  WHERE agent_config -> '__miladyManagedDiscord' IS NOT NULL;

should be added in the accompanying migration.

13. Two sequential API calls in resolveOAuthIdentity could be parallelised with the guild fetch

File: packages/lib/services/discord-automation/index.ts, handleBotOAuthCallback

resolveOAuthIdentity (token exchange + parallel user+guild fetch) completes entirely before the bot-token guild fetch begins. These are independent flows and could be run in parallel via Promise.all([resolveOAuthIdentity(...), fetch(guild)]) to shave ~100–200 ms off the callback latency.


Minor Notes

  • The milaidy typo directories (app/api/v1/milaidy/) are intentional compatibility shims (they re-export from milady). A brief comment explaining this would help future readers avoid "fixing" them.
  • gateway-manager.ts line 1401: the botMentionRegex is rebuilt on every message. Since botUserId is stable for the lifetime of the gateway, caching the compiled regex would avoid repeated compilation under load.

@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review: PR #424 — Add managed Discord install flow for Milady agents

This is a substantial feature PR (5,573 additions / 485 deletions, 90+ files) introducing the managed Discord install OAuth flow, per-user OAuth connection scoping, and a managed Google connector for Milady cloud agents. The overall architecture is sound and the test coverage is notably good for a feature of this size. Below are findings organized by severity.


Critical / Correctness Issues

1. postMessage uses wildcard origin — XSS risk
app/api/eliza-app/auth/connection-success/route.ts:

window.opener.postMessage(payload, "*");

The postMessage target origin should never be "*" when the payload contains a connectionId. An attacker who opens this URL in their own window (or via a redirect) can receive the connection ID. Replace "*" with the expected app origin (e.g., process.env.NEXT_PUBLIC_APP_URL injected server-side into the template).

2. discordGuilds.owner_id semantics silently changed
packages/lib/services/discord-automation/index.ts:

- owner_id: state.userId,     // was the Eliza/cloud user ID
+ owner_id: identity.user.id, // now the Discord user's Snowflake ID

This is a breaking change to the discord_guilds table semantics with no migration. Any downstream query that joins owner_id to the users table will silently return no rows (Discord Snowflakes will never match a UUID). At minimum this needs a comment, and ideally a schema/column rename or separate discord_owner_id column.

3. connectAgent has a TOCTOU race on the guild-link uniqueness check
packages/lib/services/milady-managed-discord.ts, connectAgent:

const conflictingGuildLinks = await miladySandboxesRepository.findByManagedDiscordGuildId(...)
const conflict = conflictingGuildLinks.find(...)
if (conflict) { throw ... }
// ... time passes ...
await miladySandboxesRepository.update(sandbox.id, { agent_config: nextConfig });

Two concurrent install requests for the same guild and different agents can both pass the uniqueness check before either writes. This should use a database-level unique constraint or advisory lock rather than an application-level guard.


Security Concerns

4. resolveOAuthReturnTarget allows loopback HTTPS redirects in managed flow
app/api/v1/discord/callback/route.ts and app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts:

const LOOPBACK_REDIRECT_ORIGINS = [
  "https://localhost:*",
  "https://127.0.0.1:*",
] as const;

The https://localhost pattern is an open redirect to any local service. In production-deployed callbacks, https://localhost:* should not be in the allow-list. Consider guarding behind NODE_ENV !== "production" or removing it from the production allow-list.

5. sanitizeReturnPath allows //evil.com protocol-relative URLs
app/api/eliza-app/connections/[platform]/initiate/route.ts:

function sanitizeReturnPath(path: string | undefined): string {
  if (!path || !path.startsWith("/")) {
    return "/connected";
  }
  return path;
}

This doesn't guard against //evil.com (protocol-relative URL). Should normalize or reject paths with more than one leading / or with embedded scheme characters.


Bugs / Potential Issues

6. Dead private sortConnectionsByRecency method left on OAuthService
packages/lib/services/oauth/oauth-service.ts — the private method now only delegates to the module-level function and is never called from within the class. Should be removed.

7. fetchManagedGoogleGmailTriage calls getGoogleAccessToken N times in Promise.all
packages/lib/services/milady-google-connector.ts:

const messages = await Promise.all(
  (listed.messages ?? []).map(async (messageRef) => {
    const response = await googleFetch({ url: `.../${messageId}?...` });
  }),
);

googleFetch calls getGoogleAccessTokenlistConnections → DB on every invocation. Hoist the token retrieval outside the Promise.all loop to avoid N redundant DB calls per triage request.

8. ambiguous_guild_link is silently swallowed at the gateway layer
packages/services/gateway-discord/src/gateway-manager.ts, handleManagedMiladyGuildMessage:

if (!routed.handled) {
  logger.debug("Managed Milady Discord message was not handled", { reason: routed.reason });
  return; // no reply to user
}

When routing returns reason: "ambiguous_guild_link" (a data integrity problem), messages are silently dropped with no user-facing feedback. Consider responding with a generic error message or promoting the log level to warn.

9. tsconfig.json contains hardcoded port-specific entries

".next-test-3303/types/**/*.ts",
".next-test-3300/types/**/*.ts",

The generic glob ".next-test-*/..." already covers these. Hardcoding specific port numbers in a shared config will confuse other contributors.

10. buildElizaAppHtml inlines provider into HTML without escaping
app/api/eliza-app/auth/connection-success/route.ts:

<title>${providerLabel} connected</title>
<h1>${providerLabel} connected.</h1>

The PROVIDER_LABELS lookup is safe, but the pattern is fragile — future providers with unescaped HTML characters would create an XSS vector. Add a minimal HTML escape utility.


Performance

11. findByManagedDiscordGuildId does a full table scan on a JSON column
packages/db/repositories/milady-sandboxes.ts:

WHERE (agent_config -> '__miladyManagedDiscord' ->> 'guildId') = $1

This expression query will do a full table scan on every guild message received. A partial index on (agent_config -> '__miladyManagedDiscord' ->> 'guildId') should be added in a migration.


Code Quality / Best Practices

12. asRecord is duplicated across two files
Both packages/lib/services/milady-agent-config.ts and packages/lib/services/milady-managed-discord.ts define an identical asRecord function. Extract to a shared utility.

13. LOOPBACK_REDIRECT_ORIGINS is duplicated across two route files
app/api/v1/discord/callback/route.ts and app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts both define the same constant and allow-list construction pattern. Extract to a shared security utility.

14. milady-google-connector.ts is 972 lines mixing unrelated concerns
HTTP client logic, email-parsing utilities (splitMailboxHeader, parseMailbox), triage classification (classifyReplyNeed), and public API functions are all combined. Consider extracting gmail-utils.ts and gmail-triage.ts.

15. connectAgent/disconnectAgent restart pattern is duplicated verbatim
The shutdown-then-provision logic appears identically in both functions. Extract to a restartAgentIfRunning(sandboxId, organizationId) helper.

16. OAuthService.isPlatformConnected semantics changed without documentation
Before: connections.some((c) => c.status === "active") — any active connection works.
After: getPreferredActiveConnection(connections, userId, connectionRole) !== null — now user-scoped.
This behavioural change to a public method warrants a comment explaining the new semantics, particularly that callers without userId will now get different results when multiple users in the org have connections.


Missing Test Coverage

The test suite is a genuine strength of this PR — discord-automation-oauth.test.ts, milady-managed-discord.test.ts, milady-google-connector.test.ts, etc. provide solid coverage. However:


Minor Nits

  • app/api/eliza-app/connections/route.ts uses a dynamic import("@/lib/services/oauth") inside the request handler while all other similar routes import at module level — inconsistent and slightly slower on cold paths.
  • The milaidy (typo) path aliases in app/api/v1/milaidy/ appear to be intentional backwards-compat shims — a one-line comment explaining this would help future maintainers.

Overall: approve after addressing issues #1 (wildcard postMessage origin), #2 (owner_id semantics), and #11 (missing index). Issues #3 (TOCTOU race) and #5 (sanitizeReturnPath) should be fixed before or shortly after merge.

@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR 424 Review posted via separate message due to shell escaping constraints - see below.

@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR 424 Code Review - Part 1 of 2 (Security and Bugs)

SECURITY

  1. postMessage wildcard origin [app/api/eliza-app/auth/connection-success/route.ts - buildElizaAppHtml]: window.opener.postMessage(payload, '*') sends connectionId to any opener page including malicious tabs. Lock target origin to the known Eliza App origin.

  2. LOOPBACK_REDIRECT_ORIGINS in production [app/api/v1/discord/callback/route.ts and milady discord oauth route]: Loopback origins are allowed unconditionally in all environments. Gate behind NODE_ENV not equal production or a dedicated env flag.

  3. OAuth HMAC comparison uses UTF-8 bytes of base64url strings [packages/lib/services/discord-automation/index.ts decodeOAuthState]: Buffer.from(signature) without encoding flag uses UTF-8. timingSafeEqual is protecting the encoded string not the raw secret bytes. Decode both sides with base64url encoding before comparing.

  4. Guild ownership check gated on flow value [packages/lib/services/discord-automation/index.ts handleBotOAuthCallback]: owner check only fires for milady-managed flow. Other flows skip it allowing non-owners to authorize the bot. Document if intentional.

  5. setWhere in upsert uses bare userId for agent connections [packages/lib/services/oauth/providers/oauth2.ts]: When connectionRole is agent connectionUserId is null but conflict guard still checks userId. Could silently update another user row on re-auth.

POTENTIAL BUGS

  1. Dead null-guard [app/api/internal/discord/eliza-app/messages/route.ts]: After the length greater than 1 guard linkedSandboxes.length is guaranteed 1 so the if(!sandbox) check is unreachable dead code.

  2. Race condition in connectAgent [packages/lib/services/milady-managed-discord.ts]: findByManagedDiscordGuildId and update are not in a transaction. Two concurrent requests for the same guildId can both pass the conflict check. Add a DB unique constraint on the JSONB guildId path or use a transaction.

  3. Config persisted on provision failure [packages/lib/services/milady-managed-discord.ts]: If shutdown succeeds but provision fails the agent is stopped with new agent_config already written. Roll back or surface a distinct config-applied-restart-failed state.

  4. N+1 Gmail API calls [packages/lib/services/milady-google-connector.ts fetchManagedGoogleGmailTriage]: Up to 50 individual Gmail API calls fired in parallel consuming per-user quota at 50x. Consider the Gmail batch endpoint.

  5. Expiry message hardcodes Google [app/api/eliza-app/connections/route.ts]: Route serves any platform via ?platform= but expiry message says Reconnect Google. Make generic or use provider label.

  6. Dynamic import inconsistency [app/api/eliza-app/connections/route.ts]: oauthService dynamically imported inside handler body. All other routes use static imports. No benefit to dynamic import here.

@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

PR 424 Code Review - Part 2 of 2 (Quality, Performance, Tests, Summary)

CODE QUALITY

  1. LOOPBACK_REDIRECT_ORIGINS duplicated: Both app/api/v1/discord/callback/route.ts and the milady discord oauth route define identical loopback origin arrays. Extract to @/lib/security/redirect-validation alongside getDefaultPlatformRedirectOrigins().

  2. getAuthUser() helper duplicated across 13 MCP route files: Every app/api/mcps/*/transport/route.ts file adds an identical local getAuthUser() function. Extract to the shared auth context module.

  3. asRecord() helper defined twice: Both milady-managed-discord.ts and milady-agent-config.ts define identical asRecord() helpers. Extract to a shared utility.

  4. Typo in directory name: app/api/v1/milaidy/ should be milady. If intentional legacy alias add a comment to prevent future confusion.

  5. OAuthState should be a discriminated union: When flow is milady-managed the fields agentId, organizationId, and userId are required but typed as optional. A discriminated union makes this compile-time enforced and eliminates optional-chaining guards in the callback route.

PERFORMANCE

  1. findByManagedDiscordGuildId performs a full table scan on every Discord message [packages/db/repositories/milady-sandboxes.ts]: The JSONB path query has no index. Add a partial expression index: CREATE INDEX milady_sandboxes_managed_discord_guild_id ON milady_sandboxes ((agent_config -> '__miladyManagedDiscord' ->> 'guildId')) WHERE agent_config -> '__miladyManagedDiscord' IS NOT NULL;

TEST COVERAGE

  1. No concurrent connectAgent test: The sequential conflict case is tested but the TOCTOU race is not covered. A test verifying two simultaneous calls for the same guildId produce exactly one success would prevent regression.

  2. buildElizaAppHtml injection surface: connectionId is embedded via JSON.stringify in a script block. A test asserting output safety for values containing close-script tag sequences would harden future changes.

MINOR NITS

  • runtime-factory.ts: Moving export const runtimeFactory above getRuntimeCacheStats fixes initialization order. Worth noting in the commit message.
  • sanitizeReturnPath defaults to /connected - verify this route exists.
  • Bot permissions changed from 83968 to 67193856 adding View Channels and Change Nickname. This scope increase should be explicitly reviewed.

SUMMARY - Priority items before merging:

[Security-High] Lock postMessage target origin instead of using wildcard.
[Security-Medium] Gate loopback redirect origins to non-production environments.
[Security-Medium] Fix HMAC comparison to use decoded binary bytes not base64url strings.
[Bug-Medium] Add DB constraint or transaction for connectAgent guild-link race condition.
[Bug-Medium] Handle provision failure after successful shutdown in agent restart flow.
[Performance] Add JSONB expression index on managed Discord guild ID path.
[Quality] Extract duplicated helpers getAuthUser, LOOPBACK_REDIRECT_ORIGINS, and asRecord into shared utilities.
[Quality] Use discriminated union for OAuthState to enforce milady-managed required fields at compile time.

@lalalune lalalune merged commit 31ebdca into dev Apr 5, 2026
4 of 5 checks passed
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review — PR #424: Managed Discord Install Flow for Milady Agents

Overall the architecture is sound and the test coverage is above average for a feature of this size. There are three high-priority items that should be addressed before merging out of draft, along with several medium and low concerns.


Security

[HIGH] postMessage uses wildcard origin

app/api/eliza-app/auth/connection-success/route.tsbuildElizaAppHtml:

window.opener.postMessage(payload, "*");

connectionId (a live OAuth connection identifier) is broadcast to any origin. A malicious page that opens this URL in a popup can capture it. Lock the target origin to the known Eliza App origin:

window.opener.postMessage(payload, process.env.NEXT_PUBLIC_ELIZA_APP_ORIGIN);

[MEDIUM] Loopback redirect origins are unconditional in production

Both app/api/v1/discord/callback/route.ts and app/api/v1/milady/agents/[agentId]/discord/oauth/route.ts define LOOPBACK_REDIRECT_ORIGINS with no environment guard. In production, http://localhost:<anything> becomes a valid redirect target. Gate behind process.env.NODE_ENV !== "production" or a feature flag.

[MEDIUM] sanitizeReturnPath accepts protocol-relative URLs

app/api/eliza-app/connections/[platform]/initiate/route.ts:

function sanitizeReturnPath(path: string | undefined): string {
  if (!path || !path.startsWith("/")) return "/connected";
  return path;
}

//evil.com/phish starts with / and passes. Add a check to reject paths starting with // or containing ://.

[LOW] Guild owner check only enforced for milady-managed flow

The organization-install flow skips the owner check. If intentional, add a comment; otherwise apply the check to both flows.

[LOW] HMAC comparison idiom is misleading

decodeOAuthState wraps base64url strings in Buffer.from(string) (UTF-8) before timingSafeEqual. This works since the strings are ASCII, but the idiomatic approach is Buffer.from(signature, "base64url") to compare raw HMAC bytes — clearer for future reviewers.


Bugs / Potential Issues

[MEDIUM] Race condition in connectAgent — no transactional guild-conflict guard

packages/lib/services/milady-managed-discord.ts: Two concurrent OAuth callbacks for the same guild can both pass the conflict check before either writes. Add a DB-level unique partial index or a transaction with row-level lock:

CREATE UNIQUE INDEX milady_sandboxes_unique_guild
  ON milady_sandboxes ((agent_config -> '__miladyManagedDiscord' ->> 'guildId'))
  WHERE agent_config -> '__miladyManagedDiscord' IS NOT NULL;

[MEDIUM] Provision failure after config write leaves agent in inconsistent state

In both connectAgent and disconnectAgent, agent_config is persisted before shutdown + provision. If provision fails, the agent is stopped with the new config written. Consider only persisting after the new instance proves it can start, or surface a distinct status for this failure mode.

[LOW] Dead null-guard after length check

app/api/internal/discord/eliza-app/messages/route.ts: After if (linkedSandboxes.length > 1) returns, linkedSandboxes[0] is guaranteed to exist, making the subsequent if (!sandbox) unreachable. Remove it.

[LOW] setWhere on upsert uses raw userId for agent-role connections

packages/lib/services/oauth/providers/oauth2.ts: When connectionRole === "agent", connectionUserId is null, but the conflict guard still checks user_id = ${userId}. This could silently update an unrelated owner-scoped row. Branch the condition on connectionRole.

[LOW] Expired-connection message hardcodes "Google"

app/api/eliza-app/connections/route.ts: The route is platform-agnostic but the expiry message reads "Reconnect Google to keep Gmail and Calendar working." Use the PROVIDER_LABELS lookup instead.


Performance

[HIGH] Missing JSONB expression index — full table scan on every inbound Discord message

packages/db/repositories/milady-sandboxes.ts queries:

WHERE (agent_config -> '__miladyManagedDiscord' ->> 'guildId') = $1

There is no index on this path. Every Discord message hitting POST /api/internal/discord/eliza-app/messages triggers a sequential scan. A migration is needed before production:

CREATE INDEX milady_sandboxes_managed_discord_guild_id
  ON milady_sandboxes ((agent_config -> '__miladyManagedDiscord' ->> 'guildId'))
  WHERE agent_config -> '__miladyManagedDiscord' IS NOT NULL;

[MEDIUM] ambiguous_guild_link silently discards messages

When linkedSandboxes.length > 1 (a data integrity violation), the gateway silently drops the message. Promote this to logger.error and consider sending a generic reply to the Discord user.

[LOW] Dynamic import inside request handler

app/api/eliza-app/connections/route.ts uses await import("@/lib/services/oauth") inside the handler body while all equivalent routes use top-level static imports. Use a static import for consistency and to avoid cold-start overhead.


Code Quality

[MEDIUM] getAuthUser duplicated across 13 MCP route files

Every app/api/mcps/*/[transport]/route.ts defines the same:

function getAuthUser() {
  const ctx = authContextStorage.getStore();
  if (!ctx) throw new Error("Not authenticated");
  return ctx.user;
}

Extract into the shared auth context module and re-export.

[MEDIUM] OAuthState should be a discriminated union

packages/lib/services/discord-automation/types.ts: For milady-managed, agentId, organizationId, and userId are all dereferenced without null checks in the callback route. A discriminated union would make missing fields a compile-time error.

[LOW] asRecord helper defined in two separate files

packages/lib/services/milady-managed-discord.ts and packages/lib/services/milady-agent-config.ts both define identical asRecord(value: unknown) functions. Extract to a shared utility.

[LOW] LOOPBACK_REDIRECT_ORIGINS duplicated across two route files

Extract to @/lib/security/redirect-validation alongside existing helpers.

[LOW] runtimeFactory initialization order fix is an implicit bug fix

Moving export const runtimeFactory = RuntimeFactory.getInstance() above getRuntimeCacheStats in packages/lib/eliza/runtime-factory.ts fixes a real initialization-order bug. Worth noting explicitly in the PR description.

[LOW] milaidy/ typo in directory name

app/api/v1/milaidy/agents/[agentId]/discord/ — if this is a backwards-compat shim, add a comment; if not, correct the typo.

[NITS]

  • OAuthService.isPlatformConnected changed semantics from "any active connection in org" to "preferred active connection for user+role" — a breaking change for callers passing only organizationId. Document the new contract.
  • Bot permissions changed from 8396867193856 (adds "View Channels" and "Change Nickname"). Call this out explicitly in the PR description for security review.
  • buildElizaAppHtml interpolates providerLabel directly into HTML. Currently safe via PROVIDER_LABELS, but a minimal HTML-escape wrapper would prevent future XSS vectors from providers with special characters.

Test Coverage

The test suite (milady-managed-discord.test.ts, managed-discord-eliza-app-route.test.ts, milady-agent-discord-routes.test.ts, discord-automation-oauth.test.ts, milady-google-connector.test.ts) provides solid happy-path and error-case coverage.

Gaps worth addressing:

  1. No test asserting postMessage uses a locked target origin (once fixed, prevents regression)
  2. No test for sanitizeReturnPath("//evil.com")
  3. No concurrent connectAgent test to verify the guild-link uniqueness constraint
  4. No test for ambiguous_guild_link log level and response behavior

Priority Summary

Priority Item
Security-High Lock postMessage target origin (not "*")
Perf-High Add JSONB expression index on guild ID path before deploy
Security-Medium Gate loopback redirect origins to non-production
Security-Medium sanitizeReturnPath must reject //evil.com
Bug-Medium DB unique constraint or transaction for connectAgent race
Bug-Medium Handle provision failure after config write
Quality-Medium Extract getAuthUser into shared auth module
Quality-Medium Use discriminated union for OAuthState

@lalalune lalalune deployed to gateway-dev April 5, 2026 12:04 — with GitHub Actions Active
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Code Review

Overall this is a well-structured PR. The HMAC-signed OAuth state using timingSafeEqual is a good security practice, the guild-conflict detection before linking is solid, and the connection scoping (scopeConnectionsForUser) is a clean model for multi-user orgs. A few issues to address:


🔴 High — Reflected XSS in buildElizaAppHtml

File: app/api/eliza-app/auth/connection-success/route.ts

const payload = JSON.stringify({
  type: "eliza-app-oauth-complete",
  provider,   // from ?platform= query param
  connectionId, // from ?connection_id= query param
  connected: true,
});
// ...
const payload = ${payload};  // injected raw into <script> tag

JSON.stringify does not escape <, >, or / by default in Node.js. A request to:

/api/eliza-app/auth/connection-success?source=eliza-app&platform=x</script><script>alert(document.cookie)</script>

would close the script tag and inject arbitrary JS. providerLabel is safe (it goes through PROVIDER_LABELS lookup), but provider and connectionId are injected raw into the JSON payload that lands inside the <script> block.

Fix: Escape < and > in the serialized JSON before injecting it, or use base64:

const safePayload = JSON.stringify({...}).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
// const payload = ${safePayload};

🟡 Medium — postMessage with wildcard origin leaks connection data

File: app/api/eliza-app/auth/connection-success/route.ts, line ~121

window.opener.postMessage(payload, "*");

The "*" targetOrigin broadcasts the OAuth completion payload (including connectionId) to any window origin. If the user's opener was navigated away or hijacked, another origin can intercept this. The initiating page should pass its origin in the OAuth state and the success page should use it as the targetOrigin.

Fix:

window.opener.postMessage(payload, expectedOrigin); // from state/param

🟡 Medium — sanitizeReturnPath allows protocol-relative open redirect

File: app/api/eliza-app/connections/[platform]/initiate/route.ts

function sanitizeReturnPath(path: string | undefined): string {
  if (!path || !path.startsWith("/")) {
    return "/connected";
  }
  return path;
}

"//evil.com/path" passes this check (it starts with /). When the return_path parameter is eventually used to construct a redirect, the browser treats //evil.com as a protocol-relative URL pointing off-site. The mitigation is to also reject paths starting with //:

if (!path || !path.startsWith("/") || path.startsWith("//")) {
  return "/connected";
}

The risk here is partially mitigated because return_path is encoded into a callback URL query parameter rather than being redirected to directly — but the downstream handler should be reviewed to confirm it validates this before redirecting.


🟡 Medium — Connection enforcement scope change may surprise existing multi-user orgs

Files: packages/lib/services/eliza-app/connection-enforcement.ts (and 4 webhook routes)

hasRequiredConnection now takes userId and scopes the cache key and platform check to the individual user. For orgs where a shared/org-level credential exists (one with no user_id), scopeConnectionsForUser falls back to those — which is good. But for any org that previously relied on one member's connection satisfying enforcement for all users in the org, this is a behavioral regression: each user now needs their own connection.

This may be intentional (the PR description mentions "persist the Discord admin lock"), but it should be explicitly called out in the PR or covered by a migration note, since it silently changes the enforcement model for deployed users.


🟢 Low — Duplicate getAuthUser() helper across 12 MCP route files

Every [transport]/route.ts file in app/api/mcps/ now has this identical 4-line helper:

function getAuthUser() {
  const ctx = authContextStorage.getStore();
  if (!ctx) throw new Error("Not authenticated");
  return ctx.user;
}

This is fine functionally, but given the pattern already exists for getOrgId(), extracting a shared getAuthContext() utility would reduce copy-paste. Low priority — flagging for awareness.


🟢 Low — milaidy typo routes are intentional but worth a note

app/api/v1/milaidy/ re-exports from milady/ — presumably for backwards compat with a typo in a deployed client. The one-liner approach is clean. Just worth a comment in the files so future maintainers know it's intentional and can be removed once clients migrate.


Positive callouts

  • HMAC-signed OAuth state (encodeOAuthState/decodeOAuthState) with timingSafeEqual — correct and well-implemented.
  • Guild conflict detection before linking prevents split-brain across agents.
  • scopeConnectionsForUser — clean model that prefers user-owned connections and gracefully falls back to shared ones.
  • Good test coverage for the new routes (managed-discord-eliza-app-route.test.ts, milady-managed-discord.test.ts, discord-automation-oauth.test.ts).

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.

1 participant