Skip to content

Commit f2aa251

Browse files
committed
fix(pi): drop the workspace BYOK fallback for the search key
A fallback exists so a key has somewhere to go when the field is unavailable. The Search API Key field is unconditionally available: unlike the model key, whose visibility runs through `shouldRequireApiKeyForModel` and its `isHosted` branch, `getSearchApiKeyCondition` gates only on whether a provider is selected. So the fallback never had a configuration to cover. Removing it also closes an escalation. Workspace BYOK keys are admin-managed and the API only ever returns them masked, yet `resolvePiSearchKey` would resolve one for any member who could run the block — and in Create PR that key is handed to the sandbox as an environment variable, which Pi copies into every bash child. A member could read a credential the product deliberately never shows them. Requiring the key on the block keeps the sandbox exposure to a key its author already holds. Nothing depends on the fallback: it has never shipped. - `resolvePiSearchKey` is now synchronous and returns the key, since there is no lookup left to await. `byokProviderId` leaves the search registry and `PiSearchKeySource` / `PiSearchKeyResolution` are gone — with one source, `keySource` carried no information, and the logging rationale for it (a block field silently shadowing a stored key) no longer exists. - The field is now `required`. Safe alongside its condition: the serializer's required check returns early for fields that are not visible, so a Pi block with search off still validates. Pinned by a test. Docs and the block's tooltip, placeholder, and best practices updated. The Create PR key-exposure callout now explains the missing fallback rather than recommending the block field as a way around it.
1 parent 9cc17ee commit f2aa251

12 files changed

Lines changed: 77 additions & 100 deletions

File tree

apps/docs/content/docs/en/workflows/blocks/pi.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ Your key for the chosen provider. On hosted Sim it is optional for Local Dev and
6767

6868
Off by default. Pick a provider — **Exa**, **Serper**, **Parallel AI**, or **Firecrawl** — and the agent gains a single `web_search` tool that returns a handful of results, each with a title, URL, snippet, and (where the provider reports one) a publication date. It works the same way in all three modes, and it is the agent's only network access in Review Code. The tool accepts at most 20 calls **per block execution**, which bounds accidental tool loops. A Pi block inside a Loop or Parallel gets that allowance again on every iteration, so bound the iteration count too if you care about what a single workflow run can spend.
6969

70-
Search always uses **your own key** for the selected provider, never a Sim-hosted one, because Create PR places the key inside the coding sandbox. Enter it in **Search API Key** or store it in **Settings → BYOK**; the run fails with a setup error before any sandbox is created when neither is present. Changing the provider in the editor clears the key field, so re-enter the key that belongs to the provider you picked — a workflow you import, fork, or update through the API keeps whatever key was saved, so check it there.
70+
Search always uses **your own key** for the selected provider, entered in the block's **Search API Key** field. That field is the only source: there is no workspace BYOK fallback and Sim never supplies a hosted search key, so unlike the model key this field appears on every deployment. Leave it empty and the run fails with a setup error before any sandbox is created. Changing the provider in the editor clears the field, so re-enter the key that belongs to the provider you picked — a workflow you import, fork, or update through the API keeps whatever key was saved, so check it there.
7171

7272
<Callout type="warn">
7373
**Create PR exposes both keys to the agent.** Create PR runs the model client and the search client *inside* the sandbox, so the model key and the search key reach it as environment variables — and Pi copies its own environment into every shell command it runs. Your prompt, or instructions injected through the contents of the cloned repository, can therefore read either key and write it anywhere the agent can reach, including into the pull request itself. Sim strips verbatim key text out of run output, but that does not stop an agent that encodes the value first.
7474

75-
This matters most for keys stored in **Settings → BYOK**, which belong to the workspace rather than to you. Sim otherwise only ever displays those masked, and only workspace admins can add or remove them — but anyone who can run a Pi block in Create PR mode can read the raw value. When that is not acceptable for a given key, put a separate key on the block's own **Search API Key** field instead of relying on the BYOK fallback, and scope it to something you are willing to rotate.
75+
This is why the search key has no **Settings → BYOK** fallback. Workspace BYOK keys belong to the workspace rather than to youSim only ever displays them masked, and only workspace admins can add or remove them — so resolving one here would let anyone who can run a Pi block read a credential they cannot otherwise see. Requiring the key on the block keeps the exposure to a key its author already holds. Scope it to something you are willing to rotate.
7676
</Callout>
7777

