Skip to content

Commit 1e8a1d0

Browse files
committed
refactor(mothership): sim lows #11,#12 — one shim validator, one header assembly
validateShimEnvelope (canonical in request/http.ts, audit-recognized) replaces the quadruplicated clone/parse/validate block in the four /api/mothership/* alias routes. mothershipRequestHeaders moves to request/headers.ts; title + steer now assemble through it (and gain the X-Client-Version their hand-rolled copies had dropped). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent 85f90fa commit 1e8a1d0

10 files changed

Lines changed: 66 additions & 83 deletions

File tree

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
import type { NextRequest } from 'next/server'
22
import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
3-
import { validationErrorResponse } from '@/lib/api/server'
43
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
4+
import { validateShimEnvelope } from '@/lib/mothership/request/http'
55
import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route'
66

77
export const POST = withRouteHandler(async (request: NextRequest) => {
8-
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
9-
const body = await request
10-
.clone()
11-
.json()
12-
.catch(() => undefined)
13-
if (body !== undefined) {
14-
const validation = mothershipChatAbortEnvelopeSchema.safeParse(body)
15-
if (!validation.success) return validationErrorResponse(validation.error)
16-
}
8+
const invalid = await validateShimEnvelope(request, mothershipChatAbortEnvelopeSchema)
9+
if (invalid) return invalid
1710

1811
return copilotAbortPost(request, undefined)
1912
})

apps/sim/app/api/mothership/chat/resources/route.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
11
import type { NextRequest, NextResponse } from 'next/server'
22
import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
3-
import { validationErrorResponse } from '@/lib/api/server'
43
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
4+
import { validateShimEnvelope } from '@/lib/mothership/request/http'
55
import {
66
DELETE as copilotResourcesDelete,
77
PATCH as copilotResourcesPatch,
88
POST as copilotResourcesPost,
99
} from '@/app/api/copilot/chat/resources/route'
1010

1111
async function validateResourceRequestEnvelope(request: NextRequest): Promise<NextResponse | null> {
12-
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
13-
const body = await request
14-
.clone()
15-
.json()
16-
.catch(() => undefined)
17-
if (body !== undefined) {
18-
const validation = mothershipChatResourceEnvelopeSchema.safeParse(body)
19-
if (!validation.success) return validationErrorResponse(validation.error)
20-
}
12+
const invalid = await validateShimEnvelope(request, mothershipChatResourceEnvelopeSchema)
13+
if (invalid) return invalid
2114
return null
2215
}
2316

apps/sim/app/api/mothership/chat/route.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { validationErrorResponse } from '@/lib/api/server'
77
import { getSession } from '@/lib/auth'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { handleUnifiedChatPost, maxDuration } from '@/lib/mothership/chat/post'
10+
import { validateShimEnvelope } from '@/lib/mothership/request/http'
1011
import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries'
1112

1213
export { maxDuration }
@@ -27,15 +28,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2728
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2829
}
2930

30-
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
31-
const body = await request
32-
.clone()
33-
.json()
34-
.catch(() => undefined)
35-
if (body !== undefined) {
36-
const validation = mothershipChatPostEnvelopeSchema.safeParse(body)
37-
if (!validation.success) return validationErrorResponse(validation.error)
38-
}
31+
const invalid = await validateShimEnvelope(request, mothershipChatPostEnvelopeSchema)
32+
if (invalid) return invalid
3933

4034
return handleUnifiedChatPost(request)
4135
})
Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
import type { NextRequest } from 'next/server'
22
import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
3-
import { validationErrorResponse } from '@/lib/api/server'
43
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
4+
import { validateShimEnvelope } from '@/lib/mothership/request/http'
55
import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route'
66

77
export const POST = withRouteHandler(async (request: NextRequest) => {
8-
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
9-
const body = await request
10-
.clone()
11-
.json()
12-
.catch(() => undefined)
13-
if (body !== undefined) {
14-
const validation = mothershipChatStopEnvelopeSchema.safeParse(body)
15-
if (!validation.success) return validationErrorResponse(validation.error)
16-
}
8+
const invalid = await validateShimEnvelope(request, mothershipChatStopEnvelopeSchema)
9+
if (invalid) return invalid
1710

1811
return copilotStopPost(request, undefined)
1912
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { AttributedBillingRequestEnvelope } from '@/lib/billing/core/billing-attribution'
2+
import { env } from '@/lib/core/config/env'
3+
import { SIM_AGENT_VERSION } from '@/lib/mothership/constants'
4+
import { getMothershipSourceEnvHeaders } from '@/lib/mothership/server/agent-url'
5+
6+
/**
7+
* The one assembly for sim -> mothership request headers (chat, title, steer, resume).
8+
* Three hand-rolled copies drifted on X-Client-Version and the request-id header.
9+
*/
10+
export function mothershipRequestHeaders(
11+
hostedBillingRequest?: AttributedBillingRequestEnvelope,
12+
simRequestId?: string
13+
): Record<string, string> {
14+
return {
15+
'Content-Type': 'application/json',
16+
...(simRequestId ? { 'X-Sim-Request-ID': simRequestId } : {}),
17+
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
18+
...getMothershipSourceEnvHeaders(),
19+
'X-Client-Version': SIM_AGENT_VERSION,
20+
...(hostedBillingRequest ? hostedBillingRequest.headers : {}),
21+
}
22+
}

