Skip to content

Commit 6dc3a6c

Browse files
committed
fix(mothership): BYOK rides every worker leg — resume, copilot route, execute, title
Companion to worker batch A. The key attached only to '/api/mothership*' sends: workflow-scoped copilot chats never pinned, and a resume landing on a dead run became a hosted-key continuation. Resolution moves to a shared resolveEnterpriseByokKey (entitlement-gated, revocation-fresh, fails to hosted), applied per leg in the lifecycle loop, on child-chain resume legs, and on title generation (which reads message content). Regenerated protocol mirror carries the new optional fields. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent ec6e084 commit 6dc3a6c

5 files changed

Lines changed: 99 additions & 25 deletions

File tree

‎apps/sim/lib/mothership/generated/protocol.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ export interface ChatContextItem {
6161
export interface ResumeRequest {
6262
streamId: string;
6363
results: ResumeResult[];
64+
/**
65+
* Enterprise BYOK, re-resolved by sim per call (S27: context-only, zero retention).
66+
* A LIVE run keeps its key inside the loop closure and ignores this; a DEAD run's
67+
* continuation leg has no closure, so without it that leg would silently fall back
68+
* to the hosted key mid-chat.
69+
*/
70+
byokApiKey?: string | undefined;
6471
}
6572

6673
export interface ResumeResult {
@@ -87,6 +94,8 @@ export interface SteerRequest {
8794
/** POST /api/generate-chat-title */
8895
export interface TitleRequest {
8996
message: string;
97+
/** Enterprise BYOK: the title call reads user content, so it pins the same key (S27). */
98+
byokApiKey?: string | undefined;
9099
}
91100

92101
/** The 409 body for a duplicate send while a sibling instance streams (S32). */
@@ -123,6 +132,8 @@ export interface ExecuteRequest {
123132
integrationTools?: unknown[] | undefined;
124133
mothershipTools?: unknown[] | undefined;
125134
delegationToken?: string | undefined;
135+
/** Enterprise BYOK: one-shot executions pin the customer key like chat turns (S27). */
136+
byokApiKey?: string | undefined;
126137
}
127138

128139
export interface ExecuteMessage {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { getBYOKKey } from '@/lib/api-key/byok'
4+
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
5+
6+
const logger = createLogger('EnterpriseByok')
7+
8+
/**
9+
* Resolves the enterprise BYOK key sim-side for a mothership call (contract field
10+
* `byokApiKey`, S27): the worker builds per-request provider instances from it and
11+
* retains nothing. Eligibility (enterprise plan) gates resolution server-side, so a
12+
* client can never assert its own eligibility; key rows are read fresh so revocation is
13+
* immediate. Failures default to hosted.
14+
*
15+
* Every worker call that reaches a model must resolve this — the initial send, the
16+
* workflow-scoped copilot send, one-shot executes, tool-resume (a dead-run continuation
17+
* leg re-applies it), and title generation — or that leg silently runs on the hosted key.
18+
*/
19+
export async function resolveEnterpriseByokKey(
20+
workspaceId: string | undefined
21+
): Promise<string | null> {
22+
if (!workspaceId) return null
23+
try {
24+
if (!(await isWorkspaceOnEnterprisePlan(workspaceId))) return null
25+
const byok = await getBYOKKey(workspaceId, 'anthropic')
26+
return byok?.apiKey ?? null
27+
} catch (error) {
28+
logger.warn('Failed to resolve BYOK key; defaulting to hosted', {
29+
workspaceId,
30+
error: toError(error).message,
31+
})
32+
return null
33+
}
34+
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ vi.mock('@/lib/mothership/request/tools/executor', () => ({
163163
pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs,
164164
}))
165165

166+
const { mockResolveEnterpriseByokKey } = vi.hoisted(() => ({
167+
mockResolveEnterpriseByokKey: vi.fn().mockResolvedValue(null),
168+
}))
169+
vi.mock('@/lib/mothership/request/enterprise-byok', () => ({
170+
resolveEnterpriseByokKey: mockResolveEnterpriseByokKey,
171+
}))
172+
166173
import {
167174
MothershipStreamV1CompletionStatus,
168175
MothershipStreamV1ToolOutcome,
@@ -435,6 +442,25 @@ describe('runCopilotLifecycle', () => {
435442
expect(sent).toEqual(payload)
436443
})
437444

445+
it('attaches the resolved enterprise BYOK key to the outbound payload', async () => {
446+
mockResolveEnterpriseByokKey.mockResolvedValueOnce('sk-ant-enterprise-test')
447+
const payload = { message: 'hi', workspaceId: 'ws-ent', messageId: 'stream-byok-attach' }
448+
let capturedRequestBody = ''
449+
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
450+
capturedRequestBody = String(request.body)
451+
})
452+
453+
await runCopilotLifecycle(payload, {
454+
userId: 'user-1',
455+
workspaceId: 'ws-ent',
456+
executionContext: { userId: 'user-1', workflowId: '', workspaceId: 'ws-ent' },
457+
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]),
458+
})
459+
460+
const sent = JSON.parse(capturedRequestBody)
461+
expect(sent.byokApiKey).toBe('sk-ant-enterprise-test')
462+
})
463+
438464
it('preserves large ordinary tool catalogs without scanning configured secret values', async () => {
439465
const registry = new ResolvedSecretTraceRegistry([
440466
{ name: 'TOKEN', plaintext: 'catalog-secret', encryptedValue: 'ciphertext' },

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

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
55
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
66
import { generateId } from '@sim/utils/id'
77
import { omit } from '@sim/utils/object'
8-
import { getBYOKKey } from '@/lib/api-key/byok'
98
import {
109
type AttributedBillingRequestEnvelope,
1110
assertBillingAttributionSnapshot,
1211
type BillingAttributionSnapshot,
1312
checkAttributedUsageLimits,
1413
createAttributedBillingRequestEnvelope,
1514
} from '@/lib/billing/core/billing-attribution'
16-
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
1715
import { env } from '@/lib/core/config/env'
1816
import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags'
1917
import type { AsyncCompletionSignal } from '@/lib/mothership/async-runs/lifecycle'
@@ -33,6 +31,7 @@ import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribut
3331
import { getAutoAllowedTools } from '@/lib/mothership/persistence/tool-permission/auto-allow'
3432
import { createStreamingContext } from '@/lib/mothership/request/context/request-context'
3533
import { buildToolCallSummaries } from '@/lib/mothership/request/context/result'
34+
import { resolveEnterpriseByokKey } from '@/lib/mothership/request/enterprise-byok'
3635
import {
3736
BillingLimitError,
3837
CopilotBackendError,
@@ -773,11 +772,15 @@ async function driveOneChildChain(
773772
options.onAbortObserved?.(reason)
774773
},
775774
}
775+
// Same per-leg BYOK rule as the main loop: this child-chain resume can also land on
776+
// a dead run and become a hosted-key continuation without it.
777+
const byokApiKey = await resolveEnterpriseByokKey(workspaceId)
776778
await runResumeLegWithRetry(
777779
`${baseURL}/api/tools/resume`,
778780
{
779781
streamId: context.messageId,
780782
results,
783+
...(byokApiKey ? { byokApiKey } : {}),
781784
},
782785
leg,
783786
execContext,
@@ -919,15 +922,15 @@ async function runCheckpointLoop(
919922
payload = { ...payload, workspaceId: lifecycleWorkspaceId }
920923
}
921924

922-
// Enterprise BYOK eligibility hint: set once on the initial mothership request
923-
// so Go only attempts a BYOK lookup for entitled workspaces. This is only a
924-
// gate — Go re-confirms entitlement authoritatively before using any key.
925-
payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId)
926-
927925
for (;;) {
928926
context.streamComplete = false
929927
const isResume = route === '/api/tools/resume'
930928

929+
// Enterprise BYOK rides EVERY leg, resume included: a resume that lands on a dead
930+
// run becomes a continuation with no closure holding the key. Re-resolved per leg so
931+
// revocation is immediate (key rows are read fresh; entitlement is cached).
932+
payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId)
933+
931934
if (isResume && isAborted(options, context)) {
932935
cancelPendingTools(context)
933936
context.awaitingAsyncContinuation = undefined
@@ -1437,30 +1440,26 @@ async function ensureHeadlessRunIdentity(input: {
14371440
// Helpers
14381441

14391442
/**
1440-
* Resolves the enterprise BYOK key sim-side and attaches it as `byokApiKey`
1441-
* (contract field, S27): the worker builds a per-run provider instance from it and
1442-
* retains nothing. Eligibility (enterprise plan) gates resolution server-side, so a
1443-
* client can never assert its own eligibility; key rows are read fresh so revocation
1444-
* is immediate. Failures default to hosted. Mothership-only — other routes untouched.
1443+
* Routes whose payloads carry `byokApiKey` (see resolveEnterpriseByokKey): every
1444+
* model-reaching worker call, INCLUDING tool-resume — a resume that lands on a dead run
1445+
* becomes a continuation leg with no closure holding the key, so omitting it there
1446+
* silently finishes an enterprise chat on the hosted key.
14451447
*/
1448+
const BYOK_ROUTES = [
1449+
'/api/mothership',
1450+
'/api/mothership/execute',
1451+
'/api/copilot',
1452+
'/api/tools/resume',
1453+
]
1454+
14461455
async function withEnterpriseByokKey(
14471456
payload: Record<string, unknown>,
14481457
route: string,
14491458
workspaceId?: string
14501459
): Promise<Record<string, unknown>> {
1451-
if (!workspaceId || !route.startsWith('/api/mothership')) return payload
1452-
try {
1453-
if (!(await isWorkspaceOnEnterprisePlan(workspaceId))) return payload
1454-
const byok = await getBYOKKey(workspaceId, 'anthropic')
1455-
if (!byok) return payload
1456-
return { ...payload, byokApiKey: byok.apiKey }
1457-
} catch (error) {
1458-
logger.warn('Failed to resolve BYOK key; defaulting to hosted', {
1459-
workspaceId,
1460-
error: toError(error).message,
1461-
})
1462-
return payload
1463-
}
1460+
if (!BYOK_ROUTES.includes(route)) return payload
1461+
const byokApiKey = await resolveEnterpriseByokKey(workspaceId)
1462+
return byokApiKey ? { ...payload, byokApiKey } : payload
14641463
}
14651464

14661465
function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@/lib/mothership/generated/trace-attribute-values-v1'
2929
import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
3030
import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1'
31+
import { resolveEnterpriseByokKey } from '@/lib/mothership/request/enterprise-byok'
3132
import { mothershipRequestHeaders } from '@/lib/mothership/request/headers'
3233
import { finalizeStream } from '@/lib/mothership/request/lifecycle/finalize'
3334
import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run'
@@ -551,11 +552,14 @@ export async function requestChatTitle(params: {
551552

552553
const { fetchGo } = await import('@/lib/mothership/request/go/fetch')
553554
const mothershipBaseURL = await getMothershipBaseURL({ userId })
555+
// Title reads the user's message content, so an enterprise chat pins its key here too.
556+
const byokApiKey = await resolveEnterpriseByokKey(workspaceId)
554557
const response = await fetchGo(`${mothershipBaseURL}/api/generate-chat-title`, {
555558
method: 'POST',
556559
headers,
557560
body: JSON.stringify({
558561
message,
562+
...(byokApiKey ? { byokApiKey } : {}),
559563
}),
560564
otelContext,
561565
spanName: 'sim → go /api/generate-chat-title',

0 commit comments

Comments
 (0)