7878
Results are third-party data. The agent is instructed to treat them as quoted evidence and never to follow instructions found inside them — the same posture Pi takes toward repository contents.
@@ -192,7 +192,7 @@ Enable sandbox execution as for Create PR. BYOK is optional because the model cr
192192
{ question: "Why does Local Dev need a public hostname?", answer: "Sim connects over raw SSH and blocks localhost, LAN, and private/reserved addresses for safety. Expose the machine with a TCP tunnel such as `ngrok tcp 22` and use the tunnel's host and port. Tailscale's private 100.x addresses won't work for the same reason." },
193193
{ question: "What GitHub permissions does Create PR need?", answer: "A token that can clone, push, and open a PR. With a fine-grained token: select the repo and grant Contents: Read and write plus Pull requests: Read and write. With a classic token: the repo scope. For organization repos, the token must be SSO-authorized." },
194194
{ question: "What GitHub permissions does Review Code need?", answer: "A token that can clone the repo and submit a review. With a fine-grained token: Contents: Read plus Pull requests: Read and write. Push permission is not required. With a classic token: the repo scope. For organization repos, the token must be SSO-authorized." },
195-
{ question: "Can the agent search the web?", answer: "Only if you pick a provider under Internet Search — Exa, Serper, Parallel AI, or Firecrawl. That adds one web_search tool in every mode, backed by your own key for that provider (on the block or in Settings → BYOK); Sim never supplies a search key. Leave it on None and the agent has no search tool at all." },
195+
{ question: "Can the agent search the web?", answer: "Only if you pick a provider under Internet Search — Exa, Serper, Parallel AI, or Firecrawl. That adds one web_search tool in every mode, backed by your own key for that provider, entered on the block; there is no BYOK fallback and Sim never supplies a search key. Leave it on None and the agent has no search tool at all." },
196196
{ question: "Can I give it Gmail, Slack, or other integrations?", answer: "Yes, in Local Dev via the Tools field. Selected Sim tools run through Sim with your connected credentials, the same as the Agent block, so the agent can act beyond the repo while it codes. MCP and custom tools aren't supported yet." },
197197
{ question: "Where do the changes or feedback go?", answer: "In Create PR, to a new branch and a pull request (read prUrl and branch). In Review Code, to a submitted GitHub review on the existing PR (read reviewUrl and commentsPosted). In Local Dev, the files are edited in place on the target machine — review them with git there. Create PR and Local Dev also return changedFiles and a diff." },
198198
{ question: "What happens when memory or context gets large?", answer: "For Create PR and Local Dev, Sim trims memory before the run based on the memory type, and Pi compacts older turns as needed. Review Code does not load or save memory because a malicious PR could otherwise expose or poison prior context." },

apps/sim/blocks/blocks/pi.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ describe('Pi block search fields', () => {
5858
expect(searchKeyVisible({ searchProvider: undefined })).toBe(false)
5959
})
6060

