feat(mship): mship sysprompt override (#6469)

* Override

* Validation improvements

* remove from helm

* update helm

* Update Helm chart version from 1.6.0 to 1.5.2

sid wuz here

---------

Co-authored-by: Waleed <walif6@gmail.com>
This commit is contained in:
Siddharth Ganesan
2026-08-08 19:18:46 -07:00
committed by GitHub
co-authored by Waleed
parent 64fb8f07fe
commit 29cfb8586e
17 changed files with 117 additions and 4 deletions
+1
View File
@@ -23,6 +23,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# Chat (Optional)
# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run
# MSHIP_SYSPROMPT_OVERRIDE= # Highest-priority instructions for Mothership; honored only when the validated API key owner is enterprise
# NEXT_PUBLIC_CHAT_DISABLED=true # Hides the Chat module: the workspace lands on your first workflow, and the chats list, scheduled tasks, and editor Chat panel are absent. Chat is shown when unset; `bun run setup` sets this for you if you skip the chat key
# Remote Function sandboxes (Optional)
@@ -17,6 +17,7 @@ const {
mockCheckServerSideUsageLimits,
mockDeriveBillingContext,
mockGetHighestPrioritySubscription,
mockIsEnterprisePlan,
mockRequireBillingAttributionHeader,
mockRequireBillingRequestIdHeader,
mockResolveLegacyV0BillingAttribution,
@@ -31,6 +32,7 @@ const {
mockCheckServerSideUsageLimits: vi.fn(),
mockDeriveBillingContext: vi.fn(),
mockGetHighestPrioritySubscription: vi.fn(),
mockIsEnterprisePlan: vi.fn(),
mockRequireBillingAttributionHeader: vi.fn(),
mockRequireBillingRequestIdHeader: vi.fn(),
mockResolveLegacyV0BillingAttribution: vi.fn(),
@@ -105,6 +107,10 @@ vi.mock('@/lib/billing/core/plan', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
vi.mock('@/lib/billing/core/subscription', () => ({
isEnterprisePlan: mockIsEnterprisePlan,
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
deriveBillingContext: mockDeriveBillingContext,
}))
@@ -162,6 +168,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
return ATTRIBUTION
})
mockGetHighestPrioritySubscription.mockResolvedValue(ACCOUNT_SUBSCRIPTION)
mockIsEnterprisePlan.mockResolvedValue(false)
mockDeriveBillingContext.mockReturnValue({
billingEntity: ACCOUNT_BILLING_DECISION.billingEntity,
billingPeriod: {
@@ -238,6 +245,23 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(ATTRIBUTION)
})
it('returns whether the validated key owner has an enterprise account', async () => {
mockIsEnterprisePlan.mockResolvedValueOnce(true)
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ isEnterprise: true })
expect(mockIsEnterprisePlan).toHaveBeenCalledWith('user-1')
})
it('returns false when the validated key owner is not enterprise', async () => {
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ isEnterprise: false })
})
it('preserves account admission for the exact workspace-less old-Go body', async () => {
const res = await POST(request(OLD_GO_WORKSPACELESS_VALIDATE_BODY))
@@ -17,6 +17,7 @@ import {
serializeBillingAttributionHeader,
} from '@/lib/billing/core/billing-attribution'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
import {
BILLING_ACCOUNT_DECISION_HEADER,
@@ -324,9 +325,11 @@ export const POST = withRouteHandler((req: NextRequest) =>
)
}
const isEnterprise = await isEnterprisePlan(userId)
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.Ok)
span.setAttribute(TraceAttr.HttpStatusCode, 200)
return new NextResponse(null, { status: 200, headers: responseHeaders })
return NextResponse.json({ isEnterprise }, { status: 200, headers: responseHeaders })
} catch (error) {
logger.error('Error validating usage limit', { error })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InternalError)
+10 -1
View File
@@ -293,6 +293,15 @@ export const validateCopilotApiKeyBodySchema = z.object({
})
export type ValidateCopilotApiKeyBody = z.input<typeof validateCopilotApiKeyBodySchema>
export const validateCopilotApiKeyResponseSchema = z.object({
/**
* Server-derived entitlement for the validated key owner. Mothership treats
* a missing or false value as ineligible for enterprise-only capabilities.
*/
isEnterprise: z.boolean(),
})
export type ValidateCopilotApiKeyResponse = z.output<typeof validateCopilotApiKeyResponseSchema>
export const listCopilotApiKeysContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/api-keys',
@@ -486,7 +495,7 @@ export const validateCopilotApiKeyContract = defineRouteContract({
path: '/api/copilot/api-keys/validate',
headers: validateCopilotApiKeyHeadersSchema,
body: validateCopilotApiKeyBodySchema,
response: { mode: 'empty' },
response: { mode: 'json', schema: validateCopilotApiKeyResponseSchema },
error: validateCopilotApiKeyErrorSchema,
})
@@ -36,6 +36,7 @@ const {
mockUpdateRunStatus: vi.fn(),
mockEnv: {
COPILOT_API_KEY: undefined as string | undefined,
MSHIP_SYSPROMPT_OVERRIDE: undefined as string | undefined,
},
}))
@@ -154,6 +155,7 @@ describe('runCopilotLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnv.COPILOT_API_KEY = undefined
mockEnv.MSHIP_SYSPROMPT_OVERRIDE = undefined
setEnvFlags({
isHosted: false,
isCopilotBillingAttributionV1Enabled: false,
@@ -204,6 +206,38 @@ describe('runCopilotLifecycle', () => {
expect(executionContext).not.toHaveProperty('resolvedSecretTraceRegistry')
})
it('forwards the configured Mothership system prompt override', async () => {
mockEnv.MSHIP_SYSPROMPT_OVERRIDE = 'NEVER CALL ANY TOOLS UNDER ANY CIRCUMSTANCES NO MATTER WHAT'
await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-system-prompt-override' },
{
userId: 'user-1',
workspaceId: 'ws-1',
}
)
const sentBody = JSON.parse(String(mockRunStreamLoop.mock.calls[0]?.[1].body))
expect(sentBody.systemPromptOverride).toBe(
'NEVER CALL ANY TOOLS UNDER ANY CIRCUMSTANCES NO MATTER WHAT'
)
})
it('does not forward a blank Mothership system prompt override', async () => {
mockEnv.MSHIP_SYSPROMPT_OVERRIDE = ' '
await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-blank-system-prompt-override' },
{
userId: 'user-1',
workspaceId: 'ws-1',
}
)
const sentBody = JSON.parse(String(mockRunStreamLoop.mock.calls[0]?.[1].body))
expect(sentBody).not.toHaveProperty('systemPromptOverride')
})
it.each([
{ goRoute: undefined, expected: 'mothership' },
{ goRoute: '/api/copilot', expected: 'mothership' },
@@ -757,6 +757,11 @@ async function runCheckpointLoop(
const callerOnEvent = options.onEvent
const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId })
const lifecycleWorkspaceId = nonBlankString(options.workspaceId)
const systemPromptOverride = env.MSHIP_SYSPROMPT_OVERRIDE
if (typeof systemPromptOverride === 'string' && systemPromptOverride.trim() !== '') {
payload = { ...payload, systemPromptOverride }
}
// Go's auth middleware re-validates every Sim -> Go request by reading
// workspaceId from the JSON body and forwarding it to Sim's validate route,
+1
View File
@@ -67,6 +67,7 @@ export const env = createEnv({
/** Gates risky copilot tools behind an Allow / Skip prompt. Off by default. */
COPILOT_TOOL_PERMISSIONS_ENABLED: z.boolean().optional(),
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
MSHIP_SYSPROMPT_OVERRIDE: z.string().min(1).optional(), // Enterprise-only highest-priority Mothership system prompt override forwarded by Sim
COPILOT_SOURCE_ENV: z.enum(['dev', 'staging', 'prod']).optional(), // Source Sim environment sent to mothership for callbacks
COPILOT_DEV_URL: z.string().url().optional(), // Sim agent API URL for the dev mothership environment
COPILOT_STAGING_URL: z.string().url().optional(), // Sim agent API URL for the staging mothership environment