Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/add-block/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ When the user asks you to create a block:

Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs.

When block work changes tool execution, same-process work must use a registered
`InternalToolConfig.operation`. Never add a Sim `/api/...` self-hop or the retired
`directExecution` property.

- Do NOT invent block outputs for undocumented tool responses
- Do NOT describe unknown JSON shapes as if they were confirmed
- Do NOT wire fields into the block just because they seem likely to exist
Expand Down
6 changes: 3 additions & 3 deletions .agents/skills/add-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Choose the tool boundary before writing the declaration:
- Use `ToolConfig.request` only for an absolute external HTTP(S) provider endpoint.

Never point a tool at `/api/...`, construct an absolute URL back to Sim, declare
`request.internal`, or add an API route merely to reuse code, normalize files, or authorize
`request.internal`, add the retired `directExecution` property, or add an API route merely to reuse code, normalize files, or authorize
resources. A real external/browser route and an in-process tool may share the same operation, but
neither calls the other. Follow the full transport and handler rules in the `add-tools` skill.

Expand Down Expand Up @@ -171,7 +171,7 @@ Hard rules:
- Never substitute secret plaintext into source or serialize plaintext provenance.
- Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns
transport and strips private metadata from functional results.
- Never attach private provenance to an external URL or to `directExecution`. Project proven
- Never attach private provenance to an external URL. Project proven
model-visible external fields with `request.modelInput`; otherwise preserve ordinary request
semantics. Use a registered in-process operation when encrypted provenance must cross the
boundary.
Expand Down Expand Up @@ -607,7 +607,7 @@ If creating V2 versions (API-aligned outputs):
- [ ] Chose exactly one boundary per tool: registered `InternalToolConfig.operation` or absolute
external HTTP(S) `ToolConfig.request`
- [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal`, or
has an HTTP fallback for an in-process operation
`directExecution`, or has an HTTP fallback for an in-process operation
- [ ] All params have correct visibility
- [ ] All nullable fields use `?? null`
- [ ] All optional outputs have `optional: true`
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/add-tools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Every tool must use exactly one of these configurations:
HTTP(S) provider endpoint.

Never set a tool URL to `/api/...`, construct an absolute URL back to Sim, declare
`request.internal`, import a route module, or create an API route merely to normalize files,
`request.internal`, add the retired `directExecution` property, import a route module, or create an API route merely to normalize files,
authorize access, or reuse server code. A real browser/API route may remain as a thin adapter, but
the route and the tool must call the same operation directly. A true cross-process/capability
boundary uses an explicit server client and is not disguised as a tool self-hop.
Expand Down Expand Up @@ -524,6 +524,7 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`
HTTP(S) `ToolConfig.request`
- [ ] No tool request points to `/api/...`, constructs a URL back to Sim, or declares
`request.internal`
- [ ] No tool declares `directExecution`; in-process work uses a registered operation
- [ ] All params have explicit `required: true` or `required: false`
- [ ] All params have appropriate `visibility`
- [ ] All nullable response fields use `?? null`
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/add-trigger/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,8 @@ Two rules the checks enforce:
Webhook and polling routes are legitimate external ingress boundaries. They must not call this
Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation
or authorized application use case and call it directly from the trigger handler and any other
server adapter. HTTP is reserved for an actual cross-process/capability boundary.
server adapter. HTTP is reserved for an actual cross-process/capability boundary. Tool work uses a
registered `InternalToolConfig.operation`; the retired `directExecution` property must not return.

### Trigger Definition
- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
Expand Down
10 changes: 8 additions & 2 deletions .agents/skills/tool-registry-boundary/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec

> Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**.

`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules.
`@/tools/registry` is a ~9,000-line barrel importing every tool. External `ToolConfig` entries mix
plain data (`params`, `outputs`, `name`) with request/response closures, while
`InternalToolConfig` entries contain semantic input projection and load their server implementation
through `lib/internal/tool-operations/registry.server.ts`. Request closures can still reach SDK
clients, API helpers, and parsers, which is what makes the executable barrel expensive: reaching it
costs ~4,700 additional modules.

`getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in.

Expand Down Expand Up @@ -95,4 +100,5 @@ The canvas route reached the registry through **four** redundant edges — `prov

Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph.

If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths.
If it genuinely executes — builds an external request, transforms a response, or dispatches a
registered internal operation — use `getTool`, and keep that file off client-reachable paths.
5 changes: 3 additions & 2 deletions .agents/skills/validate-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ search, extraction, or "AI-powered" marketing terminology.
- [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use
field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection
and scope, strip private metadata, and persist, import, or propagate it at the owning boundary
- [ ] Private provenance is never attached to external URLs or `directExecution`; proven
model-visible external fields use projection, while other external inputs remain unchanged
- [ ] Private provenance is never attached to external URLs; registered in-process operations
preserve it through `operation.modelInput` / `operation.secretProvenance`, while proven
model-visible external fields use request projection and other external inputs remain unchanged
- [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance
- [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results;
only execution-scoped, activated Sim provenance is projected at shared model/log boundaries
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/api/tools/netsuite/objects/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ vi.mock('@/lib/oauth/credential-service', () => ({
resolveCredentialAccessToken: mockResolveCredentialAccessToken,
resolveOAuthAccountId: mockResolveOAuthAccountId,
}))
vi.mock('@/tools/netsuite/get_async_status', () => ({
netsuiteGetAsyncStatusTool: { directExecution: mockGetAsyncStatus },
vi.mock('@/lib/internal/netsuite/operations/get-async-status', () => ({
executeNetsuiteGetAsyncStatusOperation: mockGetAsyncStatus,
}))
vi.mock('@/tools/netsuite/list_record_types', () => ({
netsuiteListRecordTypesTool: { directExecution: mockListRecordTypes },
vi.mock('@/lib/internal/netsuite/operations/list-record-types', () => ({
executeNetsuiteListRecordTypesOperation: mockListRecordTypes,
}))

import { POST } from '@/app/api/tools/netsuite/objects/route'
Expand Down
15 changes: 7 additions & 8 deletions apps/sim/app/api/tools/netsuite/objects/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { executeNetsuiteGetAsyncStatusOperation } from '@/lib/internal/netsuite/operations/get-async-status'
import { executeNetsuiteListRecordTypesOperation } from '@/lib/internal/netsuite/operations/list-record-types'
import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status'
import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types'
import type { NetSuiteAuthParams } from '@/tools/netsuite/types'
import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils'
import type { ToolResponse } from '@/tools/types'
Expand Down Expand Up @@ -180,14 +180,13 @@ async function executeDiscoveryTool(
throwIfAborted(signal)
switch (body.kind) {
case 'record_types': {
const execute = netsuiteListRecordTypesTool.directExecution
if (!execute) throw new Error('NetSuite record-type tool is not executable')
return execute(auth, signal)
return executeNetsuiteListRecordTypesOperation(auth, signal)
}
case 'async_tasks': {
const execute = netsuiteGetAsyncStatusTool.directExecution
if (!execute) throw new Error('NetSuite asynchronous-status tool is not executable')
return execute({ ...auth, jobId: body.jobId, view: 'tasks' }, signal)
return executeNetsuiteGetAsyncStatusOperation(
{ ...auth, jobId: body.jobId, view: 'tasks' },
signal
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,12 +416,12 @@ export function PreviewWorkflow({

// Check for direct error on the subflow block itself (e.g., loop resolution errors)
// before falling back to children-derived status
const directExecution = blockExecutionMap.get(blockId)
const blockExecution = blockExecutionMap.get(blockId)
const subflowExecutionStatus: ExecutionStatus | undefined =
directExecution?.status === 'error'
blockExecution?.status === 'error'
? 'error'
: (getSubflowExecutionStatus(blockId) ??
(directExecution ? (directExecution.status as ExecutionStatus) : undefined))
(blockExecution ? (blockExecution.status as ExecutionStatus) : undefined))

nodeArray.push({
id: blockId,
Expand Down
35 changes: 35 additions & 0 deletions apps/sim/lib/internal/bitbucket/execute-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
executeBitbucketGetFileOperation,
executeBitbucketGetPipelineStepLogOperation,
executeBitbucketGetPullRequestDiffOperation,
executeBitbucketGetPullRequestDiffstatOperation,
} from '@/lib/internal/bitbucket/operations'
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'

export const executeBitbucketTool: InternalToolOperationHandler = async (request) => {
switch (request.toolId) {
case 'bitbucket_get_file':
return executeToolOperationImplementation(executeBitbucketGetFileOperation, request)
case 'bitbucket_get_pipeline_step_log':
return executeToolOperationImplementation(
executeBitbucketGetPipelineStepLogOperation,
request
)
case 'bitbucket_get_pull_request_diff':
return executeToolOperationImplementation(
executeBitbucketGetPullRequestDiffOperation,
request
)
case 'bitbucket_get_pull_request_diffstat':
return executeToolOperationImplementation(
executeBitbucketGetPullRequestDiffstatOperation,
request
)
default:
return Response.json(
{ success: false, error: `Unsupported bitbucket tool: ${request.toolId}` },
{ status: 500 }
)
}
}
65 changes: 65 additions & 0 deletions apps/sim/lib/internal/bitbucket/operations/get-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
import { fileUrl } from '@/tools/bitbucket/get_file'
import type { BitbucketGetFileParams } from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
bitbucketHeaders,
bitbucketHeadRange,
bitbucketJson,
bitbucketMaxCharacters,
bitbucketRawHead,
normalizeBitbucketFileMetadata,
} from '@/tools/bitbucket/utils'

export const executeBitbucketGetFileOperation: InternalToolOperationImplementation<
BitbucketGetFileParams
> = async (params, signal) => {
bitbucketMaxCharacters(params.maxCharacters)
const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server')
const metadataResponse = await secureBitbucketRead(
fileUrl(params, true),
bitbucketHeaders(params.accessToken),
256 * 1024,
{ stripAuthOnRedirect: true, signal }
)
await assertBitbucketResponseOk(metadataResponse)
const metadata = normalizeBitbucketFileMetadata(await bitbucketJson(metadataResponse))
if (metadata.isBinary === true) {
return {
success: true,
output: {
content: null,
binary: true,
truncated: metadata.size === null ? null : metadata.size > 0,
returnedBytes: 0,
fullBytes: metadata.size,
contentType: null,
},
}
}

const rawResponse = await secureBitbucketRead(
fileUrl(params),
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketHeadRange(params.maxCharacters),
}),
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
{ stripAuthOnRedirect: true, signal }
)
await assertBitbucketResponseOk(rawResponse)
const raw = await bitbucketRawHead(rawResponse, params.maxCharacters, metadata.isBinary)
const fullBytes = raw.fullBytes ?? metadata.size
return {
success: true,
output: {
...raw,
truncated:
raw.binary === true && raw.truncated === null && fullBytes !== null
? fullBytes > 0
: raw.truncated,
fullBytes,
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
import {
BITBUCKET_RANGE_NOT_SATISFIABLE,
EMPTY_CONTENT_RANGE_PATTERN,
stepLogUrl,
} from '@/tools/bitbucket/get_pipeline_step_log'
import type { BitbucketGetPipelineStepLogParams } from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_LOG_TRANSFER_MAX_BYTES,
bitbucketHeaders,
bitbucketMaxCharacters,
bitbucketRawTail,
bitbucketTailRange,
} from '@/tools/bitbucket/utils'

export const executeBitbucketGetPipelineStepLogOperation: InternalToolOperationImplementation<
BitbucketGetPipelineStepLogParams
> = async (params, signal) => {
bitbucketMaxCharacters(params.maxCharacters, true)
const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server')
const response = await secureBitbucketRead(
stepLogUrl(params),
bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketTailRange(params.maxCharacters),
}),
BITBUCKET_LOG_TRANSFER_MAX_BYTES,
{ stripAuthOnRedirect: true, signal }
)
if (
response.status === BITBUCKET_RANGE_NOT_SATISFIABLE &&
EMPTY_CONTENT_RANGE_PATTERN.test(response.headers.get('content-range') ?? '')
) {
await response.body?.cancel()
return { success: true, output: { log: '', truncated: false, totalBytes: 0 } }
}
await assertBitbucketResponseOk(response)
return { success: true, output: await bitbucketRawTail(response, params.maxCharacters) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
import { pullRequestDiffUrl, transformDiff } from '@/tools/bitbucket/get_pull_request_diff'
import type { BitbucketGetPullRequestDiffParams } from '@/tools/bitbucket/types'
import {
assertBitbucketResponseOk,
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
bitbucketHeaders,
bitbucketHeadRange,
bitbucketRepositoryPathQuery,
} from '@/tools/bitbucket/utils'

export const executeBitbucketGetPullRequestDiffOperation: InternalToolOperationImplementation<
BitbucketGetPullRequestDiffParams
> = async (params, signal) => {
const { secureBitbucketPullRequestRedirect } = await import('@/tools/bitbucket/utils.server')
const headers = bitbucketHeaders(params.accessToken, {
json: false,
range: bitbucketHeadRange(params.maxCharacters),
})
const response = await secureBitbucketPullRequestRedirect(
pullRequestDiffUrl(params),
params.workspaceSlug,
params.repoSlug,
'diff',
headers,
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
{
signal,
targetQuery: { path: bitbucketRepositoryPathQuery(params.path), binary: 'false' },
}
)
await assertBitbucketResponseOk(response)
return transformDiff(response, params.maxCharacters)
}
Loading
Loading