61+
// The field is the only source for the search key — no workspace BYOK fallback, no hosted key —
62+
// so it has to be required. Safe despite the condition: the serializer's required check returns
63+
// early for fields that are not visible, which is what keeps a Pi block with search off from
64+
// failing validation over a key it does not need.
65+
it('requires the key, which the hidden case must not enforce', () => {
66+
expect(searchApiKeyField?.required).toBe(true)
67+
expect(searchKeyVisible({ searchProvider: 'none' })).toBe(false)
68+
expect(searchKeyVisible({})).toBe(false)
69+
})
70+
6171
// `inputs` is the block's type map, not the delivery mechanism — the handler reads resolved
6272
// params — but an undeclared input is a convention break the next block author would copy.
6373
it('declares both fields in the block input map', () => {

apps/sim/blocks/blocks/pi.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
9191
- Use Review Code to analyze an existing PR and leave summary + inline review comments.
9292
- Use Local Dev to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH.
9393
- Create PR requires your own provider API key because the model runs in the sandbox. Review Code keeps the model key in Sim and can use either BYOK or a hosted key.
94-
- Internet Search is off by default and always needs your own key for the selected provider, from the block field or Settings > BYOK. Leave it on None unless the task genuinely needs external information.
94+
- Internet Search is off by default and always needs your own key for the selected provider, entered on the block. There is no workspace BYOK fallback and no hosted key. Leave it on None unless the task genuinely needs external information.
9595
`,
9696
category: 'blocks',
9797
integrationType: IntegrationType.AI,
@@ -165,7 +165,11 @@ export const PiBlock: BlockConfig<PiResponse> = {
165165
password: true,
166166
paramVisibility: 'user-only',
167167
connectionDroppable: false,
168-
placeholder: 'Falls back to the key stored in Settings > BYOK',
168+
placeholder: 'Your key for the selected provider',
169+
// The only source: search has no workspace BYOK fallback and never uses a Sim-hosted key, and
170+
// unlike the model key this field is shown on every deployment. Marking it required is what
171+
// surfaces that in the editor rather than at the start of a run.
172+
required: true,
169173
// Scoped to the editor on purpose: the clear-on-switch is driven by `dependsOn` through the
170174
// collaborative setter, so a workflow imported, forked, or updated through the API keeps
171175
// whatever key was stored. Promising an unconditional clear would be wrong in exactly the

apps/sim/executor/handlers/pi/backend.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { TSchema } from 'typebox'
1111
import type { SSHConnectionConfig } from '@/app/api/tools/ssh/utils'
1212
import type { Message } from '@/executor/handlers/agent/types'
1313
import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/events'
14-
import type { PiSearchKeySource, PiSearchProvider } from '@/executor/handlers/pi/keys'
14+
import type { PiSearchProvider } from '@/executor/handlers/pi/keys'
1515
import type { PiSupportedProvider } from '@/providers/pi-provider-configs'
1616

1717
/** A conversation message seeded into the Pi run (subset of the Agent block's message). */
@@ -59,7 +59,6 @@ export interface PiToolSpec {
5959
export interface PiSearchConfig {
6060
provider: PiSearchProvider
6161
apiKey: string
62-
keySource: PiSearchKeySource
6362
/**
6463
* Host-side tool for the two SDK modes. Absent for `cloud`, which has no host in the loop and
6564
* registers a sandbox extension instead, so a spec built there could never execute.

apps/sim/executor/handlers/pi/cloud-backend.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe('runCloudPi', () => {
252252
})
253253

254254
describe('optional web search', () => {
255-
const search = { provider: 'exa' as const, apiKey: 'sk-search', keySource: 'byok' as const }
255+
const search = { provider: 'exa' as const, apiKey: 'sk-search' }
256256

257257
it('runs the stock Pi command with no extension when search is off', async () => {
258258
await runCloudPi(baseParams(), { onEvent: vi.fn() })

apps/sim/executor/handlers/pi/keys.test.ts

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -245,53 +245,37 @@ describe('resolvePiSearchKey', () => {
245245
vi.clearAllMocks()
246246
})
247247

248-
it('prefers the block field and reports its source', async () => {
249-
await expect(
250-
resolvePiSearchKey({ provider: 'exa', workspaceId: 'ws-1', apiKey: 'exa-field' })
251-
).resolves.toEqual({ apiKey: 'exa-field', source: 'block' })
252-
expect(mockGetBYOKKey).not.toHaveBeenCalled()
248+
it('returns the trimmed block field, the only source', () => {
249+
expect(resolvePiSearchKey({ provider: 'exa', apiKey: ' exa-field ' })).toBe('exa-field')
253250
})
254251

255-
it('falls back to the stored workspace key for the selected provider', async () => {
256-
mockGetBYOKKey.mockResolvedValue({ apiKey: 'serper-stored', isBYOK: true })
257-
258-
await expect(resolvePiSearchKey({ provider: 'serper', workspaceId: 'ws-1' })).resolves.toEqual({
259-
apiKey: 'serper-stored',
260-
source: 'byok',
261-
})
262-
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'serper')
263-
})
264-
265-
it('maps Parallel to its BYOK provider id', async () => {
266-
mockGetBYOKKey.mockResolvedValue({ apiKey: 'parallel-stored', isBYOK: true })
267-
268-
await resolvePiSearchKey({ provider: 'parallel', workspaceId: 'ws-1' })
269-
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'parallel_ai')
252+
// The field is shown on every deployment, so there is no configuration where a fallback would be
253+
// needed — and reading one would pull a workspace credential the runner cannot otherwise see into
254+
// the Create PR sandbox.
255+
it('never reads a stored workspace BYOK key', () => {
256+
expect(() => resolvePiSearchKey({ provider: 'serper' })).toThrow(
257+
/Serper search requires your own Serper API key/
258+
)
259+
expect(mockGetBYOKKey).not.toHaveBeenCalled()
270260
})
271261

272-
it('treats a whitespace-only key as absent, so no hosted key can be injected later', async () => {
273-
mockGetBYOKKey.mockResolvedValue({ apiKey: ' firecrawl-stored ', isBYOK: true })
274-
275-
await expect(
276-
resolvePiSearchKey({ provider: 'firecrawl', workspaceId: 'ws-1', apiKey: ' ' })
277-
).resolves.toEqual({ apiKey: 'firecrawl-stored', source: 'byok' })
278-
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'firecrawl')
262+
it('treats a whitespace-only key as absent, so no hosted key can be injected later', () => {
263+
expect(() => resolvePiSearchKey({ provider: 'firecrawl', apiKey: ' ' })).toThrow(
264+
/Firecrawl search requires your own Firecrawl API key/
265+
)
266+
expect(mockGetBYOKKey).not.toHaveBeenCalled()
279267
})
280268

281-
it('never falls back to a Sim-hosted key', async () => {
282-
mockGetBYOKKey.mockResolvedValue(null)
283-
284-
await expect(resolvePiSearchKey({ provider: 'exa', workspaceId: 'ws-1' })).rejects.toThrow(
269+
it('never falls back to a Sim-hosted key', () => {
270+
expect(() => resolvePiSearchKey({ provider: 'exa' })).toThrow(
285271
/Exa search requires your own Exa API key/
286272
)
287273
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
288274
})
289275

290-
it('reports a blank stored key as missing rather than passing it on', async () => {
291-
mockGetBYOKKey.mockResolvedValue({ apiKey: ' ', isBYOK: true })
292-
293-
await expect(resolvePiSearchKey({ provider: 'serper', workspaceId: 'ws-1' })).rejects.toThrow(
294-
/Serper search requires your own Serper API key/
276+
it('names the selected provider in the setup error, matching the dropdown label', () => {
277+
expect(() => resolvePiSearchKey({ provider: 'parallel' })).toThrow(
278+
/Parallel AI search requires your own Parallel AI API key/
295279
)
296280
})
297281
})

apps/sim/executor/handlers/pi/keys.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
getPiWorkspaceBYOKProviderId,
2121
isPiSupportedProvider,
2222
} from '@/providers/pi-providers'
23-
import type { BYOKProviderId } from '@/tools/types'
2423

2524
/** Resolved provider key and BYOK flag for a Pi run. */
2625
interface PiKeyResolution {
@@ -73,29 +72,20 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis
7372
interface PiSearchProviderConfig {
7473
/** User-facing name, used in setup errors and the review prompt. */
7574
label: string
76-
byokProviderId: BYOKProviderId
7775
/** Sim tool the host-side adapter executes; also the id checked against workspace tool denylists. */
7876
toolId: string
7977
}
8078

8179
/** The search providers the Pi block offers, keyed by the `searchProvider` field value. */
8280
export const PI_SEARCH_PROVIDERS = {
83-
exa: { label: 'Exa', byokProviderId: 'exa', toolId: 'exa_search' },
84-
serper: { label: 'Serper', byokProviderId: 'serper', toolId: 'serper_search' },
85-
parallel: { label: 'Parallel AI', byokProviderId: 'parallel_ai', toolId: 'parallel_search' },
86-
firecrawl: { label: 'Firecrawl', byokProviderId: 'firecrawl', toolId: 'firecrawl_search' },
81+
exa: { label: 'Exa', toolId: 'exa_search' },
82+
serper: { label: 'Serper', toolId: 'serper_search' },
83+
parallel: { label: 'Parallel AI', toolId: 'parallel_search' },
84+
firecrawl: { label: 'Firecrawl', toolId: 'firecrawl_search' },
8785
} as const satisfies Record<string, PiSearchProviderConfig>
8886

8987
export type PiSearchProvider = keyof typeof PI_SEARCH_PROVIDERS
9088

91-
/** Where a resolved search key came from, carried into logs to diagnose a stale block field. */
92-
export type PiSearchKeySource = 'block' | 'byok'
93-
94-
export interface PiSearchKeyResolution {
95-
apiKey: string
96-
source: PiSearchKeySource
97-
}
98-
9989
/**
10090
* Resolves the `searchProvider` field, distinguishing absent from invalid.
10191
*
@@ -115,29 +105,32 @@ export function parsePiSearchProvider(value: unknown): PiSearchProvider | 'none'
115105
}
116106

117107
/**
118-
* Resolves the search key: the block's Search API Key field, else a stored workspace BYOK key,
119-
* else an error. Never a Sim-hosted key in any mode, because Create PR places this key inside the
120-
* coding sandbox and one uniform rule beats a mode-dependent one.
108+
* Resolves the search key from the block's Search API Key field, which is the only source.
121109
*
122-
* Both sources are trimmed and a blank treated as absent. `executeTool` only skips hosted-key
123-
* injection for a key with `trim().length > 0`, so a whitespace-only value would otherwise fall
124-
* through to a rotating Sim-owned key on hosted deployments.
110+
* Deliberately no workspace BYOK fallback, and never a Sim-hosted key. Unlike the model key, the
111+
* Search API Key field is shown on every deployment — its visibility depends only on whether a
112+
* provider is selected — so there is no configuration where the field is unavailable and a fallback
113+
* would be needed. Reading a stored workspace key here would instead mean a member who cannot
114+
* otherwise see that credential (the BYOK API only ever returns it masked, and only admins may
115+
* manage it) could route it into the Create PR sandbox, where the agent holds bash and can read the
116+
* environment. Requiring the key on the block keeps that exposure something the block's author
117+
* opted into with a key they already hold.
118+
*
119+
* Trimmed, with a blank treated as absent: `executeTool` only skips hosted-key injection for a key
120+
* with `trim().length > 0`, so a whitespace-only value would otherwise fall through to a rotating
121+
* Sim-owned key on hosted deployments.
125122
*/
126-
export async function resolvePiSearchKey(params: {
123+
export function resolvePiSearchKey(params: {
127124
provider: PiSearchProvider
128-
workspaceId?: string
129125
apiKey?: string
130-
}): Promise<PiSearchKeyResolution> {
131-
const { label, byokProviderId } = PI_SEARCH_PROVIDERS[params.provider]
126+
}): string {
127+
const { label } = PI_SEARCH_PROVIDERS[params.provider]
132128

133129
const fieldKey = params.apiKey?.trim()
134-
if (fieldKey) return { apiKey: fieldKey, source: 'block' }
135-
136-
const stored = (await getBYOKKey(params.workspaceId, byokProviderId))?.apiKey.trim()
137-
if (stored) return { apiKey: stored, source: 'byok' }
130+
if (fieldKey) return fieldKey
138131

139132
throw new Error(
140-
`${label} search requires your own ${label} API key. Enter it in the block's Search API Key field, or store one in Settings > BYOK.`
133+
`${label} search requires your own ${label} API key. Enter it in the block's Search API Key field.`
141134
)
142135
}
143136

apps/sim/executor/handlers/pi/local-backend.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ describe('runLocalPi secret boundaries', () => {
187187
params.search = {
188188
provider: 'exa',
189189
apiKey: 'sk-search-key',
190-
keySource: 'byok',
191190
tool: {
192191
name: 'web_search',
193192
description: 'Search the web',

0 commit comments

Comments
 (0)