mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(executor): restore delegated workflow execution (#6539)
* fix(executor): restore delegated workflow execution * fix(executor): trust custom block execution scope * fix(providers): keep tool parameters type safe
This commit is contained in:
@@ -429,3 +429,26 @@ describe('DAGExecutor createExecutionContext useDraftState', () => {
|
||||
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DAGExecutor executor delegation origin', () => {
|
||||
it('copies the canonical origin into the runtime execution context', () => {
|
||||
const executorDelegationOrigin = {
|
||||
subjectUserId: 'user-1',
|
||||
workflowId: 'parent-workflow',
|
||||
executionId: 'parent-execution',
|
||||
}
|
||||
const executor = new DAGExecutor({
|
||||
workflow: { version: '1', blocks: [], connections: [] },
|
||||
contextExtensions: { executorDelegationOrigin },
|
||||
})
|
||||
|
||||
const { context } = (
|
||||
executor as unknown as {
|
||||
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
|
||||
}
|
||||
).createExecutionContext('child-workflow')
|
||||
|
||||
expect(context.workflowId).toBe('child-workflow')
|
||||
expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -421,6 +421,7 @@ export class DAGExecutor {
|
||||
fileKeys: this.contextExtensions.fileKeys,
|
||||
allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope,
|
||||
userId: this.contextExtensions.userId,
|
||||
executorDelegationOrigin: this.contextExtensions.executorDelegationOrigin,
|
||||
isDeployedContext: this.contextExtensions.isDeployedContext,
|
||||
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
|
||||
piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NodeMetadata } from '@/executor/dag/types'
|
||||
import type {
|
||||
BlockLog,
|
||||
BlockState,
|
||||
ExecutorDelegationOrigin,
|
||||
NormalizedBlockOutput,
|
||||
StartBlockRunMetadata,
|
||||
StreamingExecution,
|
||||
@@ -231,6 +232,8 @@ export interface ContextExtensions {
|
||||
fileKeys?: string[]
|
||||
allowLargeValueWorkflowScope?: boolean
|
||||
userId?: string
|
||||
/** Canonical signed execution identity inherited by regular nested workflows. */
|
||||
executorDelegationOrigin?: ExecutorDelegationOrigin
|
||||
/**
|
||||
* Immutable actor/payer decision for this execution. Child workflow
|
||||
* executions receive it here (they carry no full metadata), so internal
|
||||
|
||||
@@ -27,7 +27,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { installStreamingCostPolicy } from '@/providers/cost-policy'
|
||||
import { SIM_AUTO_MODEL_ID } from '@/providers/models'
|
||||
import { getProviderToolInputProvenance } from '@/providers/tool-input-provenance'
|
||||
import {
|
||||
getProviderToolInputProvenance,
|
||||
getProviderToolModelInputRegistry,
|
||||
} from '@/providers/tool-input-provenance'
|
||||
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -1155,6 +1158,61 @@ describe('AgentBlockHandler', () => {
|
||||
expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([])
|
||||
})
|
||||
|
||||
it('binds prompt-exposed placeholders to each provider tool for runtime rebinding', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'TEST_API_KEY_PERSONAL',
|
||||
plaintext: 'personal-secret-value',
|
||||
encryptedValue: 'encrypted-personal-secret',
|
||||
},
|
||||
])
|
||||
registry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [
|
||||
'userPrompt',
|
||||
])
|
||||
registry.recordResolvedInputProjection(
|
||||
['userPrompt'],
|
||||
'Use personal-secret-value',
|
||||
'Use {{TEST_API_KEY_PERSONAL}}'
|
||||
)
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
model: 'gpt-4o',
|
||||
userPrompt: 'Use personal-secret-value',
|
||||
tools: [
|
||||
{
|
||||
type: 'custom-tool',
|
||||
title: 'canary',
|
||||
schema: {
|
||||
function: {
|
||||
name: 'canary',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { secret: { type: 'string' } },
|
||||
required: ['secret'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const [, providerRequest] = mockExecuteProviderRequest.mock.calls[0]
|
||||
const modelInputRegistry = getProviderToolModelInputRegistry(providerRequest.tools[0])
|
||||
expect(providerRequest.messages).toEqual([
|
||||
{ role: 'user', content: 'Use {{TEST_API_KEY_PERSONAL}}' },
|
||||
])
|
||||
expect(
|
||||
modelInputRegistry?.resolveModelExposedEnvReferences({
|
||||
secret: '{{TEST_API_KEY_PERSONAL}}',
|
||||
})
|
||||
).toMatchObject({
|
||||
complete: true,
|
||||
matched: true,
|
||||
value: { secret: 'personal-secret-value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not carry a projected system prompt into Agent output provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' },
|
||||
|
||||
@@ -85,6 +85,7 @@ import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
|
||||
import {
|
||||
type ProviderToolInputProvenance,
|
||||
registerProviderToolInputProvenance,
|
||||
registerProviderToolModelInputRegistry,
|
||||
} from '@/providers/tool-input-provenance'
|
||||
import type { ProviderToolConfig } from '@/providers/types'
|
||||
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
@@ -397,6 +398,11 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
|
||||
const settledInputRegistry = ctx.resolvedSecretTraceRegistry
|
||||
const resultRegistry = settledInputRegistry?.forkForInputPaths([])
|
||||
if (modelInputProjection.registry) {
|
||||
for (const tool of formatted.tools) {
|
||||
registerProviderToolModelInputRegistry(tool, modelInputProjection.registry)
|
||||
}
|
||||
}
|
||||
if (resultRegistry && settledInputRegistry) {
|
||||
for (const [tool, provenance] of formatted.inputProvenance) {
|
||||
registerProviderToolInputProvenance(tool, {
|
||||
@@ -2590,6 +2596,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
},
|
||||
{
|
||||
resolvedSecretTraceRegistry: modelRuntimeRegistry,
|
||||
executionContext: ctx,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generateId } from '@sim/utils/id'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
|
||||
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
|
||||
import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
@@ -55,6 +55,7 @@ export function buildCustomBlockExecutionContext(
|
||||
options: {
|
||||
abortSignal?: AbortSignal
|
||||
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
|
||||
executorDelegationOrigin?: ExecutorDelegationOrigin
|
||||
} = {}
|
||||
): ExecutionContext {
|
||||
// Prefer the invoking agent run's ids so correlation and cancellation both
|
||||
@@ -64,6 +65,7 @@ export function buildCustomBlockExecutionContext(
|
||||
workflowId: context.workflowId ?? 'custom-block-tool',
|
||||
workspaceId: context.workspaceId,
|
||||
userId: context.userId,
|
||||
executorDelegationOrigin: options.executorDelegationOrigin,
|
||||
executionId,
|
||||
isDeployedContext: context.isDeployedContext,
|
||||
// Inherit the accumulated chain so the handler appends + validates depth;
|
||||
|
||||
@@ -582,6 +582,11 @@ describe('WorkflowBlockHandler', () => {
|
||||
expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution)
|
||||
expect(executorOptions[0].contextExtensions.userId).toBe('owner-9')
|
||||
expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source')
|
||||
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
|
||||
subjectUserId: 'owner-9',
|
||||
workflowId: 'source-workflow-id',
|
||||
executionId: loggingSessionArgs[0][1],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds trusted caller metadata for custom block children with the toggle on', async () => {
|
||||
@@ -1225,6 +1230,15 @@ describe('WorkflowBlockHandler', () => {
|
||||
expect(loggingSessionArgs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails before execution when the source child log row cannot be opened', async () => {
|
||||
mockSafeStart.mockResolvedValue(false)
|
||||
|
||||
await expect(handler.execute(customBlockContext(), customBlock(), {})).rejects.toThrow()
|
||||
|
||||
expect(mockExecutorExecute).not.toHaveBeenCalled()
|
||||
expect(executorOptions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('runs the child under its own execution id but keeps the parent readable', async () => {
|
||||
const ctx = customBlockContext()
|
||||
await handler.execute(ctx, customBlock(), {})
|
||||
@@ -1235,6 +1249,24 @@ describe('WorkflowBlockHandler', () => {
|
||||
expect(ctx.largeValueExecutionIds).toContain(extensions.executionId)
|
||||
})
|
||||
|
||||
it('replaces the consumer delegation origin with the source child execution', async () => {
|
||||
const ctx = customBlockContext({
|
||||
executorDelegationOrigin: {
|
||||
subjectUserId: 'consumer-1',
|
||||
workflowId: 'consumer-workflow',
|
||||
executionId: 'parent-execution-id',
|
||||
},
|
||||
})
|
||||
|
||||
await handler.execute(ctx, customBlock(), {})
|
||||
|
||||
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
|
||||
subjectUserId: 'owner-9',
|
||||
workflowId: 'source-workflow-id',
|
||||
executionId: executorOptions[0].contextExtensions.executionId,
|
||||
})
|
||||
})
|
||||
|
||||
it('shares one large-value id list so nested custom blocks propagate upward', async () => {
|
||||
const ctx = customBlockContext()
|
||||
await handler.execute(ctx, customBlock(), {})
|
||||
@@ -1604,9 +1636,49 @@ describe('WorkflowBlockHandler', () => {
|
||||
const extensions = executorOptions[0].contextExtensions
|
||||
expect(extensions.executionId).toBe('parent-execution-id')
|
||||
expect(extensions.resolvedSecretTraceRegistry).toBe(registry)
|
||||
expect(extensions.executorDelegationOrigin).toEqual({
|
||||
subjectUserId: 'user-1',
|
||||
workflowId: 'parent-workflow-id',
|
||||
executionId: 'parent-execution-id',
|
||||
})
|
||||
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(
|
||||
extensions.executorDelegationOrigin
|
||||
)
|
||||
expect(extensions.onStream).toBe(ctx.onStream)
|
||||
expect(extensions.childWorkflowContext).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves the canonical parent origin through deeper regular children', async () => {
|
||||
const ctx = {
|
||||
...mockContext,
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'intermediate-workflow-id',
|
||||
executionId: 'parent-execution-id',
|
||||
executorDelegationOrigin: {
|
||||
subjectUserId: 'user-1',
|
||||
workflowId: 'root-workflow-id',
|
||||
executionId: 'parent-execution-id',
|
||||
},
|
||||
} as ExecutionContext
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
name: 'Grandchild Workflow',
|
||||
workspaceId: 'workspace-1',
|
||||
state: { blocks: [], edges: [], loops: {}, parallels: {} },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await handler.execute(ctx, mockBlock, { workflowId: 'grandchild-workflow-id' })
|
||||
|
||||
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(ctx.executorDelegationOrigin)
|
||||
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toBe(
|
||||
ctx.executorDelegationOrigin
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectCustomBlockOutput', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type BlockHandler,
|
||||
type ExecutionContext,
|
||||
type ExecutionResult,
|
||||
type ExecutorDelegationOrigin,
|
||||
START_BLOCK_METADATA_FIELD,
|
||||
type StartBlockRunMetadata,
|
||||
type StreamingExecution,
|
||||
@@ -278,17 +279,22 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
/** Large-value id list shared with the child (and any nested custom blocks). */
|
||||
let sharedLargeValueIds: string[] | undefined
|
||||
let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined
|
||||
let childExecutorDelegationOrigin: ExecutorDelegationOrigin | undefined
|
||||
/** Settled in `finally` once the child is fully done — see `trackChildRun`. */
|
||||
let settleChildRun: (() => void) | undefined
|
||||
try {
|
||||
if (!loadUserId) {
|
||||
throw new Error('Workflow child loading requires a human execution subject')
|
||||
}
|
||||
const workflowReadHeaders = await buildExecutorDelegationHeaders({
|
||||
subjectUserId: loadUserId,
|
||||
workflowId: isCustomBlock ? workflowId : ctx.workflowId,
|
||||
...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}),
|
||||
})
|
||||
const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock
|
||||
? { subjectUserId: loadUserId, workflowId }
|
||||
: (ctx.executorDelegationOrigin ?? {
|
||||
subjectUserId: loadUserId,
|
||||
workflowId: ctx.workflowId,
|
||||
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
|
||||
})
|
||||
if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin
|
||||
const workflowReadHeaders = await buildExecutorDelegationHeaders(workflowReadDelegationOrigin)
|
||||
|
||||
// A custom block runs the source's latest deployment; if the source has been
|
||||
// undeployed there's nothing to run. `BoundarySafeError` marks the message as
|
||||
@@ -509,10 +515,13 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
...(correlation ? { triggerData: { correlation } } : {}),
|
||||
})
|
||||
if (!childSessionStarted) {
|
||||
logger.error('Custom block child logging failed to start; child spend will be unbilled', {
|
||||
workflowId,
|
||||
childExecutionId,
|
||||
})
|
||||
childExecutionId = undefined
|
||||
throw new Error('Custom block child logging failed to start')
|
||||
}
|
||||
childExecutorDelegationOrigin = {
|
||||
subjectUserId: loadUserId,
|
||||
workflowId,
|
||||
executionId: childExecutionId,
|
||||
}
|
||||
// The child no longer shares the parent's execution id, so it no longer
|
||||
// hears the parent's cancellation event — bridge it explicitly.
|
||||
@@ -599,6 +608,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
workspaceId: childWorkspaceId,
|
||||
userId: childUserId,
|
||||
executorDelegationOrigin: childExecutorDelegationOrigin,
|
||||
executionId: childExecutionId ?? ctx.executionId,
|
||||
// Large values are cached per execution id, so a child running under its
|
||||
// own id still needs the invoking run's id to read values in its inputs.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type CustomBlockExecutorContext,
|
||||
} from '@/executor/handlers/workflow/custom-block-tool-runner'
|
||||
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
||||
import type { ExecutorDelegationOrigin } from '@/executor/types'
|
||||
import { classifyExecutionError } from '@/executor/utils/errors'
|
||||
import { parseJSON } from '@/executor/utils/json'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
@@ -52,6 +53,7 @@ export async function runWorkflowTool(
|
||||
options: {
|
||||
abortSignal?: AbortSignal
|
||||
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
|
||||
executorDelegationOrigin?: ExecutorDelegationOrigin
|
||||
} = {}
|
||||
): Promise<ToolResponse> {
|
||||
if (!params.workflowId) {
|
||||
|
||||
@@ -338,6 +338,19 @@ export interface BlockState {
|
||||
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical signed execution identity used for executor-delegated internal operations.
|
||||
*
|
||||
* A nested workflow changes {@link ExecutionContext.workflowId} for execution semantics, but it
|
||||
* still belongs to the parent log row identified here. Custom blocks replace this origin with the
|
||||
* publisher-owned child execution after opening their own source-workspace log row.
|
||||
*/
|
||||
export interface ExecutorDelegationOrigin {
|
||||
subjectUserId: string
|
||||
workflowId: string
|
||||
executionId?: string
|
||||
}
|
||||
|
||||
export interface ExecutionContext {
|
||||
workflowId: string
|
||||
workspaceId?: string
|
||||
@@ -347,6 +360,8 @@ export interface ExecutionContext {
|
||||
fileKeys?: string[]
|
||||
allowLargeValueWorkflowScope?: boolean
|
||||
userId?: string
|
||||
/** Trusted origin for signed executor delegation, distinct from the currently executing child. */
|
||||
executorDelegationOrigin?: ExecutorDelegationOrigin
|
||||
isDeployedContext?: boolean
|
||||
enforceCredentialAccess?: boolean
|
||||
copilotToolExecution?: boolean
|
||||
|
||||
@@ -185,6 +185,15 @@ export type ResolvedSecretModelEgressSnapshot =
|
||||
| { complete: true; matches: readonly ResolvedSecretTraceMatch[] }
|
||||
| { complete: false }
|
||||
|
||||
export type ResolvedSecretModelReferenceResolution<T> =
|
||||
| {
|
||||
complete: true
|
||||
matched: boolean
|
||||
value: T
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
}
|
||||
| { complete: false }
|
||||
|
||||
export interface ResolvedSecretTraceProvenanceEntryV1 {
|
||||
encryptedValue: string
|
||||
name?: string
|
||||
@@ -803,6 +812,92 @@ export class ResolvedSecretTraceRegistry {
|
||||
return fork
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebinds environment placeholders that were actually projected into a model request.
|
||||
*
|
||||
* The model receives `{{NAME}}`, never the plaintext. A later tool argument may carry that
|
||||
* placeholder back verbatim. Only named entries attached to a resolver-recorded projected input
|
||||
* are eligible here, so inventing another workspace variable name does not grant access to it.
|
||||
* The returned registry contains only references used by this tool call.
|
||||
*/
|
||||
resolveModelExposedEnvReferences<T>(value: T): ResolvedSecretModelReferenceResolution<T> {
|
||||
if (!this.complete) return { complete: false }
|
||||
|
||||
const projectedValueContains = (candidate: unknown, placeholder: string): boolean => {
|
||||
const pending = [candidate]
|
||||
const visited = new WeakSet<object>()
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()
|
||||
if (typeof current === 'string' && current.includes(placeholder)) return true
|
||||
if (current === null || typeof current !== 'object' || visited.has(current)) continue
|
||||
visited.add(current)
|
||||
pending.push(...Object.values(current))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const exposedEntriesByName = new Map<string, ActiveSecretEntry>()
|
||||
for (const state of this.resolvedInputPaths.values()) {
|
||||
if (state.projectedValue === undefined) continue
|
||||
for (const entryKey of state.entryKeys) {
|
||||
const entry = this.activeEntries.get(entryKey)
|
||||
if (
|
||||
!entry ||
|
||||
entry.anonymous ||
|
||||
entry.name.length === 0 ||
|
||||
!projectedValueContains(state.projectedValue, `{{${entry.name}}}`)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const existing = exposedEntriesByName.get(entry.name)
|
||||
if (existing && existing.plaintext !== entry.plaintext) return { complete: false }
|
||||
exposedEntriesByName.set(entry.name, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
|
||||
let matched = false
|
||||
const resolve = (candidate: unknown, path: string[]): unknown => {
|
||||
if (typeof candidate === 'string') {
|
||||
const usedEntries = new Map<string, ActiveSecretEntry>()
|
||||
const resolved = candidate.replace(/\{\{([^{}]+)\}\}/g, (placeholder, rawName) => {
|
||||
const name = String(rawName).trim()
|
||||
const entry = exposedEntriesByName.get(name)
|
||||
if (!entry) return placeholder
|
||||
usedEntries.set(activeEntryKey(entry), entry)
|
||||
return entry.plaintext
|
||||
})
|
||||
if (usedEntries.size === 0) return candidate
|
||||
|
||||
matched = true
|
||||
for (const entry of usedEntries.values()) {
|
||||
registry.addActiveEntry({ ...entry }, { propagated: true })
|
||||
}
|
||||
registry.bindResolvedInputPathEntries(path, usedEntries.values())
|
||||
registry.recordResolvedInputProjection(path, resolved, candidate)
|
||||
return resolved
|
||||
}
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.map((item, index) => resolve(item, [...path, String(index)]))
|
||||
}
|
||||
if (candidate === null || typeof candidate !== 'object') return candidate
|
||||
|
||||
const resolved: Record<string, unknown> = {}
|
||||
for (const [key, child] of Object.entries(candidate)) {
|
||||
resolved[key] = resolve(child, [...path, key])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
const resolvedValue = resolve(value, []) as T
|
||||
return {
|
||||
complete: true,
|
||||
matched,
|
||||
value: resolvedValue,
|
||||
registry,
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges one settled tool-call registry into the turn-scoped registry. */
|
||||
mergeToolCallRegistry(child: ResolvedSecretTraceRegistry): void {
|
||||
if (!scopesMatch(this.scope, child.scope)) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import type { DelegatedPrincipal } from '@sim/auth/principal'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WORKFLOW_DELEGATION_AUDIENCE,
|
||||
workflowDelegationPolicy,
|
||||
} from '@/lib/workflows/application/authorization'
|
||||
import { workflowOperations } from '@/lib/workflows/application/operations'
|
||||
|
||||
function createExecutorPrincipal(overrides: Partial<DelegatedPrincipal> = {}): DelegatedPrincipal {
|
||||
return {
|
||||
kind: 'delegated',
|
||||
serviceId: 'executor',
|
||||
subjectUserId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
delegationId: 'execution-1',
|
||||
audience: WORKFLOW_DELEGATION_AUDIENCE,
|
||||
issuedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
delegationContext: { kind: 'workflow_execution', workflowId: 'parent-workflow' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('workflow delegation policy', () => {
|
||||
it('allows an active execution to read a different workflow in the same workspace', () => {
|
||||
const principal = createExecutorPrincipal()
|
||||
|
||||
expect(
|
||||
workflowDelegationPolicy.isWithinScope(principal, {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'user-1',
|
||||
workflowId: 'child-workflow',
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a child workflow in another workspace', () => {
|
||||
const principal = createExecutorPrincipal()
|
||||
|
||||
expect(
|
||||
workflowDelegationPolicy.isWithinScope(principal, {
|
||||
workspaceId: 'workspace-2',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'user-2',
|
||||
workflowId: 'child-workflow',
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects executor delegation without a canonical workflow execution origin', () => {
|
||||
const principal = createExecutorPrincipal({ delegationContext: undefined })
|
||||
|
||||
expect(
|
||||
workflowDelegationPolicy.isWithinScope(principal, {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'user-1',
|
||||
workflowId: 'child-workflow',
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('permits executor delegation only on workflow reads', () => {
|
||||
expect(workflowOperations.read.delegatedServices).toContain('executor')
|
||||
expect(workflowOperations.update.delegatedServices).not.toContain('executor')
|
||||
expect(workflowOperations.delete.delegatedServices).not.toContain('executor')
|
||||
})
|
||||
})
|
||||
@@ -29,8 +29,7 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy<WorkflowAuthori
|
||||
delegationContext.kind === 'workflow_execution' &&
|
||||
'workflowId' in delegationContext &&
|
||||
typeof delegationContext.workflowId === 'string' &&
|
||||
delegationContext.workflowId.length > 0 &&
|
||||
context.workflowId === delegationContext.workflowId
|
||||
delegationContext.workflowId.length > 0
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createExecutionContext } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockExecuteTool } = vi.hoisted(() => ({
|
||||
@@ -17,7 +18,10 @@ import {
|
||||
executeProviderTool as executeProviderToolWithInput,
|
||||
runWithProviderRuntimeContext,
|
||||
} from '@/providers/runtime-context'
|
||||
import { registerProviderToolInputProvenance } from '@/providers/tool-input-provenance'
|
||||
import {
|
||||
registerProviderToolInputProvenance,
|
||||
registerProviderToolModelInputRegistry,
|
||||
} from '@/providers/tool-input-provenance'
|
||||
import { prepareToolExecution } from '@/providers/utils'
|
||||
|
||||
async function executeProviderTool(
|
||||
@@ -85,6 +89,92 @@ describe('provider runtime context', () => {
|
||||
expect(toolCall?.[2]?.resolvedSecretTraceRegistry).not.toBe(registry)
|
||||
})
|
||||
|
||||
it('passes the trusted execution context to provider-emitted tool calls', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
const executionContext = createExecutionContext({
|
||||
workflowId: 'workflow-parent',
|
||||
environmentVariables: {},
|
||||
})
|
||||
|
||||
await runWithProviderRuntimeContext(
|
||||
{ executionContext, resolvedSecretTraceRegistry: registry },
|
||||
() => executeProviderTool('protected-tool', {})
|
||||
)
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'protected-tool',
|
||||
{},
|
||||
expect.objectContaining({ executionContext })
|
||||
)
|
||||
})
|
||||
|
||||
it('rebinds a prompt-exposed environment placeholder for the exact tool call', async () => {
|
||||
const sourceRegistry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'TEST_API_KEY_PERSONAL',
|
||||
plaintext: 'personal-secret-value',
|
||||
encryptedValue: 'encrypted-personal-secret',
|
||||
},
|
||||
])
|
||||
sourceRegistry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [
|
||||
'userPrompt',
|
||||
])
|
||||
sourceRegistry.recordResolvedInputProjection(
|
||||
['userPrompt'],
|
||||
'Use personal-secret-value',
|
||||
'Use {{TEST_API_KEY_PERSONAL}}'
|
||||
)
|
||||
const modelInputRegistry = sourceRegistry.forkForInputPaths([['userPrompt']])
|
||||
const runtimeRegistry = sourceRegistry.forkForInputPaths([])
|
||||
const tool = {
|
||||
id: 'custom_canary',
|
||||
params: {},
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
}
|
||||
registerProviderToolModelInputRegistry(tool, modelInputRegistry)
|
||||
mockExecuteTool.mockResolvedValueOnce({
|
||||
success: true,
|
||||
output: { reflected: 'personal-secret-value' },
|
||||
})
|
||||
|
||||
const execution = await runWithProviderRuntimeContext(
|
||||
{ resolvedSecretTraceRegistry: runtimeRegistry },
|
||||
() => {
|
||||
const { executionParams } = prepareToolExecution(
|
||||
tool,
|
||||
{ secret: '{{TEST_API_KEY_PERSONAL}}' },
|
||||
{}
|
||||
)
|
||||
return executeProviderToolWithInput(tool.id, executionParams)
|
||||
}
|
||||
)
|
||||
|
||||
expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual({
|
||||
secret: 'personal-secret-value',
|
||||
_toolSchema: { type: 'object', properties: {}, required: [] },
|
||||
})
|
||||
expect(execution.rawResponse.output).toEqual({ reflected: 'personal-secret-value' })
|
||||
expect(execution.modelResponse.output).toEqual({
|
||||
reflected: '{{TEST_API_KEY_PERSONAL}}',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not bind an environment placeholder absent from the model input', async () => {
|
||||
const sourceRegistry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'UNEXPOSED', plaintext: 'hidden-value', encryptedValue: 'encrypted-hidden' },
|
||||
])
|
||||
const tool = {
|
||||
id: 'custom_canary',
|
||||
params: {},
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
}
|
||||
registerProviderToolModelInputRegistry(tool, sourceRegistry)
|
||||
|
||||
const { executionParams } = prepareToolExecution(tool, { secret: '{{UNEXPOSED}}' }, {})
|
||||
|
||||
expect(executionParams.secret).toBe('{{UNEXPOSED}}')
|
||||
})
|
||||
|
||||
it('does not treat an arbitrary tool-result collision as secret provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { getPreparedProviderToolInputProvenance } from '@/providers/tool-input-provenance'
|
||||
import { type ExecuteToolOptions, executeTool } from '@/tools'
|
||||
@@ -9,6 +10,8 @@ import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface ProviderRuntimeContext {
|
||||
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
|
||||
/** Trusted server execution context inherited by model-emitted tool calls. */
|
||||
executionContext?: ExecutionContext
|
||||
}
|
||||
|
||||
export type ExecuteProviderToolOptions = ExecuteToolOptions
|
||||
@@ -68,8 +71,10 @@ export async function executeProviderTool(
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const executionContext = options.executionContext ?? runtimeContext?.executionContext
|
||||
const result = await executeTool(toolId, params, {
|
||||
...options,
|
||||
...(executionContext ? { executionContext } : {}),
|
||||
resolvedSecretTraceRegistry: toolCallRegistry,
|
||||
})
|
||||
if (!registry || !toolCallRegistry) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PreparedProviderToolInputProvenance {
|
||||
|
||||
const configuredToolInputProvenance = new WeakMap<object, ProviderToolInputProvenance>()
|
||||
const preparedToolInputProvenance = new WeakMap<object, PreparedProviderToolInputProvenance>()
|
||||
const modelInputRegistries = new WeakMap<object, ResolvedSecretTraceRegistry>()
|
||||
|
||||
/** Associates one provider tool object with its exact resolver-recorded preset input. */
|
||||
export function registerProviderToolInputProvenance(
|
||||
@@ -32,6 +33,21 @@ export function getProviderToolInputProvenance(
|
||||
return configuredToolInputProvenance.get(tool)
|
||||
}
|
||||
|
||||
/** Associates a provider tool with secrets whose placeholders were visible in this model turn. */
|
||||
export function registerProviderToolModelInputRegistry(
|
||||
tool: object,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): void {
|
||||
modelInputRegistries.set(tool, registry)
|
||||
}
|
||||
|
||||
/** Reads the model-visible placeholder allowlist for the exact provider tool instance. */
|
||||
export function getProviderToolModelInputRegistry(
|
||||
tool: object
|
||||
): ResolvedSecretTraceRegistry | undefined {
|
||||
return modelInputRegistries.get(tool)
|
||||
}
|
||||
|
||||
/** Associates one prepared execution object with its isolated transformed-input registry. */
|
||||
export function registerPreparedProviderToolInputProvenance(
|
||||
executionParams: object,
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from '@/providers/models'
|
||||
import {
|
||||
getProviderToolInputProvenance,
|
||||
getProviderToolModelInputRegistry,
|
||||
registerPreparedProviderToolInputProvenance,
|
||||
} from '@/providers/tool-input-provenance'
|
||||
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
|
||||
@@ -1563,11 +1564,27 @@ export function prepareToolExecution(
|
||||
// scoped to "Selected secrets" with an empty list is an explicit deny, and a
|
||||
// model emitting `mountedSecrets: ['STRIPE_KEY']` would otherwise mount it.
|
||||
const modelParams = stripModelBlockedParams(tool.modelBlockedParams, llmArgs)
|
||||
let toolParams = mergeToolParameters(tool.params || {}, modelParams) as Record<string, any>
|
||||
const modelInputRegistry = getProviderToolModelInputRegistry(tool)
|
||||
const modelReferenceResolution = modelInputRegistry?.resolveModelExposedEnvReferences(modelParams)
|
||||
if (modelReferenceResolution && !modelReferenceResolution.complete) {
|
||||
throw new Error('Agent tool input environment references could not be safely resolved')
|
||||
}
|
||||
const resolvedModelParams = modelReferenceResolution?.value ?? modelParams
|
||||
let toolParams = mergeToolParameters(tool.params || {}, resolvedModelParams)
|
||||
const inputProvenance = getProviderToolInputProvenance(tool)
|
||||
const inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath])
|
||||
let projectedToolParams = inputProvenance
|
||||
? (mergeToolParameters(inputProvenance.projectedParams, modelParams) as Record<string, any>)
|
||||
let inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath])
|
||||
if (modelReferenceResolution?.matched) {
|
||||
if (inputRegistry) {
|
||||
inputRegistry.mergeToolCallRegistry(modelReferenceResolution.registry)
|
||||
} else {
|
||||
inputRegistry = modelReferenceResolution.registry
|
||||
}
|
||||
}
|
||||
if (inputRegistry && !inputRegistry.isComplete()) {
|
||||
throw new Error('Agent tool input environment references could not be safely resolved')
|
||||
}
|
||||
let projectedToolParams = inputRegistry
|
||||
? mergeToolParameters(inputProvenance?.projectedParams ?? tool.params ?? {}, modelParams)
|
||||
: undefined
|
||||
|
||||
if (tool.paramsTransform) {
|
||||
@@ -1622,7 +1639,7 @@ export function prepareToolExecution(
|
||||
...(tool.parameters ? { _toolSchema: tool.parameters } : {}),
|
||||
}
|
||||
|
||||
if (inputProvenance && inputRegistry) {
|
||||
if (inputRegistry) {
|
||||
const inputPaths = [['params']] as const
|
||||
if (projectedToolParams) {
|
||||
inputRegistry.recordTransformedInputProjection(
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { fileGetContentTool } from '@/tools/file/get'
|
||||
import { memoryAddTool } from '@/tools/memory/add'
|
||||
import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows'
|
||||
import { customBlockExecutorTool } from '@/tools/workflow/custom-block-executor'
|
||||
import { workflowExecutorTool } from '@/tools/workflow/executor'
|
||||
|
||||
// Hoisted mock state - these are available to vi.mock factories
|
||||
@@ -47,6 +48,7 @@ const {
|
||||
mockGetCustomToolById,
|
||||
mockListCustomTools,
|
||||
mockMarkWorkspaceFileSecretProvenanceUnknown,
|
||||
mockRunCustomBlockTool,
|
||||
mockRunWorkflowTool,
|
||||
mockGetCustomToolByIdOrTitle,
|
||||
mockGenerateInternalDelegationToken,
|
||||
@@ -63,6 +65,7 @@ const {
|
||||
mockGetCustomToolById: vi.fn(),
|
||||
mockListCustomTools: vi.fn(),
|
||||
mockMarkWorkspaceFileSecretProvenanceUnknown: vi.fn(),
|
||||
mockRunCustomBlockTool: vi.fn(),
|
||||
mockRunWorkflowTool: vi.fn(),
|
||||
mockGetCustomToolByIdOrTitle: vi.fn(),
|
||||
mockGenerateInternalDelegationToken: vi.fn(),
|
||||
@@ -130,9 +133,14 @@ vi.mock('@/executor/handlers/workflow/workflow-tool-runner', () => ({
|
||||
runWorkflowTool: (...args: unknown[]) => mockRunWorkflowTool(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({
|
||||
runCustomBlockTool: (...args: unknown[]) => mockRunCustomBlockTool(...args),
|
||||
}))
|
||||
|
||||
// Mock the tools registry to avoid loading the full 4500+ line registry file.
|
||||
// Only the tools actually exercised in tests are provided.
|
||||
const mockRegistryTools: Record<string, any> = {
|
||||
deployed_block_executor: customBlockExecutorTool,
|
||||
workflow_executor: workflowExecutorTool,
|
||||
file_get_content: fileGetContentTool,
|
||||
memory_add: memoryAddTool,
|
||||
@@ -774,6 +782,36 @@ describe('executeTool Function', () => {
|
||||
expect(new Headers(request?.headers).get('authorization')).toBe('Bearer executor-token')
|
||||
})
|
||||
|
||||
it('uses the canonical parent origin for protected tools inside a nested workflow', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const executionContext = createToolExecutionContext({
|
||||
userId: 'trusted-user',
|
||||
workflowId: 'child-workflow',
|
||||
executionId: 'parent-execution',
|
||||
executorDelegationOrigin: {
|
||||
subjectUserId: 'trusted-user',
|
||||
workflowId: 'parent-workflow',
|
||||
executionId: 'parent-execution',
|
||||
},
|
||||
})
|
||||
await executeTool('test_executor_delegation', {}, { executionContext })
|
||||
|
||||
expect(mockGenerateInternalDelegationToken).toHaveBeenCalledWith({
|
||||
subjectUserId: 'trusted-user',
|
||||
workflowId: 'parent-workflow',
|
||||
executionId: 'parent-execution',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects protected internal tools without trusted executor scope before transport', async () => {
|
||||
const result = await executeTool('test_executor_delegation', {
|
||||
_context: {
|
||||
@@ -1481,6 +1519,55 @@ describe('executeTool Function', () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('overwrites custom-block tool context with the trusted workflow scope', async () => {
|
||||
const executionContext = createToolExecutionContext({
|
||||
userId: 'trusted-user',
|
||||
workflowId: 'trusted-workflow',
|
||||
workspaceId: 'trusted-workspace',
|
||||
executionId: 'trusted-execution',
|
||||
callChain: ['trusted-parent'],
|
||||
isDeployedContext: true,
|
||||
})
|
||||
mockRunCustomBlockTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
|
||||
|
||||
await executeTool(
|
||||
'deployed_block_executor_custom_block_123',
|
||||
{
|
||||
blockType: 'custom_block_123',
|
||||
_context: {
|
||||
userId: 'forged-user',
|
||||
workflowId: 'forged-workflow',
|
||||
workspaceId: 'forged-workspace',
|
||||
executionId: 'forged-execution',
|
||||
callChain: ['forged-parent'],
|
||||
isDeployedContext: false,
|
||||
billingAttribution: { forged: true },
|
||||
},
|
||||
},
|
||||
{ executionContext }
|
||||
)
|
||||
|
||||
expect(mockRunCustomBlockTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
blockType: 'custom_block_123',
|
||||
_context: expect.objectContaining({
|
||||
userId: 'trusted-user',
|
||||
workflowId: 'trusted-workflow',
|
||||
workspaceId: 'trusted-workspace',
|
||||
executionId: 'trusted-execution',
|
||||
callChain: ['trusted-parent'],
|
||||
isDeployedContext: true,
|
||||
billingAttribution: TEST_BILLING_ATTRIBUTION,
|
||||
requestId: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resolvedSecretTraceRegistry: undefined,
|
||||
})
|
||||
)
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters cross-scope workflow provenance to literals present in the unchanged result', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([], {
|
||||
userId: 'parent-user',
|
||||
|
||||
@@ -158,6 +158,13 @@ function resolveInternalExecutorDelegation(
|
||||
if (!executionContext?.userId || !executionContext.workflowId) {
|
||||
throw new Error('Executor delegation requires a trusted workflow execution context')
|
||||
}
|
||||
if (executionContext.executorDelegationOrigin) {
|
||||
const origin = executionContext.executorDelegationOrigin
|
||||
if (!origin.subjectUserId || !origin.workflowId) {
|
||||
throw new Error('Executor delegation origin requires an authenticated user and workflow')
|
||||
}
|
||||
return origin
|
||||
}
|
||||
return {
|
||||
subjectUserId: executionContext.userId,
|
||||
workflowId: executionContext.workflowId,
|
||||
@@ -1780,6 +1787,7 @@ async function executeToolImplementation(
|
||||
{
|
||||
abortSignal: effectiveSignal,
|
||||
resolvedSecretTraceRegistry,
|
||||
executorDelegationOrigin: executionContext?.executorDelegationOrigin,
|
||||
}
|
||||
)
|
||||
const endTime = new Date()
|
||||
@@ -1807,7 +1815,15 @@ async function executeToolImplementation(
|
||||
...contextParams,
|
||||
_context: {
|
||||
...(contextParams._context as Record<string, unknown> | undefined),
|
||||
...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
|
||||
...(scope.workflowId ? { workflowId: scope.workflowId } : {}),
|
||||
...(scope.userId ? { userId: scope.userId } : {}),
|
||||
...(scope.executionId ? { executionId: scope.executionId } : {}),
|
||||
...(scope.callChain ? { callChain: scope.callChain } : {}),
|
||||
...(scope.isDeployedContext !== undefined
|
||||
? { isDeployedContext: scope.isDeployedContext }
|
||||
: {}),
|
||||
...(scope.billingAttribution ? { billingAttribution: scope.billingAttribution } : {}),
|
||||
requestId,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user