Skip to content

Commit 37ac4c8

Browse files
icecrasher321claude
andcommitted
Merge origin/staging into staging-v4
Conflicts resolved: - workspace-file-folder-manager.ts: staging kept the workflow-alias import while ac55b1f deleted that module here, so only staging's collectDescendantFolderIds import survives — the alias symbol has no remaining callers. - check-api-validation-contracts.ts: route baseline is additive on both sides (979 base + 6 ours + 2 staging); audit reports exactly 987. - bun.lock: regenerated from the merged package.json files rather than hand-merged. Our 0272 was dropped in the preceding commit and regenerated as 0273 on top of staging's 0272; the emitted SQL is byte-identical to what was dropped. Co-Authored-By: Claude <noreply@anthropic.com>
2 parents d640a94 + 3d72ab3 commit 37ac4c8

82 files changed

Lines changed: 43333 additions & 419 deletions

File tree

Some content is hidden

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

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ description: The Pi Coding Agent block runs an autonomous coding agent on a real
44
pageType: reference
55
---
66

7+
import { Callout } from 'fumadocs-ui/components/callout'
78
import { BlockPreview } from '@/components/workflow-preview'
89
import { FAQ } from '@/components/ui/faq'
910

@@ -37,7 +38,7 @@ Review Code uses a disposable sandbox for the repository, but the Pi harness and
3738
- Requires sandbox execution. The provider key stays in Sim, so hosted keys and BYOK are both supported.
3839
- Needs a **GitHub token** that can clone the repo and submit reviews (see [Setup](#setup-cloud-code-review)).
3940
- Needs the **Pull Request Number** to review.
40-
- Does not load skills or memory, and never exposes shell, write, edit, or arbitrary network tools to the reviewer.
41+
- Does not load skills or memory, and never exposes shell, write, or edit tools to the reviewer. Its only network access is [Internet Search](#internet-search), and only when you select a provider.
4142
- Rechecks the PR immediately before submission and pins the review to the exact checked-out head commit.
4243
- The deliverable is a **submitted review** — read `reviewUrl` and `commentsPosted`.
4344

@@ -63,6 +64,22 @@ The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown c
6364

6465
Your key for the chosen provider. On hosted Sim it is optional for Local Dev and Review Code runs (a hosted key is used and metered to your workspace). **Create PR requires your own key** because its model client runs in the sandbox. When the provider supports workspace BYOK, you can store the key in **Settings → BYOK** instead of entering it on the block.
6566

67+
### Internet Search
68+
69+
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.
70+
71+
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.
72+
73+
<Callout type="warn">
74+
**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.
75+
76+
This is why the search key has no **Settings → BYOK** fallback. Workspace BYOK keys belong to the workspace rather than to you — Sim 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.
77+
</Callout>
78+
79+
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.
80+
81+
Traffic goes both ways: the agent writes its own queries after reading the repository, so leave search on **None** in Review Code when the pull request comes from an untrusted fork of a private repo. Injected instructions in a diff could otherwise put repository text into a query sent to the provider.
82+
6683
### Repository (Create PR / Review Code)
6784

6885
- **Repository Owner / Repository Name** — the GitHub repo (for example `your-org` / `your-repo`).
@@ -176,6 +193,7 @@ Enable sandbox execution as for Create PR. BYOK is optional because the model cr
176193
{ 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." },
177194
{ 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." },
178195
{ 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." },
196+
{ 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." },
179197
{ 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." },
180198
{ 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." },
181199
{ 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/app/api/folders/[id]/duplicate/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import type { DbOrTx } from '@/lib/db/types'
15+
import { toFolderApi } from '@/lib/folders/queries'
1516
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1617
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1718

@@ -33,7 +34,7 @@ export const POST = withRouteHandler(
3334
try {
3435
const parsed = await parseRequest(duplicateFolderContract, req, context)
3536
if (!parsed.success) return parsed.response
36-
const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body
37+
const { name, workspaceId, parentId, newId: clientNewId } = parsed.data.body
3738

3839
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
3940

@@ -106,7 +107,7 @@ export const POST = withRouteHandler(
106107
userId: session.user.id,
107108
workspaceId: targetWorkspaceId,
108109
name: deduplicatedName,
109-
color: color || sourceFolder.color,
110+
color: sourceFolder.color,
110111
parentId: targetParentId,
111112
sortOrder,
112113
isExpanded: false,
@@ -172,7 +173,7 @@ export const POST = withRouteHandler(
172173
.where(eq(workflowFolder.id, newFolderId))
173174
.then((rows) => rows[0])
174175

175-
return NextResponse.json({ folder: duplicatedFolder }, { status: 201 })
176+
return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 })
176177
} catch (error) {
177178
if (error instanceof Error) {
178179
if (error instanceof FolderLockedError) {

apps/sim/app/api/folders/[id]/route.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,9 @@ describe('Individual Folder API Route', () => {
306306
})
307307

308308
describe('Input Validation', () => {
309-
it('should handle empty folder name', async () => {
309+
it('rejects an empty folder name', async () => {
310+
// The contract bounds `name` to 1-255 chars: renaming a folder to '' previously
311+
// slipped through as a no-op 200, which silently discarded the user's rename.
310312
mockAuthenticatedUser()
311313

312314
queueFolderLookup()
@@ -317,7 +319,23 @@ describe('Individual Folder API Route', () => {
317319

318320
const response = await PUT(req, { params })
319321

320-
expect(response.status).toBe(200)
322+
expect(response.status).toBe(400)
323+
})
324+
325+
it('rejects a whitespace-only folder name', async () => {
326+
// The write path trims before persisting, so validating the raw string would let
327+
// ' ' through and store an empty name — the same failure the '' case closes.
328+
mockAuthenticatedUser()
329+
330+
queueFolderLookup()
331+
const req = createMockRequest('PUT', {
332+
name: ' ',
333+
})
334+
const params = Promise.resolve({ id: 'folder-1' })
335+
336+
const response = await PUT(req, { params })
337+
338+
expect(response.status).toBe(400)
321339
})
322340

323341
it('should handle invalid JSON payload', async () => {

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { updateFolderContract } from '@/lib/api/contracts'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { toFolderApi } from '@/lib/folders/queries'
1112
import { captureServerEvent } from '@/lib/posthog/server'
1213
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
1314
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -38,7 +39,7 @@ export const PUT = withRouteHandler(
3839
if (!parsed.success) return parsed.response
3940

4041
const { id } = parsed.data.params
41-
const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body
42+
const { name, locked, parentId, sortOrder } = parsed.data.body
4243

4344
// Verify the folder exists
4445
const existingFolder = await db
@@ -85,8 +86,6 @@ export const PUT = withRouteHandler(
8586
workspaceId: existingFolder.workspaceId,
8687
userId: session.user.id,
8788
name,
88-
color,
89-
isExpanded,
9089
locked,
9190
parentId,
9291
sortOrder,
@@ -100,7 +99,7 @@ export const PUT = withRouteHandler(
10099

101100
logger.info('Updated folder:', { id, updates: parsed.data.body })
102101

103-
return NextResponse.json({ folder: result.folder })
102+
return NextResponse.json({ folder: toFolderApi(result.folder) })
104103
} catch (error) {
105104
if (error instanceof FolderLockedError) {
106105
return NextResponse.json({ error: error.message }, { status: error.status })

apps/sim/app/api/folders/route.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createFolderContract, listFoldersContract } from '@/lib/api/contracts'
55
import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { listFoldersForWorkspace } from '@/lib/folders/queries'
8+
import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries'
99
import { captureServerEvent } from '@/lib/posthog/server'
1010
import { performCreateFolder } from '@/lib/workflows/orchestration'
1111
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -29,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2929

3030
const parsed = await parseRequest(listFoldersContract, request, {})
3131
if (!parsed.success) return parsed.response
32-
const { workspaceId, scope } = parsed.data.query
32+
const { workspaceId, resourceType, scope } = parsed.data.query
3333

3434
// Check if user has workspace permissions
3535
const workspacePermission = await getUserEntityPermissions(
@@ -42,7 +42,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4242
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
4343
}
4444

45-
const folders = await listFoldersForWorkspace(workspaceId, scope)
45+
const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType)
4646

4747
return NextResponse.json({ folders })
4848
} catch (error) {
@@ -66,7 +66,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6666
name,
6767
workspaceId,
6868
parentId,
69-
color,
7069
sortOrder: providedSortOrder,
7170
} = parsed.data.body
7271

@@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9190
workspaceId,
9291
name,
9392
parentId,
94-
color,
9593
sortOrder: providedSortOrder,
9694
})
9795

@@ -113,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
113111
{ groups: { workspace: workspaceId } }
114112
)
115113

116-
return NextResponse.json({ folder: newFolder })
114+
return NextResponse.json({ folder: toFolderApi(newFolder) })
117115
} catch (error) {
118116
if (error instanceof FolderLockedError) {
119117
return NextResponse.json({ error: error.message }, { status: error.status })

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import {
4-
document,
5-
embedding,
6-
knowledgeBase,
7-
knowledgeConnector,
8-
knowledgeConnectorSyncLog,
9-
} from '@sim/db/schema'
3+
import { document, embedding, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema'
104
import { createLogger } from '@sim/logger'
115
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
126
import { type NextRequest, NextResponse } from 'next/server'
@@ -17,6 +11,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1711
import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
1812
import { generateRequestId } from '@/lib/core/utils/request'
1913
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
2015
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
2116
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
2217
import { captureServerEvent } from '@/lib/posthog/server'
@@ -157,16 +152,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
157152
)
158153
}
159154

160-
const kbRows = await db
161-
.select({ userId: knowledgeBase.userId })
162-
.from(knowledgeBase)
163-
.where(eq(knowledgeBase.id, knowledgeBaseId))
164-
.limit(1)
165-
166-
if (kbRows.length === 0) {
167-
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
168-
}
169-
170155
let accessToken: string | null = null
171156
if (connectorConfig.auth.mode === 'apiKey') {
172157
if (!existing.encryptedApiKey) {
@@ -183,9 +168,32 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
183168
{ status: 400 }
184169
)
185170
}
171+
const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId
172+
if (!connectorWorkspaceId) {
173+
return NextResponse.json(
174+
{ error: 'Knowledge base is missing workspace context' },
175+
{ status: 409 }
176+
)
177+
}
178+
/**
179+
* Resolve the credential's own account owner, not the knowledge base owner:
180+
* workspace credentials are shared, and token reads are scoped to
181+
* `account.userId`.
182+
*/
183+
const identity = await resolveCredentialTokenIdentity(
184+
existing.credentialId,
185+
connectorWorkspaceId
186+
)
187+
if (!identity) {
188+
return NextResponse.json(
189+
{ error: 'Credential is no longer usable in this workspace. Please reconnect it.' },
190+
{ status: 400 }
191+
)
192+
}
186193
accessToken = await refreshAccessTokenIfNeeded(
187194
existing.credentialId,
188-
kbRows[0].userId,
195+
// Service accounts mint their own token and ignore the acting user.
196+
identity.kind === 'oauth' ? identity.userId : auth.userId,
189197
`patch-${connectorId}`
190198
)
191199
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Tests for the unpin API route.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { authMockFns, createMockRequest, schemaMock } from '@sim/testing'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockLogger, mockDb } = vi.hoisted(() => ({
10+
mockLogger: {
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
debug: vi.fn(),
15+
trace: vi.fn(),
16+
fatal: vi.fn(),
17+
child: vi.fn(),
18+
},
19+
mockDb: { delete: vi.fn() },
20+
}))
21+
22+
vi.mock('@sim/logger', () => ({
23+
createLogger: vi.fn().mockReturnValue(mockLogger),
24+
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
25+
getRequestContext: () => undefined,
26+
}))
27+
vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock }))
28+
29+
import { DELETE } from '@/app/api/pinned-items/[resourceType]/[resourceId]/route'
30+
31+
const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test User' }
32+
33+
function routeContext(resourceType: string, resourceId: string) {
34+
return { params: Promise.resolve({ resourceType, resourceId }) }
35+
}
36+
37+
describe('Unpin API', () => {
38+
const mockWhere = vi.fn()
39+
const mockReturning = vi.fn()
40+
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
44+
mockDb.delete.mockReturnValue({ where: mockWhere })
45+
mockWhere.mockReturnValue({ returning: mockReturning })
46+
mockReturning.mockReturnValue([{ id: 'pinned-1' }])
47+
48+
authMockFns.mockGetSession.mockResolvedValue({ user: mockUser })
49+
})
50+
51+
it('unpins a resource', async () => {
52+
const response = await DELETE(
53+
createMockRequest('DELETE'),
54+
routeContext('workflow', 'workflow-1')
55+
)
56+
57+
expect(response.status).toBe(200)
58+
await expect(response.json()).resolves.toEqual({ success: true })
59+
expect(mockDb.delete).toHaveBeenCalled()
60+
})
61+
62+
it('returns 404 when no matching pin exists', async () => {
63+
mockReturning.mockReturnValue([])
64+
65+
const response = await DELETE(
66+
createMockRequest('DELETE'),
67+
routeContext('workflow', 'workflow-1')
68+
)
69+
70+
expect(response.status).toBe(404)
71+
})
72+
73+
it('rejects an unknown resourceType at the contract boundary', async () => {
74+
const response = await DELETE(createMockRequest('DELETE'), routeContext('nope', 'resource-1'))
75+
76+
expect(response.status).toBe(400)
77+
expect(mockDb.delete).not.toHaveBeenCalled()
78+
})
79+
80+
it('returns 401 when unauthenticated', async () => {
81+
authMockFns.mockGetSession.mockResolvedValue(null)
82+
83+
const response = await DELETE(
84+
createMockRequest('DELETE'),
85+
routeContext('workflow', 'workflow-1')
86+
)
87+
88+
expect(response.status).toBe(401)
89+
expect(mockDb.delete).not.toHaveBeenCalled()
90+
})
91+
})

0 commit comments

Comments
 (0)