apps/sim/lib/mothership/request/http.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { safeCompare } from '@sim/security/compare'
22
import { generateId } from '@sim/utils/id'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
5+
import type { z } from 'zod'
6+
import { validationErrorResponse } from '@/lib/api/server'
57
import { getSession } from '@/lib/auth'
68
import { env } from '@/lib/core/config/env'
79
import { generateRequestId } from '@/lib/core/utils/request'
@@ -102,3 +104,23 @@ export function checkInternalApiKey(req: NextRequest) {
102104

103105
return { success: true }
104106
}
107+
108+
/**
109+
* Shim-route envelope pre-validation (the /api/mothership/* aliases): clone the body,
110+
* and when it parses as JSON, check it against the envelope schema before delegating to
111+
* the copilot handler that actually consumes the request. Returns the 400 to send, or
112+
* null to proceed. A non-JSON body proceeds — the delegate owns that failure mode.
113+
*/
114+
export async function validateShimEnvelope(
115+
request: NextRequest,
116+
schema: z.ZodType
117+
): Promise<NextResponse | null> {
118+
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
119+
const body = await request
120+
.clone()
121+
.json()
122+
.catch(() => undefined)
123+
if (body === undefined) return null
124+
const validation = schema.safeParse(body)
125+
return validation.success ? null : validationErrorResponse(validation.error)
126+
}

