Skip to content

Commit c3e5bba

Browse files
authored
feat: unify the provider tables and resolve the chat provider on the server (#2540)
* feat(server): merge acp agents and llm providers into one table Both are providers for the chat, and everything above the database already said so: the chat target is one union with a kind on the client, and the wire target has always been a discriminated union. Only storage disagreed, and it charged for the mismatch. Selecting an acp agent left the default provider pointing at the previously selected llm one, because the write was conditional on kind. A scheduled job could reference an llm provider and nothing else, so picking Claude Code in chat was possible while scheduling against it was not. Migration 0010 creates the unified table and copies both sources in; 0011 repoints scheduled jobs and drops the old tables. Two migrations rather than one, so the copy proves itself before anything is dropped, and because drizzle cannot diff a simultaneous add and remove without a rename prompt. Drizzle put its drops ahead of its own foreign key pragma, which fired the ON DELETE SET NULL on scheduled jobs and silently unlinked every job from its provider. Reordered so the drops happen after the rebuild, inside the block where enforcement is off. The default provider now lives on a column, and a partial unique index admits exactly one row, of either kind. Not keyed by profile: sqlite treats nulls as distinct in a unique index, so pairing it with the unset profile column would let every row be default at once. Also brings the packaged-build schema fallback current. It had drifted four migrations behind, so a build without migration files would have created a database with none of this epic's tables. The test's copy of the migration history is now derived from the journal, since a hand written duplicate is what let the drift go unnoticed. * feat: resolve the chat provider on the server The provider block is gone from the chat body. A request names an id, or names nothing and gets whichever provider is selected, and the server fills in the model, endpoint and credentials from the row. Sixteen fields of provider configuration collapse into one, and the api key, the aws secret and the session token stop crossing the wire on every message. Every one of those fields is still accepted. The extension updates independently of the browser binary, so a shipped build can be running a client that sends the whole configuration inline; the server stops requiring them, not accepting them, and a row it does not recognise leaves whatever the client sent in place. The selection moved to the server with the tables it points at, so choosing a coding agent now records it. It could not before: the default lived in extension storage and only ever named an llm provider, so picking an agent left it pointing at the provider chosen before it. The scheduled runner drops its provider lookup entirely, and the guard that came with it. That guard existed because an unreachable list and an empty one looked alike, so a job could run on the built-in provider with the wrong credentials and still be recorded as a success. There is nothing to tell apart now: the job names an id and the server resolves it. Refine prompt still resolves on the client. It posts to its own endpoint with its own schema, and giving it the same treatment is separate work. * fix(server): gate chat on trust when the server supplies the credentials A browseros chat request is deliberately allowed without the app-origin check, on the reasoning that it carries its own credentials and so can only spend what the caller already had. Resolving the provider from storage broke that reasoning: naming an id, or naming nothing and taking the selected provider, would have let any local caller spend the user's key against an external service. The check now applies exactly when the configuration came from a stored row. A request that brought its own is as unrestricted as it was before, so the capability that reasoning was about is untouched. * fix(server): gate chat on every path where the server holds the credential The previous gate keyed on whether a stored provider row was read, on the reasoning that a request naming no known row must have brought its own key. That is false for four provider types. The oauth three take a token from this machine's oauth store and browseros takes the gateway credential, so naming one with an unknown id skipped the check and had the server hand over a credential the caller never held. The predicate lives beside resolveLLMConfig, since it has to mirror those branches exactly and would drift if the route kept its own copy. This particular hole predates the change: the exemption for browseros requests and the credential injection behind it were both already there. It is fixed here because the gate added alongside it claims the ungated path is safe, and that claim has to hold. The chat integration test now sends the origin header the extension always sends. It was relying on the exemption this closes, and the routes the background alarm runner already calls carry that header today. * test(server): stop a module mock dropping the exports it does not name CI failed with `Export named 'SERVER_CREDENTIALED_PROVIDERS' not found` against a file that plainly exports it. A module factory is a total replacement, so everything it omits disappears for every file importing that module afterwards, and bun's registry is process wide. Adding an export to a module someone mocks partially is enough to break a different file entirely. The factory now spreads the real module and overrides only the function under test. This does not reproduce locally: file ordering is stable on APFS and not on ext4, which the test runner's own notes call out as the reason this class of failure kills CI while local runs pass.
1 parent 92df7a9 commit c3e5bba

47 files changed

Lines changed: 3256 additions & 728 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,46 @@ describe('buildChatRequestBody', () => {
2525
type: 'browseros',
2626
providerId: 'provider-1',
2727
})
28-
expect(body.providerId).toBe('provider-1')
28+
})
29+
30+
// The provider is named and nothing more. Its model, endpoint and
31+
// credentials are resolved from the id server side, so an api key and an aws
32+
// secret no longer cross the wire on every message.
33+
it('sends no provider configuration or credentials', () => {
34+
const body = buildChatRequestBody({
35+
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
36+
provider: {
37+
...provider,
38+
id: 'bedrock-1',
39+
type: 'bedrock',
40+
apiKey: 'sk-secret',
41+
accessKeyId: 'AKIA',
42+
secretAccessKey: 'aws-secret',
43+
sessionToken: 'token',
44+
baseUrl: 'https://example.com',
45+
},
46+
})
47+
48+
for (const field of [
49+
'apiKey',
50+
'accessKeyId',
51+
'secretAccessKey',
52+
'sessionToken',
53+
'baseUrl',
54+
'model',
55+
'provider',
56+
'providerId',
57+
'providerType',
58+
'providerName',
59+
'temperature',
60+
'contextWindowSize',
61+
'region',
62+
'resourceName',
63+
]) {
64+
expect(field in body).toBe(false)
65+
}
66+
expect(JSON.stringify(body)).not.toContain('aws-secret')
67+
expect(JSON.stringify(body)).not.toContain('sk-secret')
2968
})
3069

