6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
252 lines
9.1 KiB
TypeScript
252 lines
9.1 KiB
TypeScript
import { isDeepStrictEqual } from 'node:util'
|
|
import { isPlainRecord } from '@sim/utils/object'
|
|
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
|
import {
|
|
addModelInputProvenanceToRequest,
|
|
createModelInputProvenanceRequestMetadata,
|
|
createPrivateSecretProvenanceRequestMetadata,
|
|
markModelInputProjected,
|
|
} from '@/lib/execution/model-input-provenance'
|
|
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
|
import type { ToolConfig } from '@/tools/types'
|
|
|
|
const MODEL_INPUT_PROJECTION_ERROR_MESSAGE = 'Model input could not be safely projected'
|
|
const PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE =
|
|
'Private model input provenance is only supported for internal routes'
|
|
const PRIVATE_SECRET_PROVENANCE_EXTERNAL_URL_ERROR_MESSAGE =
|
|
'Private secret provenance is only supported for internal routes'
|
|
|
|
export interface PreparedToolRequest {
|
|
url: string
|
|
method: string
|
|
headers: Headers
|
|
body?: string
|
|
timeout?: number
|
|
proxyUrl?: string
|
|
stripAuthOnRedirect?: boolean
|
|
isInternalRoute: boolean
|
|
}
|
|
|
|
function haveExactOwnKeys(
|
|
selected: Record<string, unknown>,
|
|
projected: Record<string, unknown>
|
|
): boolean {
|
|
const selectedKeys = Reflect.ownKeys(selected)
|
|
const projectedKeys = Reflect.ownKeys(projected)
|
|
return (
|
|
selectedKeys.length === projectedKeys.length &&
|
|
selectedKeys.every((key) => typeof key === 'string' && Object.hasOwn(projected, key)) &&
|
|
projectedKeys.every((key) => typeof key === 'string' && Object.hasOwn(selected, key))
|
|
)
|
|
}
|
|
|
|
export function getOwnEnumerableDataEntries(
|
|
record: Record<string, unknown>
|
|
): Array<[string, unknown]> | undefined {
|
|
const entries: Array<[string, unknown]> = []
|
|
for (const key of Reflect.ownKeys(record)) {
|
|
if (typeof key !== 'string') return undefined
|
|
const descriptor = Object.getOwnPropertyDescriptor(record, key)
|
|
if (!descriptor?.enumerable || !('value' in descriptor)) return undefined
|
|
entries.push([key, descriptor.value])
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function inspectSelectedModelInputRecord(
|
|
tool: ToolConfig,
|
|
selected: unknown
|
|
): { record: Record<string, unknown>; entries: Array<[string, unknown]> } | undefined {
|
|
if (!isPlainRecord(selected)) return undefined
|
|
|
|
const entries = getOwnEnumerableDataEntries(selected)
|
|
if (!entries || entries.some(([key]) => !Object.hasOwn(tool.params, key))) return undefined
|
|
return { record: selected, entries }
|
|
}
|
|
|
|
export function projectToolModelInputParams(
|
|
tool: ToolConfig,
|
|
params: Record<string, any>,
|
|
registry: ResolvedSecretTraceRegistry | undefined
|
|
): Record<string, any> {
|
|
const modelInput = tool.request.modelInput
|
|
if (!registry || modelInput?.mode !== 'project') return params
|
|
|
|
try {
|
|
const selection = inspectSelectedModelInputRecord(tool, modelInput.select(params))
|
|
if (!selection) throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE)
|
|
|
|
const { record: selected, entries } = selection
|
|
const originalSelectedParams: Record<string, unknown> = {}
|
|
for (const [key] of entries) originalSelectedParams[key] = params[key]
|
|
const projection = registry.projectResolvedInputSelection(originalSelectedParams)
|
|
if (!projection.complete) {
|
|
throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE)
|
|
}
|
|
|
|
const projectedParams = { ...params, ...projection.value }
|
|
const projectedSelection = inspectSelectedModelInputRecord(
|
|
tool,
|
|
modelInput.select(projectedParams)
|
|
)
|
|
if (!projectedSelection || !haveExactOwnKeys(selected, projectedSelection.record)) {
|
|
throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE)
|
|
}
|
|
JSON.stringify(projectedSelection.record)
|
|
|
|
if (!modelInput.applyProjected) return projectedParams
|
|
|
|
const selectedParamsClone = structuredClone(originalSelectedParams)
|
|
const patch = inspectSelectedModelInputRecord(
|
|
tool,
|
|
modelInput.applyProjected(selectedParamsClone, projectedSelection.record)
|
|
)
|
|
if (!patch || !haveExactOwnKeys(selected, patch.record)) {
|
|
throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE)
|
|
}
|
|
|
|
const patchedParams = { ...projectedParams, ...patch.record }
|
|
const verification = inspectSelectedModelInputRecord(tool, modelInput.select(patchedParams))
|
|
if (
|
|
!verification ||
|
|
!haveExactOwnKeys(selected, verification.record) ||
|
|
!isDeepStrictEqual(verification.record, projectedSelection.record)
|
|
) {
|
|
throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE)
|
|
}
|
|
|
|
return patchedParams
|
|
} catch {
|
|
throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE)
|
|
}
|
|
}
|
|
|
|
function formatToolRequest(tool: ToolConfig, params: Record<string, any>): PreparedToolRequest {
|
|
const url = typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
|
|
const method =
|
|
typeof tool.request.method === 'function'
|
|
? tool.request.method(params)
|
|
: params.method || tool.request.method || 'GET'
|
|
const headers = new Headers(tool.request.headers ? tool.request.headers(params) : {})
|
|
const hasBody = method !== 'GET' && method !== 'HEAD' && Boolean(tool.request.body)
|
|
const bodyResult = tool.request.body ? tool.request.body(params) : undefined
|
|
const contentType = headers.get('content-type')
|
|
const isPreformattedContent =
|
|
contentType === 'application/x-ndjson' || contentType === 'application/x-www-form-urlencoded'
|
|
|
|
let body: string | undefined
|
|
if (hasBody) {
|
|
if (isPreformattedContent && typeof bodyResult === 'string') {
|
|
body = bodyResult
|
|
} else if (
|
|
isPreformattedContent &&
|
|
bodyResult &&
|
|
typeof bodyResult === 'object' &&
|
|
'body' in bodyResult
|
|
) {
|
|
body = bodyResult.body as string
|
|
} else {
|
|
body = typeof bodyResult === 'string' ? bodyResult : JSON.stringify(bodyResult)
|
|
}
|
|
}
|
|
|
|
const rawTimeout = params.timeout
|
|
const timeout = rawTimeout != null ? Number(rawTimeout) : undefined
|
|
const validTimeout =
|
|
timeout != null && Number.isFinite(timeout) && timeout > 0
|
|
? Math.min(timeout, getMaxExecutionTimeout())
|
|
: undefined
|
|
const proxyUrl =
|
|
typeof params.proxyUrl === 'string' && params.proxyUrl.trim()
|
|
? params.proxyUrl.trim()
|
|
: undefined
|
|
|
|
return {
|
|
url,
|
|
method,
|
|
headers,
|
|
body,
|
|
timeout: validTimeout,
|
|
proxyUrl,
|
|
stripAuthOnRedirect: tool.request.stripAuthOnRedirect,
|
|
isInternalRoute: url.startsWith('/api/'),
|
|
}
|
|
}
|
|
|
|
/** Materializes one tool HTTP request and attaches all private provenance metadata. */
|
|
export function prepareToolRequest(
|
|
tool: ToolConfig,
|
|
params: Record<string, any>,
|
|
registry?: ResolvedSecretTraceRegistry
|
|
): PreparedToolRequest {
|
|
const configuredUrl =
|
|
typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
|
|
const modelInput = tool.request.modelInput
|
|
const secretProvenance = tool.request.secretProvenance
|
|
const hasPrivateModelInputProvenance =
|
|
modelInput?.mode === 'private-provenance' ||
|
|
(modelInput?.mode === 'project' && modelInput.privateInputPaths !== undefined)
|
|
|
|
if (hasPrivateModelInputProvenance && !configuredUrl.startsWith('/api/')) {
|
|
throw new Error(PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE)
|
|
}
|
|
if (secretProvenance && !configuredUrl.startsWith('/api/')) {
|
|
throw new Error(PRIVATE_SECRET_PROVENANCE_EXTERNAL_URL_ERROR_MESSAGE)
|
|
}
|
|
|
|
const requestInput = projectToolModelInputParams(tool, params, registry)
|
|
const request = formatToolRequest(tool, requestInput)
|
|
if (hasPrivateModelInputProvenance && !request.isInternalRoute) {
|
|
throw new Error(PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE)
|
|
}
|
|
if (secretProvenance && !request.isInternalRoute) {
|
|
throw new Error(PRIVATE_SECRET_PROVENANCE_EXTERNAL_URL_ERROR_MESSAGE)
|
|
}
|
|
|
|
const selectedModelInputPaths =
|
|
modelInput?.mode === 'private-provenance'
|
|
? modelInput.inputPaths(requestInput)
|
|
: modelInput?.mode === 'project'
|
|
? modelInput.privateInputPaths?.(requestInput)
|
|
: undefined
|
|
const modelInputMetadata = hasPrivateModelInputProvenance
|
|
? createModelInputProvenanceRequestMetadata(registry, selectedModelInputPaths ?? [])
|
|
: undefined
|
|
const secretProvenanceMetadata = secretProvenance?.request
|
|
? createPrivateSecretProvenanceRequestMetadata(registry, secretProvenance.request(requestInput))
|
|
: undefined
|
|
|
|
if (modelInputMetadata || secretProvenanceMetadata) {
|
|
if (!request.body) throw new Error('Model input provenance requires a JSON request body')
|
|
|
|
let requestBody: unknown
|
|
try {
|
|
requestBody = JSON.parse(request.body)
|
|
} catch {
|
|
throw new Error('Model input provenance requires a JSON request body')
|
|
}
|
|
if (!isPlainRecord(requestBody)) {
|
|
throw new Error('Model input provenance request body is invalid')
|
|
}
|
|
|
|
const bodyWithModelInputProvenance = addModelInputProvenanceToRequest(
|
|
requestBody,
|
|
request.headers,
|
|
modelInputMetadata
|
|
)
|
|
if (modelInputMetadata && modelInput?.mode === 'project') {
|
|
markModelInputProjected(request.headers)
|
|
}
|
|
request.body = JSON.stringify(
|
|
addModelInputProvenanceToRequest(
|
|
bodyWithModelInputProvenance,
|
|
request.headers,
|
|
secretProvenanceMetadata
|
|
)
|
|
)
|
|
}
|
|
if (!request.headers.has('User-Agent')) request.headers.set('User-Agent', 'Sim')
|
|
|
|
return request
|
|
}
|