Skip to content

Commit 08cb445

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 1fbc6e5 commit 08cb445

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
@@ -157,6 +157,13 @@ vi.mock('@/lib/mothership/request/tools/executor', () => ({
157157
pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs,
158158
}))
159159

160+
const { mockResolveEnterpriseByokKey } = vi.hoisted(() => ({
161+
mockResolveEnterpriseByokKey: vi.fn().mockResolvedValue(null),
162+
}))
163+
vi.mock('@/lib/mothership/request/enterprise-byok', () => ({
164+
resolveEnterpriseByokKey: mockResolveEnterpriseByokKey,
165+
}))
166+
160167
import {
161168
MothershipStreamV1CompletionStatus,
162169
MothershipStreamV1ToolOutcome,
@@ -428,6 +435,25 @@ describe('runCopilotLifecycle', () => {
428435
expect(sent).toEqual(payload)
429436
})
430437

438+
it('attaches the resolved enterprise BYOK key to the outbound payload', async () => {
439+
mockResolveEnterpriseByokKey.mockResolvedValueOnce('sk-ant-enterprise-test')
440+
const payload = { message: 'hi', workspaceId: 'ws-ent', messageId: 'stream-byok-attach' }
441+
let capturedRequestBody = ''
442+
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
443+
capturedRequestBody = String(request.body)
444+
})
445+
446+
await runCopilotLifecycle(payload, {
447+
userId: 'user-1',
448+
workspaceId: 'ws-ent',
449+
executionContext: { userId: 'user-1', workflowId: '', workspaceId: 'ws-ent' },
450+
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]),
451+
})
452+
453+
const sent = JSON.parse(capturedRequestBody)
454+
expect(sent.byokApiKey).toBe('sk-ant-enterprise-test')
455+
})
456+
431457
it('preserves large ordinary tool catalogs without scanning configured secret values', async () => {
432458
const registry = new ResolvedSecretTraceRegistry([
433459
{ 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 { 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,
@@ -733,11 +732,15 @@ async function driveOneChildChain(
733732
options.onAbortObserved?.(reason)
734733
},
735734
}
735+
// Same per-leg BYOK rule as the main loop: this child-chain resume can also land on
736+
// a dead run and become a hosted-key continuation without it.
737+
const byokApiKey = await resolveEnterpriseByokKey(workspaceId)
736738
await runResumeLegWithRetry(
737739
`${baseURL}/api/tools/resume`,
738740
{
739741
streamId: context.messageId,
740742
results,
743+
...(byokApiKey ? { byokApiKey } : {}),
741744
},
742745
leg,
743746
execContext,
@@ -879,15 +882,15 @@ async function runCheckpointLoop(
879882
payload = { ...payload, workspaceId: lifecycleWorkspaceId }
880883
}
881884

882-
// Enterprise BYOK eligibility hint: set once on the initial mothership request
883-
// so Go only attempts a BYOK lookup for entitled workspaces. This is only a
884-
// gate — Go re-confirms entitlement authoritatively before using any key.
885-
payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId)
886-
887885
for (;;) {
888886
context.streamComplete = false
889887
const isResume = route === '/api/tools/resume'
890888

889+
// Enterprise BYOK rides EVERY leg, resume included: a resume that lands on a dead
890+
// run becomes a continuation with no closure holding the key. Re-resolved per leg so
891+
// revocation is immediate (key rows are read fresh; entitlement is cached).
892+
payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId)
893+
891894
if (isResume && isAborted(options, context)) {
892895
cancelPendingTools(context)
893896
context.awaitingAsyncContinuation = undefined
@@ -1397,30 +1400,26 @@ async function ensureHeadlessRunIdentity(input: {
13971400
// Helpers
13981401

13991402
/**
1400-
* Resolves the enterprise BYOK key sim-side and attaches it as `byokApiKey`
1401-
* (contract field, S27): the worker builds a per-run provider instance from it and
1402-
* retains nothing. Eligibility (enterprise plan) gates resolution server-side, so a
1403-
* client can never assert its own eligibility; key rows are read fresh so revocation
1404-
* is immediate. Failures default to hosted. Mothership-only — other routes untouched.
1403+
* Routes whose payloads carry `byokApiKey` (see resolveEnterpriseByokKey): every
1404+
* model-reaching worker call, INCLUDING tool-resume — a resume that lands on a dead run
1405+
* becomes a continuation leg with no closure holding the key, so omitting it there
1406+
* silently finishes an enterprise chat on the hosted key.
14051407
*/
1408+
const BYOK_ROUTES = [
1409+
'/api/mothership',
1410+
'/api/mothership/execute',
1411+
'/api/copilot',
1412+
'/api/tools/resume',
1413+
]
1414+
14061415
async function withEnterpriseByokKey(
14071416
payload: Record<string, unknown>,
14081417
route: string,
14091418
workspaceId?: string
14101419
): Promise<Record<string, unknown>> {
1411-
if (!workspaceId || !route.startsWith('/api/mothership')) return payload
1412-
try {
1413-
if (!(await isWorkspaceOnEnterprisePlan(workspaceId))) return payload
1414-
const byok = await getBYOKKey(workspaceId, 'anthropic')
1415-
if (!byok) return payload
1416-
return { ...payload, byokApiKey: byok.apiKey }
1417-
} catch (error) {
1418-
logger.warn('Failed to resolve BYOK key; defaulting to hosted', {
1419-
workspaceId,
1420-
error: toError(error).message,
1421-
})
1422-
return payload
1423-
}
1420+
if (!BYOK_ROUTES.includes(route)) return payload
1421+
const byokApiKey = await resolveEnterpriseByokKey(workspaceId)
1422+
return byokApiKey ? { ...payload, byokApiKey } : payload
14241423
}
14251424

14261425
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'
@@ -541,11 +542,14 @@ export async function requestChatTitle(params: {
541542

542543
const { fetchGo } = await import('@/lib/mothership/request/go/fetch')
543544
const mothershipBaseURL = await getMothershipBaseURL({ userId })
545+
// Title reads the user's message content, so an enterprise chat pins its key here too.
546+
const byokApiKey = await resolveEnterpriseByokKey(workspaceId)
544547
const response = await fetchGo(`${mothershipBaseURL}/api/generate-chat-title`, {
545548
method: 'POST',
546549
headers,
547550
body: JSON.stringify({
548551
message,
552+
...(byokApiKey ? { byokApiKey } : {}),
549553
}),
550554
otelContext,
551555
spanName: 'sim → go /api/generate-chat-title',

0 commit comments

Comments
 (0)