3170
it('preserves browser context and chat metadata', () => {

packages/browseros-agent/apps/app/lib/messaging/server/buildChatRequestBody.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ export interface ChatRequestBrowserContext {
4040

4141
export interface ChatRequestBodyParams {
4242
conversationId: string
43-
provider: LlmProviderConfig
43+
/**
44+
* The provider config, when the caller already holds it. Only the id is sent;
45+
* the rest is used to describe what the chosen model can do, which comes from
46+
* a catalogue the extension bundles.
47+
*
48+
* Callers that hold nothing but an id, such as the scheduled runner, pass
49+
* `providerId` instead and let the server resolve the rest.
50+
*/
51+
provider?: LlmProviderConfig
52+
providerId?: string
4453
message?: string
4554
mode?: ChatMode
4655
browserContext?: ChatRequestBrowserContext
@@ -61,6 +70,7 @@ export interface ChatRequestBodyParams {
6170
export const buildChatRequestBody = ({
6271
conversationId,
6372
provider,
73+
providerId,
6474
message = '',
6575
mode,
6676
browserContext,
@@ -74,31 +84,26 @@ export const buildChatRequestBody = ({
7484
selectedTextSource,
7585
isScheduledTask,
7686
}: ChatRequestBodyParams) => ({
77-
target: { type: 'browseros' as const, providerId: provider.id },
87+
// The provider is named, not described. The server holds the list and which
88+
// one is selected, so it resolves the model, endpoint and credentials from
89+
// the id. Those used to travel on every message, which meant the api key and
90+
// the aws secret crossed the wire each time the user pressed send.
91+
target: {
92+
type: 'browseros' as const,
93+
// Absent when the caller has neither, which tells the server to use the
94+
// selected provider.
95+
providerId: provider?.id ?? providerId,
96+
},
7897
message,
79-
provider: provider.type,
80-
providerId: provider.id,
81-
providerType: provider.type,
82-
providerName: provider.name,
83-
apiKey: provider.apiKey,
84-
baseUrl: provider.baseUrl,
8598
conversationId,
86-
model: provider.modelId ?? 'default',
8799
mode,
88-
contextWindowSize: provider.contextWindow,
89-
temperature: provider.temperature,
90-
resourceName: provider.resourceName,
91-
accessKeyId: provider.accessKeyId,
92-
secretAccessKey: provider.secretAccessKey,
93-
region: provider.region,
94-
sessionToken: provider.sessionToken,
95-
reasoningEffort: provider.reasoningEffort,
96-
reasoningSummary: provider.reasoningSummary,
97100
browserContext,
98101
userSystemPrompt,
99102
userWorkingDir,
100-
supportsImages: supportsImages ?? provider.supportsImages,
101-
supportsReasoning: resolvesSupportsReasoning(provider),
103+
// Sent because the caller can override what the provider says, and because
104+
// the reasoning answer comes from a model catalogue the extension bundles.
105+
supportsImages: supportsImages ?? provider?.supportsImages,
106+
supportsReasoning: provider ? resolvesSupportsReasoning(provider) : undefined,
102107
previousConversation,
103108
historyMode,
104109
declinedApps: declinedApps?.length ? declinedApps : undefined,

packages/browseros-agent/apps/app/lib/schedules/getChatServerResponse.ts

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
11
import { chatErrorMessage } from '@browseros/shared/schemas/chat-error'
22
import { createParser, type EventSourceMessage } from 'eventsource-parser'
33
import { getAgentServerUrl } from '@/lib/browseros/helpers'
4-
import {
5-
createDefaultBrowserOSProvider,
6-
defaultProviderIdStorage,
7-
} from '@/lib/llm-providers/storage'
8-
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
94
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
105
import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody'
116
import type { ChatMode } from '@/modules/chat/chat-types'
12-
import { listProvidersOrNull } from '@/modules/llm-providers/llm-providers.api'
13-
import {
14-
findChatProviderById,
15-
resolveChatProvider,
16-
} from '../llm-providers/provider-runtime'
177
import { personalizationStorage } from '../personalization/personalizationStorage'
188
import { scheduleSystemPrompt } from './scheduleSystemPrompt'
199
import type { ToolCallExecution } from './scheduleTypes'
@@ -73,49 +63,15 @@ interface StreamParseState {
7363
receivedFinish: boolean
7464
}
7565

76-
const resolveProvider = async (
77-
providerId?: string,
78-
): Promise<LlmProviderConfig> => {
79-
// One read for both branches: the list is now a request rather than a local
80-
// storage lookup, and the explicit-provider path used to fetch it twice.
81-
const loaded = await listProvidersOrNull()
82-
83-
// Never resolve a provider from a list that failed to load. A job that named
84-
// one must not quietly run on a different one, and a job that named none
85-
// still has a choice behind it: the configured default, whose id lives in
86-
// extension storage but whose credentials and model live in that list. Either
87-
// way, substituting the built-in would spend the wrong credentials on the
88-
// wrong model and still record the run as completed.
89-
//
90-
// An empty list is a different answer and keeps the fallback: the server
91-
// answered, and it really has no providers.
92-
if (loaded === null) {
93-
throw new Error(
94-
'Cannot reach the BrowserOS server to load the selected provider',
95-
)
96-
}
97-
98-
const providers = loaded
99-
100-
if (providerId) {
101-
const match = findChatProviderById(providers, providerId)
102-
if (match) return match
103-
}
104-
105-
if (providers.length > 0) {
106-
const defaultProviderId = await defaultProviderIdStorage.getValue()
107-
const provider = resolveChatProvider(providers, defaultProviderId)
108-
if (provider) return provider
109-
}
110-
111-
return createDefaultBrowserOSProvider()
112-
}
113-
11466
export async function getChatServerResponse(
11567
request: ChatServerRequest,
11668
): Promise<ChatServerResponse> {
11769
const agentServerUrl = await getAgentServerUrl()
118-
const provider = await resolveProvider(request.providerId)
70+
// No provider lookup here any more. The server holds the list and the
71+
// selection, so a job names an id or names nothing and the server resolves
72+
// it. That also removes the guard this path needed when it did the lookup
73+
// itself: an unreachable list could not be told apart from an empty one, so
74+
// a job risked running on the built-in provider with the wrong credentials.
11975
const conversationId = request.conversationId ?? crypto.randomUUID()
12076
const personalization = await personalizationStorage.getValue()
12177

@@ -138,9 +94,9 @@ export async function getChatServerResponse(
13894
body: JSON.stringify({
13995
messages: [{ role: 'user', content: request.message }],
14096
...buildChatRequestBody({
97+
providerId: request.providerId,
14198
message: request.message,
14299
conversationId,
143-
provider,
144100
mode: request.mode ?? 'agent',
145101
browserContext:
146102
request.activeTab ||
@@ -157,7 +113,6 @@ export async function getChatServerResponse(
157113
}
158114
: undefined,
159115
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
160-
supportsImages: provider.supportsImages,
161116
isScheduledTask: true,
162117
}),
163118
}),

packages/browseros-agent/apps/app/lib/schedules/provider-resolution.test.ts

Lines changed: 39 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ mock.module('../personalization/personalizationStorage', () => ({
7878
},
7979
}))
8080

81+
// Only the refine path still resolves a provider on this side; the scheduled
82+
// path names an id and lets the server do it.
83+
const providers: LlmProviderConfig[] = [
84+
{
85+
id: 'anthropic-sonnet',
86+
type: 'anthropic',
87+
name: 'Anthropic Sonnet',
88+
modelId: 'claude-sonnet-4-6',
89+
supportsImages: true,
90+
contextWindow: 200000,
91+
temperature: 0.2,
92+
createdAt: 0,
93+
updatedAt: 0,
94+
},
95+
]
96+
8197
beforeEach(() => {
8298
storageValues.clear()
8399
fetchBodies.length = 0
@@ -106,7 +122,12 @@ afterEach(() => {
106122
})
107123

108124
describe('scheduled provider resolution', () => {
109-
it('uses an explicit scheduled provider', async () => {
125+
// The runner names the provider and stops there. Its model, endpoint and
126+
// credentials are resolved from the id on the server, which is what let the
127+
// client-side lookup and its unreachable-versus-empty guard go: the guard
128+
// existed because a failed lookup and an empty list looked the same, and a
129+
// job risked running on the built-in provider with the wrong credentials.
130+
it('names the provider and sends no configuration', async () => {
110131
const { getChatServerResponse } = await import('./getChatServerResponse')
111132

112133
await getChatServerResponse({
@@ -115,10 +136,24 @@ describe('scheduled provider resolution', () => {
115136
})
116137

117138
expect(fetchBodies[0]).toMatchObject({
118-
provider: 'anthropic',
119-
providerName: 'Anthropic Sonnet',
120-
model: 'claude-sonnet-4-6',
139+
target: { type: 'browseros', providerId: 'anthropic-sonnet' },
140+
isScheduledTask: true,
121141
})
142+
for (const field of ['apiKey', 'model', 'provider', 'baseUrl']) {
143+
expect(field in fetchBodies[0]).toBe(false)
144+
}
145+
})
146+
147+
// A job created without picking a provider names none, and the server uses
148+
// whichever is selected.
149+
it('leaves the provider unnamed when the job has none', async () => {
150+
const { getChatServerResponse } = await import('./getChatServerResponse')
151+
152+
await getChatServerResponse({ message: 'Run my schedule' })
153+
154+
expect(
155+
(fetchBodies[0] as { target: { providerId?: string } }).target.providerId,
156+
).toBeUndefined()
122157
})
123158

124159
it('uses an explicit refine provider', async () => {
@@ -141,101 +176,3 @@ describe('scheduled provider resolution', () => {
141176
})
142177
})
143178
})
144-
145-
const timestamp = 1000
146-
147-
const providers: LlmProviderConfig[] = [
148-
{
149-
id: 'browseros',
150-
type: 'browseros',
151-
name: 'BrowserOS',
152-
modelId: 'browseros-auto',
153-
supportsImages: true,
154-
contextWindow: 200000,
155-
temperature: 0.2,
156-
createdAt: timestamp,
157-
updatedAt: timestamp,
158-
},
159-
{
160-
id: 'anthropic-sonnet',
161-
type: 'anthropic',
162-
name: 'Anthropic Sonnet',
163-
modelId: 'claude-sonnet-4-6',
164-
apiKey: 'sk-ant',
165-
supportsImages: true,
166-
contextWindow: 200000,
167-
temperature: 0.2,
168-
createdAt: timestamp,
169-
updatedAt: timestamp,
170-
},
171-
]
172-
173-
describe('provider resolution when the server is unreachable', () => {
174-
// The list being unreachable says nothing about whether the chosen provider
175-
// exists, so running anyway would spend the wrong credentials on the wrong
176-
// model. The job runner turns this into a failed run the user can see.
177-
it('fails a scheduled job that named a provider', async () => {
178-
storageValues.set('unreachable', true)
179-
const { getChatServerResponse } = await import('./getChatServerResponse')
180-
181-
await expect(
182-
getChatServerResponse({
183-
message: 'Run my schedule',
184-
providerId: 'anthropic-sonnet',
185-
}),
186-
).rejects.toThrow('Cannot reach the BrowserOS server')
187-
188-
expect(fetchBodies).toHaveLength(0)
189-
})
190-
191-
it('fails a refine that named a provider', async () => {
192-
storageValues.set('unreachable', true)
193-
const { refinePrompt } = await import('./refine-prompt')
194-
195-
await expect(
196-
refinePrompt({
197-
prompt: 'Check mail',
198-
name: 'Morning brief',
199-
providerId: 'anthropic-sonnet',
200-
}),
201-
).rejects.toThrow('Cannot reach the BrowserOS server')
202-
})
203-
204-
// A job that named nothing still has a choice behind it: the configured
205-
// default. Its id is in extension storage but its model and credentials are
206-
// in the list that failed to load, so the built-in is not a safe stand-in.
207-
it('fails a scheduled job that relies on the configured default', async () => {
208-
storageValues.set('unreachable', true)
209-
const { getChatServerResponse } = await import('./getChatServerResponse')
210-
211-
await expect(
212-
getChatServerResponse({ message: 'Run my schedule' }),
213-
).rejects.toThrow('Cannot reach the BrowserOS server')
214-
215-
expect(fetchBodies).toHaveLength(0)
216-
})
217-
218-
// An empty list is a different answer from an unreachable one: the server
219-
// replied and really has no providers, so the built-in is correct.
220-
it('still falls back to the built-in provider when the server has none', async () => {
221-
storageValues.set('providers', [])
222-
const { getChatServerResponse } = await import('./getChatServerResponse')
223-
224-
await getChatServerResponse({ message: 'Run my schedule' })
225-
226-
expect(fetchBodies[0]).toMatchObject({ provider: 'browseros' })
227-
})
228-
229-
// A provider that was genuinely deleted still falls back, as before. Only
230-
// the unreachable case is treated as unsafe.
231-
it('falls back when the named provider no longer exists', async () => {
232-
const { getChatServerResponse } = await import('./getChatServerResponse')
233-
234-
await getChatServerResponse({
235-
message: 'Run my schedule',
236-
providerId: 'deleted-provider',
237-
})
238-
239-
expect(fetchBodies[0]).toMatchObject({ provider: 'anthropic' })
240-
})
241-
})

packages/browseros-agent/apps/app/modules/chat/chat-session-request.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ describe('chat request preparation', () => {
2121
expect(request.api).toBe('http://127.0.0.1:5151/chat')
2222
expect(request.body).toMatchObject({
2323
target: { type: 'browseros', providerId: 'browseros' },
24-
provider: 'browseros',
2524
message: 'Summarize this page',
2625
})
26+
// The provider is named, not described: its configuration is resolved
27+
// from the id on the server.
28+
expect('provider' in request.body).toBe(false)
2729
})
2830

2931
it('sends ACP agents to the same endpoint without provider fields', () => {

0 commit comments

Comments
 (0)