apps/sim/lib/mothership/request/lifecycle/run.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { env } from '@/lib/core/config/env'
1818
import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags'
1919
import type { AsyncCompletionSignal } from '@/lib/mothership/async-runs/lifecycle'
2020
import { createRunSegment, updateRunStatus } from '@/lib/mothership/async-runs/repository'
21-
import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/mothership/constants'
21+
import { TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/mothership/constants'
2222
import {
2323
type CopilotEnvironmentContext,
2424
prepareCopilotEnvironmentContext,
@@ -39,6 +39,7 @@ import {
3939
runStreamLoop,
4040
StreamEndedWithoutTerminalError,
4141
} from '@/lib/mothership/request/go/stream'
42+
import { mothershipRequestHeaders } from '@/lib/mothership/request/headers'
4243
import { recordDegraded } from '@/lib/mothership/request/metrics'
4344
import { AbortReason } from '@/lib/mothership/request/session/abort-reason'
4445
import {
@@ -64,10 +65,7 @@ import type {
6465
StreamingContext,
6566
} from '@/lib/mothership/request/types'
6667
import type { SecretMountPolicy } from '@/lib/mothership/secret-mount-policy'
67-
import {
68-
getMothershipBaseURL,
69-
getMothershipSourceEnvHeaders,
70-
} from '@/lib/mothership/server/agent-url'
68+
import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url'
7169
import { prepareExecutionContext } from '@/lib/mothership/tools/handlers/context'
7270
import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
7371
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
@@ -481,20 +479,6 @@ function isPerSubagentContinuation(c: AsyncContinuation): boolean {
481479
// Shared header set for every Sim -> Go mothership request (initial stream and
482480
// every resume leg), so the auth/source/version headers can't drift between the
483481
// sequential path and the concurrent per-subagent resume legs.
484-
function mothershipRequestHeaders(
485-
hostedBillingRequest?: AttributedBillingRequestEnvelope,
486-
simRequestId?: string
487-
): Record<string, string> {
488-
return {
489-
'Content-Type': 'application/json',
490-
...(simRequestId ? { 'X-Sim-Request-ID': simRequestId } : {}),
491-
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
492-
...getMothershipSourceEnvHeaders(),
493-
'X-Client-Version': SIM_AGENT_VERSION,
494-
...(hostedBillingRequest ? hostedBillingRequest.headers : {}),
495-
}
496-
}
497-
498482
// makeResumeLegContext / mergeResumeLegOutputs are a PAIR and must stay in
499483
// lockstep: every field reset here is folded back there, and nothing else on
500484
// StreamingContext is per-leg. Everything not listed is shared BY REFERENCE

apps/sim/lib/mothership/request/lifecycle/start.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
createAttributedBillingRequestEnvelope,
1111
resolveBillingAttribution,
1212
} from '@/lib/billing/core/billing-attribution'
13-
import { env } from '@/lib/core/config/env'
1413
import { isHosted } from '@/lib/core/config/env-flags'
1514
import { createRunSegment } from '@/lib/mothership/async-runs/repository'
1615
import { chatPubSub } from '@/lib/mothership/chat-status'
@@ -29,6 +28,7 @@ import {
2928
} from '@/lib/mothership/generated/trace-attribute-values-v1'
3029
import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
3130
import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1'
31+
import { mothershipRequestHeaders } from '@/lib/mothership/request/headers'
3232
import { finalizeStream } from '@/lib/mothership/request/lifecycle/finalize'
3333
import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run'
3434
import { runCopilotLifecycle } from '@/lib/mothership/request/lifecycle/run'
@@ -48,10 +48,7 @@ import {
4848
} from '@/lib/mothership/request/session'
4949
import { SSE_RESPONSE_HEADERS } from '@/lib/mothership/request/session/sse'
5050
import { TraceCollector } from '@/lib/mothership/request/trace'
51-
import {
52-
getMothershipBaseURL,
53-
getMothershipSourceEnvHeaders,
54-
} from '@/lib/mothership/server/agent-url'
51+
import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url'
5552

5653
export { SSE_RESPONSE_HEADERS }
5754

@@ -524,13 +521,7 @@ export async function requestChatTitle(params: {
524521
const { message, model, provider, userId, workspaceId, billingAttribution, otelContext } = params
525522
if (!message || !model) return null
526523

527-
const headers: Record<string, string> = {
528-
'Content-Type': 'application/json',
529-
}
530-
if (env.COPILOT_API_KEY) {
531-
headers['x-api-key'] = env.COPILOT_API_KEY
532-
}
533-
Object.assign(headers, getMothershipSourceEnvHeaders())
524+
const headers = mothershipRequestHeaders()
534525

535526
try {
536527
if (isHosted) {

apps/sim/lib/mothership/request/session/steer.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import type { Context } from '@opentelemetry/api'
2-
import { env } from '@/lib/core/config/env'
32
import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
43
import { fetchGo } from '@/lib/mothership/request/go/fetch'
5-
import {
6-
getMothershipBaseURL,
7-
getMothershipSourceEnvHeaders,
8-
} from '@/lib/mothership/server/agent-url'
4+
import { mothershipRequestHeaders } from '@/lib/mothership/request/headers'
5+
import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url'
96

107
export const DEFAULT_STEER_TIMEOUT_MS = 3000
118

@@ -37,13 +34,7 @@ export async function requestStreamSteering(params: {
3734
otelContext,
3835
} = params
3936

40-
const headers: Record<string, string> = {
41-
'Content-Type': 'application/json',
42-
}
43-
if (env.COPILOT_API_KEY) {
44-
headers['x-api-key'] = env.COPILOT_API_KEY
45-
}
46-
Object.assign(headers, getMothershipSourceEnvHeaders())
37+
const headers = mothershipRequestHeaders()
4738

4839
const controller = new AbortController()
4940
const timeout = setTimeout(() => controller.abort('steer_fetch_timeout'), timeoutMs)

scripts/check-api-validation-contracts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN =
173173
/\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/
174174
const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/
175175
const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/
176-
const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/
176+
const CONTRACT_SERVER_HELPER_PATTERN = /\b(?:parseToolRequest|validateShimEnvelope)\(/
177177
const CANONICAL_HELPER_USAGE_PATTERN =
178178
/\b(?:isZodError|validationErrorResponse|validationErrorResponseFromError|getValidationErrorMessage)\s*\(/
179179
const CONTRACT_MAP_PARSE_PATTERN =

0 commit comments

Comments
 (0)